Files
hermes-agent/scripts/audit-old-updater-imports.py
ethernet 92686159d1 fix(pm): integrate audited runtime and lifecycle repairs
Prepare dependency generations before selecting them. Keep shipped tool
bytes separate from writable additions, and store facts beside their entries.
Validate proposed plugin sets before config publication. Restore the previous
config if the facts write fails.

Consolidate duplicate updater, backup, setup, and voice helpers. Repair
launcher selection, dependency consumers, download ownership, update feeds,
and native Windows process and file handling.

Verification: 206 changed/prior-failing Python files reported 4630 passed,
one failed, and 330 skipped. Fix the remaining Hindsight fixture boundary.
The final targeted rerun reported 234 passed and two skipped. The store
review regression batch reported 83 passed and one skipped. Desktop
TypeScript checks, 56 selected Electron tests, 24 release tests, and the
removed-import/compatibility guards passed.

This is an integration checkpoint, not full audit acceptance. The complete
Python suite has not run on this fixed tree. Crash-atomic plugin publication,
generation cleanup, receipt correlation, and packaged lifecycle acceptance
remain open in docs/pm-audit-status.md.
2026-09-05 22:36:48 -04:00

874 lines
32 KiB
Python

#!/usr/bin/env python3
"""What an OLD `hermes update` can still import from a NEW tree.
`hermes update` swaps the checkout under its own feet. The process keeps
running the code it started with, but the files underneath it are the
ones we just pulled. Anything it loads from disk after that point is a
contract with every released updater in the wild: delete one of those
names and the users on that release get a traceback halfway through an
update, on a tree that is already half-new.
`managed_uv._reload_hermes_constants` is the scar tissue proving this is
real: an updater hit ``cannot import name 'venv_python_path' from
'hermes_constants'`` while the file on disk plainly contained the name.
WHY THIS OVER-APPROXIMATES, ON PURPOSE
--------------------------------------
An earlier version of this script tried to find the exact swap statement
(the ``git merge --ff-only``) and count only what runs after it. That was
wrong twice over. It was fragile — ``ast.unparse`` normalises quotes, so
matching source text for ``"merge", "--ff-only"`` silently matched
nothing and the whole git path reported no swap at all. And it was wrong
in the DANGEROUS direction: every miss SHRINKS the frozen set, and a
symbol wrongly dropped from the set is a bricked update for whoever
reaches that branch.
So the rule is deliberately blunt: everything reachable from the update
entrypoints counts. A false positive costs one kept symbol. A false
negative costs somebody's install, mid-update, on a half-new tree.
A dynamic trace (driving real updates and watching imports) has the
opposite bias and is the wrong tool here for the same reason: one run
takes one path. It never enters the diverged-history reset, the Windows
rollback, or the ZIP fallback, so it reports a SMALLER
surface than reality.
THE DYNAMIC PATTERNS THAT MATTER (and why they are not missed)
--------------------------------------------------------------
Plain import analysis misses three things this flow really does, all of
which are resolved here because they are all spelled with literals:
* ``importlib.reload(m)`` — RE-EXECUTES the new file in the old process.
This is the most dangerous load in the whole flow and it looks like
nothing to an import walker. ``_UPDATE_RUNTIME_RELOAD_MODULES`` and
``_reload_config_modules`` reload ``hermes_constants``,
``hermes_cli.config`` and friends by name. Treated as a whole-module
requirement.
* ``getattr(module, "name")`` — a symbol requirement with no import
statement. ``managed_uv._windows_runtime_holders`` looks up
``_detect_venv_python_processes`` on ``hermes_cli.main`` this way, and
silently refuses the update when it is absent.
* ``importlib.import_module(x)`` with a non-literal argument — cannot be
resolved statically. Reported as UNRESOLVED rather than ignored.
Usage:
python scripts/audit-old-updater-imports.py # report
python scripts/audit-old-updater-imports.py --json
python scripts/audit-old-updater-imports.py --check # CI gate
python scripts/audit-old-updater-imports.py --explain hermes_constants
"""
from __future__ import annotations
import argparse
import ast
import json
import subprocess
from dataclasses import dataclass, field
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
# The update flow has lived at both of these paths.
UPDATE_MODULE_CANDIDATES = (
"hermes_cli/update_cmd.py",
"hermes_cli/subcommands/update.py",
)
# Helpers the update flow calls into after the tree moves. Every function
# in these is treated as post-swap.
POST_SWAP_HELPER_MODULES = (
"hermes_cli/post_update.py",
"hermes_cli/runtime_repair.py",
"hermes_cli/update_lock.py",
# pm era: `hermes update` drives the store through the pm package. A pm
# module imported BEFORE the swap keeps running as old code afterwards,
# so its lazy loads resolve against the NEW tree -- same failure shape
# as managed_uv. Files absent at a given revision are simply skipped.
"pm/__init__.py",
"pm/cli.py",
"pm/ensure.py",
"pm/extras.py",
"pm/lock.py",
"pm/package.py",
"pm/packages.py",
"pm/paths.py",
"pm/registry.py",
"pm/store.py",
)
# Only OUR packages matter: a third-party import is pinned by the
# dependency resolver, not by this repo's file layout.
FIRST_PARTY_ROOTS = frozenset(
{
"agent",
"gateway",
"hermes_cli",
"hermes_constants",
"hermes_state",
"installation",
"plugins",
"pm",
"tools",
"utils",
}
)
# Where an update begins. Everything reachable from here can run while
# the tree is being replaced.
UPDATE_ENTRYPOINTS = (
"cmd_update",
"_cmd_update_impl",
"_update_via_zip",
"_run_update_phase_inline",
)
_AnyFunc = ast.FunctionDef | ast.AsyncFunctionDef
def _first_party(module: str) -> bool:
return module.split(".")[0] in FIRST_PARTY_ROOTS
@dataclass(frozen=True)
class Requirement:
"""One name the updater needs to find in the NEW tree."""
module: str
symbol: str | None
kind: str # import | reload | getattr
function: str
source_file: str
guarded: bool = False
"""True when the load sits in a ``try`` that catches its failure.
A guarded requirement cannot brick an update — the old code has a
fallback arm — so it is reported as informational, not frozen.
"""
def key(self) -> tuple[str, str]:
return (self.module, self.symbol or "")
@dataclass
class Analysis:
path: str
requirements: list[Requirement] = field(default_factory=list)
unresolved: list[str] = field(default_factory=list)
reachable: set[str] = field(default_factory=set)
def _function_table(tree: ast.AST) -> dict[str, _AnyFunc]:
table: dict[str, _AnyFunc] = {}
for node in ast.walk(tree):
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
table.setdefault(node.name, node)
return table
def _called_names(node: ast.AST) -> set[str]:
"""Function names a piece of code can call.
Covers the three shapes this codebase uses: ``foo()``,
``module.foo()``, and ``_m().foo()`` — update_cmd's lazy
``hermes_cli.main`` handle, which re-exports these same helpers.
Attribute calls that are not ours simply find no match in the
module's own function table.
"""
names: set[str] = set()
for child in ast.walk(node):
if isinstance(child, ast.Call):
func = child.func
if isinstance(func, ast.Name):
names.add(func.id)
elif isinstance(func, ast.Attribute):
names.add(func.attr)
return names
def _string_constants(tree: ast.AST) -> dict[str, list[str]]:
"""Module-level ``NAME = (...)`` / ``NAME = [...]`` string collections.
``_UPDATE_RUNTIME_RELOAD_MODULES`` is exactly this shape, and its
contents are module names that get reloaded — i.e. re-executed from
the new tree.
"""
out: dict[str, list[str]] = {}
for node in getattr(tree, "body", []):
if not isinstance(node, ast.Assign) or len(node.targets) != 1:
continue
target = node.targets[0]
if not isinstance(target, ast.Name):
continue
if not isinstance(node.value, (ast.Tuple, ast.List, ast.Set)):
continue
values = [
el.value
for el in node.value.elts
if isinstance(el, ast.Constant) and isinstance(el.value, str)
]
if values:
out[target.id] = values
return out
def _guarded_spans(func: _AnyFunc) -> list[tuple[int, int]]:
"""Line spans of ``try`` bodies whose handlers catch an import failure.
``except Exception``, ``except ImportError`` and bare ``except``
all swallow a missing name; a load inside such a body has a fallback
arm in the OLD code and cannot brick the update by itself.
"""
spans: list[tuple[int, int]] = []
for node in ast.walk(func):
if not isinstance(node, ast.Try):
continue
catches = False
for handler in node.handlers:
if handler.type is None:
catches = True
elif isinstance(handler.type, ast.Name) and handler.type.id in (
"Exception",
"BaseException",
"ImportError",
"ModuleNotFoundError",
"AttributeError",
):
catches = True
elif isinstance(handler.type, ast.Tuple):
for el in handler.type.elts:
if isinstance(el, ast.Name) and el.id in (
"Exception",
"ImportError",
"ModuleNotFoundError",
"AttributeError",
):
catches = True
if catches and node.body:
first = node.body[0].lineno
last = max(
getattr(stmt, "end_lineno", stmt.lineno) for stmt in node.body
)
spans.append((first, last))
return spans
def _requirements_in(
func: _AnyFunc,
source_file: str,
constants: dict[str, list[str]],
) -> tuple[list[Requirement], list[str]]:
"""Every name *func* needs from the new tree, plus what we could not read."""
reqs: list[Requirement] = []
unresolved: list[str] = []
guarded_spans = _guarded_spans(func)
def _is_guarded(node: ast.AST) -> bool:
line = getattr(node, "lineno", None)
if line is None:
return False
return any(first <= line <= last for first, last in guarded_spans)
def add(
module: str, symbol: str | None, kind: str, node: ast.AST
) -> None:
if _first_party(module):
reqs.append(
Requirement(
module,
symbol,
kind,
func.name,
source_file,
guarded=_is_guarded(node),
)
)
for child in ast.walk(func):
# ── plain lazy imports ─────────────────────────────────────────
if isinstance(child, ast.ImportFrom):
if not child.level and child.module:
for alias in child.names:
add(child.module, alias.name, "import", child)
elif isinstance(child, ast.Import):
for alias in child.names:
add(alias.name, None, "import", child)
# ── dynamic loads ──────────────────────────────────────────────
elif isinstance(child, ast.Call):
fname = (
child.func.attr
if isinstance(child.func, ast.Attribute)
else child.func.id
if isinstance(child.func, ast.Name)
else ""
)
if fname in ("reload", "import_module") and child.args:
arg = child.args[0]
if isinstance(arg, ast.Constant) and isinstance(arg.value, str):
# importlib.reload("x") is not legal, but
# import_module("x") is — same requirement either way.
add(arg.value, None, "reload", child)
elif isinstance(arg, ast.Name) and arg.id in constants:
for module in constants[arg.id]:
add(module, None, "reload", child)
else:
# A reload of a loop variable: find the collection the
# loop walks. `for m in (...)` / `for m in CONST`.
resolved = False
for loop in ast.walk(func):
if not isinstance(loop, ast.For):
continue
if not (
isinstance(loop.target, ast.Name)
and isinstance(arg, ast.Name)
):
continue
names: list[str] = []
if isinstance(loop.iter, (ast.Tuple, ast.List)):
names = [
el.value
for el in loop.iter.elts
if isinstance(el, ast.Constant)
and isinstance(el.value, str)
]
elif isinstance(loop.iter, ast.Name):
names = constants.get(loop.iter.id, [])
for module in names:
add(module, None, "reload", child)
resolved = True
if not resolved:
try:
text = ast.unparse(child)
except Exception: # noqa: BLE001
text = f"{fname}(...)"
unresolved.append(f"{source_file}:{func.name}: {text[:90]}")
elif fname == "getattr" and len(child.args) >= 2:
holder, attr = child.args[0], child.args[1]
if isinstance(attr, ast.Constant) and isinstance(attr.value, str):
module = _module_of(holder)
if module:
add(module, attr.value, "getattr", child)
return reqs, unresolved
def _module_of(node: ast.AST) -> str | None:
"""Best-effort: which module a getattr target refers to.
Handles the one real shape — ``sys.modules.get("hermes_cli.main")``
stashed in a local and then getattr'd (managed_uv does exactly this).
"""
if isinstance(node, ast.Call):
if isinstance(node.func, ast.Attribute) and node.func.attr == "get":
if node.args and isinstance(node.args[0], ast.Constant):
value = node.args[0].value
if isinstance(value, str):
return value
return None
def _resolve_sys_modules_locals(func: _AnyFunc) -> dict[str, str]:
"""Locals bound to ``sys.modules.get("<module>")`` inside *func*."""
bound: dict[str, str] = {}
for node in ast.walk(func):
if isinstance(node, ast.Assign) and len(node.targets) == 1:
target = node.targets[0]
if isinstance(target, ast.Name):
module = _module_of(node.value)
if module:
bound[target.id] = module
return bound
def _getattr_on_bound_locals(
func: _AnyFunc, source_file: str
) -> list[Requirement]:
"""``m = sys.modules.get("x")`` … ``getattr(m, "y")`` → x.y required."""
bound = _resolve_sys_modules_locals(func)
if not bound:
return []
out: list[Requirement] = []
for node in ast.walk(func):
if not isinstance(node, ast.Call):
continue
fname = node.func.id if isinstance(node.func, ast.Name) else ""
if fname != "getattr" or len(node.args) < 2:
continue
holder, attr = node.args[0], node.args[1]
if not (isinstance(holder, ast.Name) and holder.id in bound):
continue
if isinstance(attr, ast.Constant) and isinstance(attr.value, str):
module = bound[holder.id]
if _first_party(module):
out.append(
Requirement(module, attr.value, "getattr", func.name, source_file)
)
return out
def analyse(source: str, source_file: str, *, entrypoints: bool) -> Analysis | None:
"""Requirements of one version of one file.
*entrypoints* selects the reachability seed: an update module starts
from ``UPDATE_ENTRYPOINTS``; a post-swap helper module is entered
wholesale, so every function in it counts.
"""
try:
tree = ast.parse(source)
except SyntaxError:
return None
result = Analysis(path=source_file)
functions = _function_table(tree)
constants = _string_constants(tree)
if entrypoints:
seen: set[str] = set()
stack = [name for name in UPDATE_ENTRYPOINTS if name in functions]
if not stack:
return result # not an update module at this revision
while stack:
name = stack.pop()
if name in seen:
continue
seen.add(name)
for callee in _called_names(functions[name]):
if callee in functions and callee not in seen:
stack.append(callee)
reachable = seen
else:
reachable = set(functions)
result.reachable = reachable
for name in sorted(reachable):
func = functions[name]
reqs, unresolved = _requirements_in(func, source_file, constants)
result.requirements.extend(reqs)
result.requirements.extend(_getattr_on_bound_locals(func, source_file))
result.unresolved.extend(unresolved)
return result
# ─── history walking ────────────────────────────────────────────────────
def _all_audited_paths() -> tuple[str, ...]:
siblings = tuple(sorted(
str(p.relative_to(REPO_ROOT)).replace("\\", "/")
for p in (REPO_ROOT / "hermes_cli").glob("update_cmd_*.py")
))
return tuple(dict.fromkeys((*UPDATE_MODULE_CANDIDATES, *POST_SWAP_HELPER_MODULES, *siblings)))
def shipped_commits() -> list[str]:
"""Commits a user could actually be running, newest first.
Restricted to ``origin/main``: the update channel is ``main`` or
``stable`` (``installation/tree.py``) and both track this branch.
Unmerged topic branches are not something anyone updates from, and
including them would freeze names that never shipped.
Only commits that touched the audited files are listed; every other
commit leaves them byte-identical to its parent.
"""
proc = subprocess.run(
["git", "log", "origin/main", "--format=%H", "--", *_all_audited_paths()],
cwd=REPO_ROOT,
capture_output=True,
text=True,
check=True,
)
return [line for line in proc.stdout.split() if line]
def _batch_read_blobs(refs: list[str]) -> dict[str, str]:
"""Read many ``<commit>:<path>`` revisions in ONE git process.
A ``git show`` per pair is thousands of spawns; ``cat-file --batch``
streams them through a single pipe. Bytes both ways, because payloads
are located by the byte offset in each header and text decoding would
shift every offset at the first multi-byte character.
"""
if not refs:
return {}
proc = subprocess.run(
["git", "cat-file", "--batch"],
cwd=REPO_ROOT,
input=("\n".join(refs) + "\n").encode(),
capture_output=True,
check=True,
)
out = proc.stdout
contents: dict[str, str] = {}
pos = 0
for ref in refs:
newline = out.find(b"\n", pos)
if newline == -1:
break
header = out[pos:newline].decode("utf-8", "replace")
pos = newline + 1
if header.endswith(" missing"):
continue
try:
size = int(header.rsplit(" ", 1)[1])
except (IndexError, ValueError):
continue
contents[ref] = out[pos : pos + size].decode("utf-8", "replace")
pos += size + 1
return contents
@dataclass
class Surface:
required: dict[tuple[str, str], set[str]] = field(default_factory=dict)
kinds: dict[tuple[str, str], set[str]] = field(default_factory=dict)
sites: dict[tuple[str, str], set[str]] = field(default_factory=dict)
guarded_only: set[tuple[str, str]] = field(default_factory=set)
"""Pairs whose every load site sits under a swallowing ``try``.
These cannot brick an update (the old code has a fallback arm), so
they are informational: reported, but absent from the frozen surface
and never fatal in ``--check``.
"""
unresolved: set[str] = field(default_factory=set)
stats: dict = field(default_factory=dict)
def audit_history() -> Surface:
commits = shipped_commits()
paths = _all_audited_paths()
refs = [f"{c}:{p}" for c in commits for p in paths]
blobs = _batch_read_blobs(refs)
surface = Surface()
memo: dict[int, Analysis | None] = {}
distinct = 0
bare_pairs: set[tuple[str, str]] = set()
for ref, source in blobs.items():
commit, _, path = ref.partition(":")
fingerprint = hash(source)
if fingerprint not in memo:
memo[fingerprint] = analyse(
source, path, entrypoints=path in UPDATE_MODULE_CANDIDATES
)
distinct += 1
analysis = memo[fingerprint]
if analysis is None:
continue
for req in analysis.requirements:
surface.required.setdefault(req.key(), set()).add(commit[:12])
surface.kinds.setdefault(req.key(), set()).add(req.kind)
surface.sites.setdefault(req.key(), set()).add(
f"{req.source_file}:{req.function}"
)
if not req.guarded:
bare_pairs.add(req.key())
surface.unresolved.update(analysis.unresolved)
surface.guarded_only = set(surface.required) - bare_pairs
surface.stats = {
"commits": len(commits),
"revisions_read": len(blobs),
"distinct_file_versions": distinct,
}
return surface
def audit_tree() -> Surface:
"""The surface of the update flow as it exists in THIS working tree.
The pm rewrite replaced the installation/* + managed_uv update stack
wholesale, so the shipped-history walk over origin/main freezes names
(installation.*, hermes_cli.managed_uv.*) that this branch deliberately
deleted along with the updaters that loaded them. On this lineage the
honest contract is the CURRENT update flow: freeze what today's updater
lazy-loads mid-swap, so a FUTURE rename of any of those names turns the
test red before it bricks a live update. Once this branch is the shipped
channel, every release cut from it re-enters the surface by regenerating
against the tree that shipped it.
"""
surface = Surface()
bare_pairs: set[tuple[str, str]] = set()
analyzed: list[str] = []
for path in _all_audited_paths():
file = REPO_ROOT / path
if not file.is_file():
continue
source = file.read_text(encoding="utf-8-sig", errors="replace")
analysis = analyse(
source, path, entrypoints=path in UPDATE_MODULE_CANDIDATES
)
if analysis is None or (
path in UPDATE_MODULE_CANDIDATES and not analysis.reachable
):
continue
analyzed.append(path)
for req in analysis.requirements:
surface.required.setdefault(req.key(), set()).add("worktree")
surface.kinds.setdefault(req.key(), set()).add(req.kind)
surface.sites.setdefault(req.key(), set()).add(
f"{req.source_file}:{req.function}"
)
if not req.guarded:
bare_pairs.add(req.key())
surface.unresolved.update(analysis.unresolved)
surface.guarded_only = set(surface.required) - bare_pairs
surface.stats = {"mode": "tree", "files_analyzed": sorted(analyzed)}
return surface
# ─── resolution against the working tree ────────────────────────────────
def resolve_in_tree(module: str, symbol: str | None, root: Path) -> tuple[bool, str]:
"""Does *module* (and *symbol*) exist in the tree at *root*?
Static resolution against the FILES, deliberately: importing would
RUN the module, and the question is what an updater finds on disk,
not what this interpreter can execute.
"""
rel = Path(module.replace(".", "/"))
for candidate in (root / f"{rel}.py", root / rel / "__init__.py"):
if candidate.is_file():
path = candidate
break
else:
return False, f"module {module} not found"
if symbol is None:
return True, ""
# `from hermes_cli import gateway_windows` names a SUBMODULE, not an
# attribute of the package body.
submodule = root / rel / symbol
if submodule.with_suffix(".py").is_file() or (submodule / "__init__.py").is_file():
return True, ""
try:
tree = ast.parse(path.read_text(encoding="utf-8-sig"))
except (OSError, SyntaxError) as exc:
return False, f"{module}: unreadable ({exc})"
for node in ast.walk(tree):
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
if node.name == symbol:
return True, ""
elif isinstance(node, ast.Assign):
for target in node.targets:
if isinstance(target, ast.Name) and target.id == symbol:
return True, ""
elif isinstance(node, ast.AnnAssign):
if isinstance(node.target, ast.Name) and node.target.id == symbol:
return True, ""
elif isinstance(node, (ast.Import, ast.ImportFrom)):
# A re-export counts.
for alias in node.names:
if (alias.asname or alias.name.split(".")[0]) == symbol:
return True, ""
return False, f"{module}.{symbol} not found"
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--json", action="store_true", help="Emit JSON.")
parser.add_argument(
"--check",
action="store_true",
help="Exit nonzero when this tree is missing something an old "
"updater needs.",
)
parser.add_argument(
"--explain", metavar="MODULE", help="Show every requirement on MODULE."
)
parser.add_argument(
"--history",
action="store_true",
help="Walk every shipped revision on origin/main instead of the "
"working tree. The pm rewrite retired that lineage's updaters, so "
"the default is the working-tree surface; the walk remains for "
"archaeology.",
)
parser.add_argument(
"--freeze",
metavar="PATH",
help="Write the surface as JSON (the file the enforcing test reads). "
"The default audits the working tree; --history re-walks shipped "
"revisions on origin/main (needs a full clone).",
)
ns = parser.parse_args(argv)
surface = audit_history() if ns.history else audit_tree()
if ns.freeze:
payload = {
"_comment": (
"Generated by scripts/audit-old-updater-imports.py --freeze. "
"Names an already-running `hermes update` loads from the NEW "
"tree after the checkout swap. Deleting a bare name bricks "
"every release that loads it, mid-update, on a half-new "
"tree. Regenerate after changing the update flow; never "
"hand-trim."
),
"stats": surface.stats,
"bare": sorted(
f"{m}::{s}"
for (m, s) in surface.required
if (m, s) not in surface.guarded_only
),
"guarded_only": sorted(
f"{m}::{s}" for (m, s) in surface.guarded_only
),
}
Path(ns.freeze).write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
print(
f"froze {len(payload['bare'])} bare + "
f"{len(payload['guarded_only'])} guarded pairs -> {ns.freeze}"
)
return 0
missing = []
soft_missing = []
for (module, symbol), commits in sorted(surface.required.items()):
ok, why = resolve_in_tree(module, symbol or None, REPO_ROOT)
if not ok:
if (module, symbol) in surface.guarded_only:
soft_missing.append((module, symbol, sorted(commits), why))
else:
missing.append((module, symbol, sorted(commits), why))
if ns.explain:
print(f"Requirements on {ns.explain!r}:")
for (module, symbol), commits in sorted(surface.required.items()):
if module != ns.explain:
continue
kinds = "/".join(sorted(surface.kinds[(module, symbol)]))
where = ", ".join(sorted(surface.sites[(module, symbol)])[:3])
print(
f" {module}.{symbol or '<module>'} [{kinds}]"
f" {len(commits)} commits {where}"
)
return 0
if ns.json:
print(
json.dumps(
{
"stats": surface.stats,
"required": [
{
"module": m,
"symbol": s or None,
"kinds": sorted(surface.kinds[(m, s)]),
"commits": sorted(c),
"sites": sorted(surface.sites[(m, s)]),
"guarded_only": (m, s) in surface.guarded_only,
}
for (m, s), c in sorted(surface.required.items())
],
"unresolved_dynamic": sorted(surface.unresolved),
"missing": [
{"module": m, "symbol": s or None, "commits": c, "why": w}
for m, s, c, w in missing
],
"soft_missing": [
{"module": m, "symbol": s or None, "commits": c, "why": w}
for m, s, c, w in soft_missing
],
},
indent=2,
)
)
return 1 if (missing and ns.check) else 0
st = surface.stats
if st.get("mode") == "tree":
print(
f"Audited the update flow in this working tree: "
f"{len(st['files_analyzed'])} files "
f"({', '.join(st['files_analyzed'])})."
)
else:
print(
f"Walked every shipped commit that touched the update flow: "
f"{st['commits']} commits, {st['revisions_read']} file revisions, "
f"{st['distinct_file_versions']} distinct versions."
)
print()
by_kind: dict[str, int] = {}
for kinds in surface.kinds.values():
for kind in kinds:
by_kind[kind] = by_kind.get(kind, 0) + 1
kinds_summary = ", ".join(f"{v} {k}" for k, v in sorted(by_kind.items()))
hard = {k for k in surface.required if k not in surface.guarded_only}
print(
f"FROZEN COMPAT SURFACE — {len(hard)} module/symbol pairs an old "
f"updater can load BARE from the NEW tree ({kinds_summary} overall; "
f"{len(surface.guarded_only)} more guarded-only, listed after):"
)
by_module: dict[str, list[str]] = {}
for module, symbol in hard:
by_module.setdefault(module, []).append(symbol or "<module>")
for module in sorted(by_module):
print(f" {module}: {', '.join(sorted(by_module[module]))}")
if surface.guarded_only:
print()
print(
f"GUARDED-ONLY — {len(surface.guarded_only)} pairs loaded solely "
f"under a swallowing try (deleting one degrades a fallback arm, "
f"not the update):"
)
by_module = {}
for module, symbol in surface.guarded_only:
by_module.setdefault(module, []).append(symbol or "<module>")
for module in sorted(by_module):
print(f" {module}: {', '.join(sorted(by_module[module]))}")
if surface.unresolved:
print()
print(
f"! {len(surface.unresolved)} dynamic load(s) this script cannot "
f"resolve — audit by hand before deleting anything they may reach:"
)
for item in sorted(surface.unresolved):
print(f" {item}")
print()
if missing:
print(f"X {len(missing)} name(s) an old updater needs are GONE:")
for module, symbol, commits, why in missing:
kinds = "/".join(sorted(surface.kinds[(module, symbol or "")]))
where = ", ".join(sorted(surface.sites[(module, symbol or "")])[:2])
shown = ", ".join(commits[:3])
more = f" +{len(commits) - 3}" if len(commits) > 3 else ""
print(f" [{kinds}] {why}\n from {where} [{shown}{more}]")
else:
print("OK: every name an old updater needs still exists in this tree")
if soft_missing:
print()
print(
f"o {len(soft_missing)} guarded-only name(s) gone — fallback arms "
f"now taken, worth knowing but not fatal:"
)
for module, symbol, commits, why in soft_missing:
where = ", ".join(sorted(surface.sites[(module, symbol or "")])[:2])
print(f" {why} (from {where})")
return 1 if (missing and ns.check) else 0
if __name__ == "__main__":
raise SystemExit(main())