Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.
- Binary-probe fallback in `guide.sh` when the post-install snapshot refresh is stale.

### Fixed
- The network-free refresh that `make upgrade` runs first (`audit.py --update-local`) skipped every multi-version row, so `python@3.14`, `node@26` and the other cycles kept the version they were last written with. After a successful upgrade the guide still offered the same upgrade until an install ran again. The refresh re-detects the cycle rows, the same way the post-install merge refresh already did.
- Audit detection skips virtualenv/conda bin dirs, like reconcile already did. An activated `~/.venv` made the audit report its own copy (`~/.venv/bin/black` 25.11.0) instead of the installation (`uv tool` black 26.5.1), so every upgrade of black, isort and python@3.14 looked like a no-op. Catalog `version_command`s run with the same filtered PATH. Reconcile no longer drops uv-tool and pipx installations, whose per-tool directories also carry a `pyvenv.cfg`. A tool that exists only inside a virtualenv or conda environment is now reported as not installed; the bulk missing-tool check and the post-install validation use the same lookup.
- `make upgrade` hid every pinned tool, whatever the pin. A release skipped with `s` ("ask again when newer patch available") hid the tool for good. A pin now hides a tool only while it is `never`, equals the target release (`s`), equals the installed version (`p`), or equals the cycle.
- `make upgrade` auto-update no longer reports an upgrade as "Updated" just because the install script exited 0. The version is compared after the re-audit, the same check the interactive `Y`/`a` answers use; an unchanged version counts as "Failed" with the old and target version. A package manager without a newer package (`bwrap` on apt) and a binary identical to the target release with a stale version string (`sd` 1.1.0 reports 1.0.0) count as "Skipped". "Held back" requires the install command to succeed and, for apt, the installed package to be the candidate and to own the binary found on PATH; otherwise the unchanged version counts as "Failed". Without an upstream version the result is reported as unverified ("Skipped").
Expand Down
192 changes: 113 additions & 79 deletions audit.py
Original file line number Diff line number Diff line change
Expand Up @@ -868,6 +868,113 @@
return 1


def _refresh_multi_version_entries(tools_list, tools_by_name: dict, existing_tools: list) -> None:

Check failure on line 871 in audit.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 38 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=netresearch_coding_agent_cli_toolset&issues=AaDKLiudhb3vRqewDNF2&open=AaDKLiudhb3vRqewDNF2&pullRequest=151
"""Re-detect each multi-version cycle (python@3.14, node@22, …) into tools_by_name.

build_legacy_snapshot emits only the base-tool key, so without this the cycle
rows keep the version they were written with, and the guide offers an upgrade
for a version that is no longer installed. Network-free: the supported cycles
come from the existing snapshot.
"""
try:
from cli_audit.catalog import ToolCatalog

_catalog = ToolCatalog()
except Exception:
_catalog = None
if _catalog is not None:
for tool in tools_list:
if not _catalog.has_tool(tool.name):
continue
catalog_data = _catalog.get_raw_data(tool.name)
mv_config = catalog_data.get("multi_version", {})
if not mv_config.get("enabled"):
continue
# Reuse supported-cycle metadata from existing snapshot so this
# fast-path stays network-free. First full audit populates it;
# subsequent refreshes just re-detect local installs.
supported: list[dict] = []
for t in existing_tools:
if t.get("base_tool") == tool.name and t.get("version_cycle"):
supported.append(
{
"cycle": t["version_cycle"],
"latest": t.get("latest_upstream", ""),
"status": t.get("lifecycle_status", "unknown"),
"eol": None,
"support": None,
"release_date": None,
"lts": False,
}
)
if not supported:
continue
try:
detected = detect_multi_versions(tool.name, mv_config, supported)
except Exception as exc:
# One failing runtime must not abort the refresh of the others, or
# leave local_state.json written and the snapshot not
print(f"# {tool.name}: multi-version detection failed: {exc!r}", file=sys.stderr)
continue
for info in detected:
cycle = str(info.get("cycle", ""))
if not cycle:
continue
installed_v = info.get("installed")
latest_v = info.get("latest_upstream", "")
# Directional, like base-tool rows: a runtime ahead of a stale
# stored latest is up to date, not an upgrade candidate
status_v = compute_status(installed_v or "", latest_v)
method = info.get("install_method")
versioned = f"{tool.name}@{cycle}"
entry = dict(tools_by_name.get(versioned, {}))
entry.update(
{
"tool": versioned,
"category": catalog_data.get("category", tool.name),
"installed": installed_v or "",
"installed_method": method,
"installed_version": installed_v or "",
"installed_path_selected": info.get("path"),
"classification_reason_selected": (
f"Detected via path analysis: {method}" if method else "No installation detected"
),
"latest_upstream": latest_v,
"latest_version": latest_v,
"status": status_v,
"is_multi_version": True,
"base_tool": tool.name,
"version_cycle": cycle,
"lifecycle_status": info.get("status", "unknown"),
}
)
# Hint stays empty for generic multi-version runtimes;
# the tool name + state already tell the user what to do.
entry["hint"] = ""
tools_by_name[versioned] = entry


