From 9c597a2a4334c3dd5afd9d28fd321ed0a46aa405 Mon Sep 17 00:00:00 2001 From: Sebastian Mendel Date: Sun, 21 Jun 2026 00:42:06 +0200 Subject: [PATCH 1/2] fix(guide): refresh installed state at startup, preserving latest The guide rendered "installed:" from a cached snapshot while the installers detect "before:" live, so a stale snapshot showed e.g. "installed: not installed" directly above "before: 10.5.0" (and "installed: 11.12.1" above "before: 11.17.0"). Refresh the snapshot's installed state, network-free, at guide startup so the two agree. The full `audit.py --update-local` path now refreshes installed_version + status from live detection but PRESERVES each tool's existing latest_version instead of rebuilding it from the older committed baseline. This keeps the refresh offline, avoids showing a target lower than installed, and keeps make-update and make-upgrade in agreement. Multi-version cycles are left to make update / per-cycle re-audit (no per-cycle local-only data). Signed-off-by: Sebastian Mendel --- Makefile.d/user.mk | 2 +- audit.py | 32 ++++++++++++++++++++++++++++---- scripts/guide.sh | 7 ++++++- tests/test_update_fixes.py | 37 +++++++++++++++++++++++++++++++++++++ 4 files changed, 72 insertions(+), 6 deletions(-) diff --git a/Makefile.d/user.mk b/Makefile.d/user.mk index d4d07b0..ed25d4f 100644 --- a/Makefile.d/user.mk +++ b/Makefile.d/user.mk @@ -20,7 +20,7 @@ update: ## Collect fresh version data with network calls and update snapshot (~1 @$(MAKE) check-python-managers 2>/dev/null || true @$(MAKE) check-node-managers 2>/dev/null || true -upgrade: scripts-perms ## Run interactive upgrade guide (uses snapshot, no network calls) +upgrade: scripts-perms ## Run interactive upgrade guide (refreshes installed state first, no network calls) @bash scripts/guide.sh cleanup: scripts-perms ## Interactive removal of installed tools diff --git a/audit.py b/audit.py index de3b154..7b538d7 100755 --- a/audit.py +++ b/audit.py @@ -955,10 +955,34 @@ def cmd_update_local(args: argparse.Namespace) -> int: merged_tools = list(tools_by_name.values()) write_snapshot(merged_tools, offline=OFFLINE_MODE) else: - # Full update: replace entire snapshot - legacy_snapshot = build_legacy_snapshot(upstream_cache, local_state) - write_snapshot(legacy_snapshot.get("tools", []), offline=OFFLINE_MODE) - print(f"✓ Legacy snapshot updated: {get_snapshot_path()}", file=sys.stderr) + # Full update: refresh the snapshot's installed_version + status from the + # fresh local detection, but PRESERVE each tool's existing latest_version. + # A network-free refresh must NOT clobber the upstream "latest" that + # `make update` collected (the committed baseline is older) -- otherwise + # make-update and make-upgrade disagree and the guide shows a target that + # is lower than the installed version. + existing = load_snapshot().get("tools", []) + if existing: + for entry in existing: + name = entry.get("tool", "") + if "@" in name: + continue # multi-version cycle: no per-cycle local-only data + inst = local_state.tools.get(name) + if inst is None: + continue + latest = entry.get("latest_version", "") + entry["installed"] = inst.installed_version + entry["installed_version"] = inst.installed_version + entry["installed_method"] = inst.installed_method + entry["installed_path_selected"] = inst.installed_path + entry["status"] = compute_status(inst.installed_version, latest) + write_snapshot(existing, offline=OFFLINE_MODE) + print(f"✓ Legacy snapshot refreshed (installed state): {get_snapshot_path()}", file=sys.stderr) + else: + # No snapshot yet -- build one from the upstream baseline. + legacy_snapshot = build_legacy_snapshot(upstream_cache, local_state) + write_snapshot(legacy_snapshot.get("tools", []), offline=OFFLINE_MODE) + print(f"✓ Legacy snapshot updated: {get_snapshot_path()}", file=sys.stderr) return 0 diff --git a/scripts/guide.sh b/scripts/guide.sh index d49b463..9374f13 100755 --- a/scripts/guide.sh +++ b/scripts/guide.sh @@ -96,7 +96,12 @@ reload_audit_json() { fi } -echo "Gathering current tool status from snapshot..." +# Refresh the snapshot's installed state (network-free) so the displayed +# "installed:" matches what the installers detect live as "before:". The refresh +# preserves the cached upstream "latest", so it stays offline and consistent with +# `make update` (no target regression, no make-update/make-upgrade disagreement). +echo "Refreshing installed status (no network)..." +(cd "$ROOT" && "$CLI" audit.py --update-local >/dev/null 2>&1) || true AUDIT_OUTPUT="$(cd "$ROOT" && CLI_AUDIT_RENDER=1 CLI_AUDIT_LINKS=0 CLI_AUDIT_EMOJI=0 "$CLI" audit.py || true)" AUDIT_JSON="$(cd "$ROOT" && CLI_AUDIT_JSON=1 CLI_AUDIT_RENDER=1 "$CLI" audit.py || true)" diff --git a/tests/test_update_fixes.py b/tests/test_update_fixes.py index 2b9653b..8e0e294 100644 --- a/tests/test_update_fixes.py +++ b/tests/test_update_fixes.py @@ -1223,3 +1223,40 @@ def test_missing_latest_is_unknown(self): import audit # no known latest -> UNKNOWN, never a false OUTDATED assert audit.compute_status("1.0.0", "") == "UNKNOWN" + + +@skip_on_windows +class TestUpdateLocalPreservesLatest: + """A full `--update-local` refreshes installed_version + status from live + detection but PRESERVES each tool's existing latest_version, so a network-free + refresh never clobbers the upstream data `make update` collected (which would + show a target lower than installed and make update/upgrade disagree).""" + + def test_refreshes_installed_but_preserves_latest(self, tmp_path): + snap = tmp_path / "snap.json" + # git is present in CI; seed a stale installed + a deliberately-high latest + snap.write_text(json.dumps({ + "__meta__": {"count": 1}, + "tools": [{"tool": "git", "installed_version": "0.0.1", + "latest_version": "999.0.0", "status": "OUTDATED"}], + })) + env = os.environ.copy() + env.update({ + "CLI_AUDIT_SNAPSHOT_FILE": str(snap), + "CLI_AUDIT_LOCAL_FILE": str(tmp_path / "local.json"), + "CLI_AUDIT_UPSTREAM_FILE": str(tmp_path / "upstream.json"), + "CLI_AUDIT_OFFLINE": "1", + "PYTHONUTF8": "1", + }) + r = subprocess.run( + [sys.executable, "audit.py", "--update-local", "git"], + capture_output=True, text=True, cwd=str(PROJECT_ROOT), env=env, timeout=60, + ) + assert r.returncode == 0, r.stderr + entry = next(t for t in json.loads(snap.read_text())["tools"] if t["tool"] == "git") + # latest PRESERVED (not rebuilt from the empty/baseline upstream cache) + assert entry["latest_version"] == "999.0.0", "latest must be preserved" + # installed REFRESHED to the real git version (not the seeded 0.0.1) + 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" From 7487a3c3a09a633605372fe9bc786aa4100c1a40 Mon Sep 17 00:00:00 2001 From: Sebastian Mendel Date: Sun, 21 Jun 2026 00:48:39 +0200 Subject: [PATCH 2/2] fix(audit): guard None values when refreshing snapshot installed state (review) Coerce installed_version/method/path and latest to '' with 'or ""' so they never serialize as null (which guide.sh's json_field would render as the literal 'None'). Addresses gemini-code-assist review feedback. Signed-off-by: Sebastian Mendel --- audit.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/audit.py b/audit.py index 7b538d7..98f4eb8 100755 --- a/audit.py +++ b/audit.py @@ -970,12 +970,12 @@ def cmd_update_local(args: argparse.Namespace) -> int: inst = local_state.tools.get(name) if inst is None: continue - latest = entry.get("latest_version", "") - entry["installed"] = inst.installed_version - entry["installed_version"] = inst.installed_version - entry["installed_method"] = inst.installed_method - entry["installed_path_selected"] = inst.installed_path - entry["status"] = compute_status(inst.installed_version, latest) + latest = entry.get("latest_version") or "" + entry["installed"] = inst.installed_version or "" + entry["installed_version"] = inst.installed_version or "" + entry["installed_method"] = inst.installed_method or "" + entry["installed_path_selected"] = inst.installed_path or "" + entry["status"] = compute_status(inst.installed_version or "", latest) write_snapshot(existing, offline=OFFLINE_MODE) print(f"✓ Legacy snapshot refreshed (installed state): {get_snapshot_path()}", file=sys.stderr) else: