From 56cb833fe9ad4fdde830cd8e2d1ad7c82ae3a5a8 Mon Sep 17 00:00:00 2001 From: Sebastian Mendel Date: Thu, 23 Jul 2026 10:35:34 +0200 Subject: [PATCH] fix(audit): merge --update-baseline results into the existing baseline cmd_update_baseline started from an empty UpstreamCache, so a tool-scoped run ('audit.py --update-baseline vault') rewrote upstream_versions.json with only the requested tool, dropping every other committed entry. A transient collection failure likewise deleted that tool's entry even in full runs. Load the existing baseline and update only the collected entries. The 'Collected N versions' summary now counts this run's successful collections instead of the merged map size. The orphaned UpstreamCache import is removed. Fixes #125 Signed-off-by: Sebastian Mendel --- audit.py | 12 ++++++--- tests/test_update_fixes.py | 54 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 4 deletions(-) diff --git a/audit.py b/audit.py index cb34196..c12f48a 100755 --- a/audit.py +++ b/audit.py @@ -36,7 +36,7 @@ from cli_audit.logging_config import setup_logging # noqa: E402 # Split file support (Phase 2.1) from cli_audit.upstream_cache import ( # noqa: E402 - UpstreamVersion, UpstreamCache, + UpstreamVersion, load_upstream_cache, write_upstream_cache, get_upstream_cache_path, update_cached_upstream, ) @@ -1025,9 +1025,12 @@ def cmd_update_baseline(args: argparse.Namespace) -> int: print(f"# Collecting upstream versions for {total} tools...", file=sys.stderr) - # Collect upstream versions only - upstream_cache = UpstreamCache() + # Collect upstream versions only. Merge into the existing baseline so a + # tool-scoped run (or a transient collection failure) never drops the + # entries that were not collected this run (issue #125). + upstream_cache = load_upstream_cache() completed = 0 + collected = 0 with ThreadPoolExecutor(max_workers=min(MAX_WORKERS, total)) as executor: future_to_tool = {} @@ -1048,6 +1051,7 @@ def cmd_update_baseline(args: argparse.Namespace) -> int: ) upstream_cache.versions[tool.name] = version completed += 1 + collected += 1 display = latest_num or latest_tag or "n/a" print(f"# [{completed}/{total}] {tool.name}: {display}", file=sys.stderr) @@ -1059,7 +1063,7 @@ def cmd_update_baseline(args: argparse.Namespace) -> int: write_upstream_cache(upstream_cache) print("", file=sys.stderr) print(f"✓ Upstream baseline updated: {get_upstream_cache_path()}", file=sys.stderr) - print(f"✓ Collected {len(upstream_cache.versions)} versions", file=sys.stderr) + print(f"✓ Collected {collected}/{total} versions", file=sys.stderr) # Report rate limit rate_limit = get_github_rate_limit() diff --git a/tests/test_update_fixes.py b/tests/test_update_fixes.py index 8e0e294..31a27d3 100644 --- a/tests/test_update_fixes.py +++ b/tests/test_update_fixes.py @@ -1260,3 +1260,57 @@ def test_refreshes_installed_but_preserves_latest(self, tmp_path): assert entry["installed_version"] not in ("", "0.0.1"), "installed must be refreshed" # directional status: real git (e.g. 2.x) < 999.0.0 -> OUTDATED assert entry["status"] == "OUTDATED" + + +@skip_on_windows +class TestUpdateBaselineMerges: + """A tool-scoped `--update-baseline ` must MERGE the collected entry + into the existing committed baseline, never replace the whole file with only + the requested tool (issue #125: `--update-baseline vault` dropped every other + entry). Failed collections must likewise leave the existing entry intact.""" + + def _seed_baseline(self, path): + path.write_text(json.dumps({ + "__meta__": {"baseline_updated_at": "2026-01-01T00:00:00Z", + "schema_version": 2, "source": "test"}, + "versions": { + "keepme": {"latest_tag": "v1.0.0", "latest_version": "1.0.0", + "latest_url": "", "tool_url": "", "upstream_method": "gh"}, + "ripgrep": {"latest_tag": "v0.0.1", "latest_version": "0.0.1", + "latest_url": "", "tool_url": "", "upstream_method": "gh"}, + }, + })) + + def test_single_tool_update_preserves_other_entries(self, tmp_path, monkeypatch): + import argparse + import audit + baseline = tmp_path / "upstream_versions.json" + self._seed_baseline(baseline) + monkeypatch.setenv("CLI_AUDIT_UPSTREAM_FILE", str(baseline)) + + with patch.object(audit, "collect_latest_version", return_value=("v9.9.9", "9.9.9")), \ + patch.object(audit, "get_github_rate_limit", return_value=None): + rc = audit.cmd_update_baseline(argparse.Namespace(tools=["ripgrep"])) + + assert rc == 0 + data = json.loads(baseline.read_text()) + assert data["versions"]["ripgrep"]["latest_version"] == "9.9.9" + assert "keepme" in data["versions"], "unrelated entries must survive a tool-scoped run" + assert data["versions"]["keepme"]["latest_version"] == "1.0.0" + + def test_failed_collection_keeps_existing_entry(self, tmp_path, monkeypatch): + import argparse + import audit + baseline = tmp_path / "upstream_versions.json" + self._seed_baseline(baseline) + monkeypatch.setenv("CLI_AUDIT_UPSTREAM_FILE", str(baseline)) + + with patch.object(audit, "collect_latest_version", side_effect=RuntimeError("network down")), \ + patch.object(audit, "get_github_rate_limit", return_value=None): + rc = audit.cmd_update_baseline(argparse.Namespace(tools=["ripgrep"])) + + assert rc == 0 + data = json.loads(baseline.read_text()) + # transient failure must not delete the committed entry + assert data["versions"]["ripgrep"]["latest_version"] == "0.0.1" + assert "keepme" in data["versions"]