def _refresh_cycle_rows(existing: list[dict], tools_list) -> None:
"""Update the multi-version rows of a snapshot in place from a fresh detection.

Works on copies: a row the detection does not reach (its runtime is not in
tools_list, or its catalog entry is gone) keeps its data unchanged.
"""
by_name = {t.get("tool"): dict(t) for t in existing}
try:
_refresh_multi_version_entries(tools_list, by_name, existing)
except Exception as exc:
# A failed probe must not abort the refresh before the snapshot is written
print(f"# Multi-version refresh skipped: {exc}", file=sys.stderr)
return
for entry in existing:
name = entry.get("tool", "")
if "@" in name and name in by_name:
refreshed = by_name[name]
entry.clear()
entry.update(refreshed)


def cmd_update_local(args: argparse.Namespace) -> int:
"""Update only local installation state (fast, no network)."""
# Check if we're in merge mode (updating specific tools only)
Expand Down Expand Up @@ -955,84 +1062,7 @@
if tool_name in updated_tool_names:
tools_by_name[tool_name] = updated_tool

# Multi-version tools (python@3.14, node@22, php@8.3, …) have one
# snapshot entry per cycle. build_legacy_snapshot/merge_for_display
# only emits the base-tool key, so without this block the cycle
# entries would stay stale after an upgrade — masking successful
# installs as "version unchanged" in the guide.
try:
from cli_audit.catalog import ToolCatalog

_catalog = ToolCatalog()
except Exception:
_catalog = None
if _catalog is not None:
for tool in tools_list:
if not _catalog.has_tool(tool.name):
continue
catalog_data = _catalog.get_raw_data(tool.name)
mv_config = catalog_data.get("multi_version", {})
if not mv_config.get("enabled"):
continue
# Reuse supported-cycle metadata from existing snapshot so this
# fast-path stays network-free. First full audit populates it;
# subsequent refreshes just re-detect local installs.
supported: list[dict] = []
for t in existing_tools:
if t.get("base_tool") == tool.name and t.get("version_cycle"):
supported.append(
{
"cycle": t["version_cycle"],
"latest": t.get("latest_upstream", ""),
"status": t.get("lifecycle_status", "unknown"),
"eol": None,
"support": None,
"release_date": None,
"lts": False,
}
)
if not supported:
continue
detected = detect_multi_versions(tool.name, mv_config, supported)
for info in detected:
cycle = str(info.get("cycle", ""))
if not cycle:
continue
installed_v = info.get("installed")
latest_v = info.get("latest_upstream", "")
if installed_v and installed_v == latest_v:
status_v = "UP-TO-DATE"
elif installed_v:
status_v = "OUTDATED"
else:
status_v = STATUS_NOT_INSTALLED
method = info.get("install_method")
versioned = f"{tool.name}@{cycle}"
entry = dict(tools_by_name.get(versioned, {}))
entry.update(
{
"tool": versioned,
"category": catalog_data.get("category", tool.name),
"installed": installed_v or "",
"installed_method": method,
"installed_version": installed_v or "",
"installed_path_selected": info.get("path"),
"classification_reason_selected": (
f"Detected via path analysis: {method}" if method else "No installation detected"
),
"latest_upstream": latest_v,
"latest_version": latest_v,
"status": status_v,
"is_multi_version": True,
"base_tool": tool.name,
"version_cycle": cycle,
"lifecycle_status": info.get("status", "unknown"),
}
)
# Hint stays empty for generic multi-version runtimes;
# the tool name + state already tell the user what to do.
entry["hint"] = ""
tools_by_name[versioned] = entry
_refresh_multi_version_entries(tools_list, tools_by_name, existing_tools)

# Write merged snapshot
merged_tools = list(tools_by_name.values())
Expand All @@ -1046,10 +1076,14 @@
# is lower than the installed version.
existing = load_snapshot().get("tools", [])
if existing:
# Cycle rows carry no base-tool local state, so they need their own
# detection — otherwise `make upgrade` opens with a stale version
# for every runtime cycle (python@3.14, node@26, …).
_refresh_cycle_rows(existing, tools_list)
for entry in existing:
name = entry.get("tool", "")
if "@" in name:
continue # multi-version cycle: no per-cycle local-only data
continue # refreshed by _refresh_cycle_rows
inst = local_state.tools.get(name)
if inst is None:
continue
Expand Down
Loading
Loading