From e05ea147986c79e4c375d363dea9a48bede5eae7 Mon Sep 17 00:00:00 2001 From: Jerry Chen Date: Thu, 27 Aug 2026 10:34:17 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20add=20`graphify=20stale`=20=E2=80=94=20?= =?UTF-8?q?flag=20nodes=20referencing=20files=20changed=20since=20last=20e?= =?UTF-8?q?xtraction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit file-level manifest hashing (ast_hash/semantic_hash) already skips re-extracting unchanged files on --update, but a node in an UNCHANGED file that calls/imports/ references/inherits-from a node in a file that DID change keeps whatever was true about that relationship when it was last extracted — the update never revisits it. `graphify stale` unions the ast/semantic changed-file sets a project has actually populated (checking a kind never stamped would report every file as changed forever), then reuses affected.py's existing reverse-traversal to list nodes elsewhere with a structural reason to double-check. It does not verify the claims themselves, same as `graphify affected` narrows a blast-radius query without answering it. --- graphify/__main__.py | 5 ++ graphify/affected.py | 83 ++++++++++++++++++++++ graphify/cli.py | 95 ++++++++++++++++++++++++++ tests/test_stale_from_changed_files.py | 76 +++++++++++++++++++++ 4 files changed, 259 insertions(+) create mode 100644 tests/test_stale_from_changed_files.py diff --git a/graphify/__main__.py b/graphify/__main__.py index 155501a98d..b32290378f 100644 --- a/graphify/__main__.py +++ b/graphify/__main__.py @@ -561,6 +561,11 @@ def _run_cli() -> None: print(" --relation R edge relation to traverse in reverse (repeatable)") print(" --depth N reverse traversal depth (default 2)") print(" --graph path to graph.json (default graphify-out/graph.json)") + print(" stale list nodes outside files changed since the last extraction") + print(" that reference those files — worth re-checking, not confirmed stale") + print(" --relation R edge relation to traverse in reverse (repeatable)") + print(" --depth N reverse traversal depth (default 1)") + print(" --graph path to graph.json (default graphify-out/graph.json)") print(" god-nodes list the most connected nodes (architectural hubs)") print(" --top N how many to show (default 10)") print(" --graph path to graph.json (default graphify-out/graph.json)") diff --git a/graphify/affected.py b/graphify/affected.py index 0184a8f88f..80b3061d79 100644 --- a/graphify/affected.py +++ b/graphify/affected.py @@ -255,6 +255,89 @@ def affected_nodes( return hits +def stale_from_changed_files( + graph: nx.Graph, + changed_files: Iterable[str], + *, + relations: Iterable[str] = DEFAULT_AFFECTED_RELATIONS, + depth: int = 1, + root: Path | None = None, +) -> dict[str, list[AffectedHit]]: + """For files that changed since the last extraction, find nodes elsewhere + in the graph whose stored relation to a node in that file may now be + stale. + + `graphify update`/`extract --update` only re-extracts nodes and edges + FROM a changed file (file-level manifest hashing in detect.py). A node in + an unchanged file that calls/imports/references/inherits-from a node + defined in the changed file keeps whatever was true about that + relationship at the time IT was extracted — the update never revisits it, + so a claim like "handles retries via BaseHandler.retry()" can go stale + silently if BaseHandler.retry() changed shape and nothing re-reads the + caller. + + This does not verify whether those nodes' extracted descriptions are + actually wrong — like `graphify affected`, it only narrows "everything in + the graph" down to "nodes with a structural reason to double-check". + Confirming or fixing the claim still needs a human or an LLM re-read of + the current source, the same way `graphify affected` narrows a manual + blast-radius query without answering it. + + Returns a dict keyed by changed file (repo-relative, matching the graph's + stored `source_file`), each value the deduplicated AffectedHit list for + every node graphify extracted from that file. A changed file with no + nodes in the graph yet (brand new file, nothing points at it yet) or no + dependents is omitted — only files worth a second look are reported. + """ + relation_list = tuple(relations) + result: dict[str, list[AffectedHit]] = {} + for raw_path in changed_files: + rel = _as_repo_relative(raw_path, root) + seeds = [ + n for n, d in graph.nodes(data=True) + if str(d.get("source_file") or "") == rel + ] + if not seeds: + continue + seen_ids: set[str] = set() + hits: list[AffectedHit] = [] + for seed in seeds: + for hit in affected_nodes(graph, seed, relations=relation_list, depth=depth): + if hit.node_id in seen_ids or hit.node_id in seeds: + continue + seen_ids.add(hit.node_id) + hits.append(hit) + if hits: + result[rel] = hits + return result + + +def format_stale( + graph: nx.Graph, + changed_files: Iterable[str], + *, + relations: Iterable[str] = DEFAULT_AFFECTED_RELATIONS, + depth: int = 1, + root: Path | None = None, +) -> str: + by_file = stale_from_changed_files(graph, changed_files, relations=relations, depth=depth, root=root) + if not by_file: + return "No nodes outside the changed files reference them — nothing flagged." + + lines: list[str] = [] + for changed_file, hits in sorted(by_file.items()): + lines.append(f"# {changed_file} changed — {len(hits)} node(s) elsewhere may be stale") + for hit in hits: + data = graph.nodes[hit.node_id] + if hit.via_location: + location = f"{hit.via_file or data.get('source_file') or '-'}:{hit.via_location}" + else: + location = _format_location(data) + lines.append(f" - {_node_label(graph, hit.node_id)} [{hit.via_relation}] {location}") + lines.append("") + return "\n".join(lines).rstrip() + + def format_affected( graph: nx.Graph, query: str, diff --git a/graphify/cli.py b/graphify/cli.py index bd2956815b..a3b899cbae 100644 --- a/graphify/cli.py +++ b/graphify/cli.py @@ -1254,6 +1254,101 @@ def dispatch_command(cmd: str) -> None: root=graph_root, ) ) + elif cmd == "stale": + # Read-only, additive to `--update`: for every file the manifest says + # changed since the last extraction, reverse-walk the graph (reusing + # `affected`'s traversal) to list nodes elsewhere that reference a node + # in that file and were NOT re-extracted — candidates worth a second + # look, not confirmed-wrong claims. See affected.stale_from_changed_files + # docstring for why this stops short of verifying the claims themselves. + from graphify.affected import DEFAULT_AFFECTED_RELATIONS, format_stale, load_graph + from graphify.detect import detect_incremental + from graphify.paths import GRAPHIFY_OUT_NAME + graph_path = _default_graph_path() + depth = 1 + relations: list[str] = [] + args = sys.argv[2:] + i = 0 + while i < len(args): + if args[i] == "--graph" and i + 1 < len(args): + graph_path = args[i + 1] + i += 2 + elif args[i].startswith("--graph="): + graph_path = args[i].split("=", 1)[1] + i += 1 + elif args[i] == "--depth" and i + 1 < len(args): + try: + depth = int(args[i + 1]) + except ValueError: + print("error: --depth must be an integer", file=sys.stderr) + sys.exit(1) + i += 2 + elif args[i].startswith("--depth="): + try: + depth = int(args[i].split("=", 1)[1]) + except ValueError: + print("error: --depth must be an integer", file=sys.stderr) + sys.exit(1) + i += 1 + elif args[i] == "--relation" and i + 1 < len(args): + relations.append(args[i + 1]) + i += 2 + elif args[i].startswith("--relation="): + relations.append(args[i].split("=", 1)[1]) + i += 1 + else: + i += 1 + gp = Path(graph_path).resolve() + if not gp.exists(): + print(f"error: graph file not found: {gp}", file=sys.stderr) + sys.exit(1) + try: + graph = load_graph(gp) + except Exception as exc: + print(f"error: could not load graph: {exc}", file=sys.stderr) + sys.exit(1) + graph_root = gp.parent.parent if gp.parent.name == GRAPHIFY_OUT_NAME else gp.parent + # Only compare the hash kind(s) this project has actually populated. + # `graphify update` (code, AST-only) stamps ast_hash and leaves + # semantic_hash "" forever if `extract`/`--update` never ran; per + # detect_incremental's own docstring, a missing hash for a kind + # always reads as "changed" for that kind — so checking a kind no + # pipeline has ever stamped would report every file as changed on + # every run, not because anything changed but because that kind was + # simply never used here. A brand-new manifest (first run, nothing + # stamped yet) falls back to checking both. + from graphify.detect import load_manifest as _load_manifest + manifest = _load_manifest(root=graph_root) + entries = [e for e in manifest.values() if isinstance(e, dict)] + kinds = set() + if any(e.get("ast_hash") for e in entries): + kinds.add("ast") + if any(e.get("semantic_hash") for e in entries): + kinds.add("semantic") + kinds = kinds or {"ast", "semantic"} + try: + changed_by_kind = { + f + for kind in kinds + for files in detect_incremental(graph_root, kind=kind).get("new_files", {}).values() + for f in files + } + except Exception as exc: + print(f"error: could not scan {graph_root} for changes: {exc}", file=sys.stderr) + sys.exit(1) + changed = sorted(changed_by_kind) + if not changed: + print("No files changed since the last extraction — nothing to check.") + else: + print( + format_stale( + graph, + changed, + relations=relations or DEFAULT_AFFECTED_RELATIONS, + depth=depth, + root=graph_root, + ) + ) elif cmd in ("god-nodes", "god_nodes"): # god_nodes has long been an analyzer (analyze.py), an MCP tool, and a # README-advertised capability, but never a CLI subcommand — `graphify diff --git a/tests/test_stale_from_changed_files.py b/tests/test_stale_from_changed_files.py new file mode 100644 index 0000000000..73161b0c77 --- /dev/null +++ b/tests/test_stale_from_changed_files.py @@ -0,0 +1,76 @@ +"""stale_from_changed_files() — surfaces nodes elsewhere in the graph that +reference a node in a changed file, so an incremental update can flag +possibly-stale relationships without re-verifying them (see affected.py +docstring for why this stops short of OpenWiki-style claim verification). +""" +from __future__ import annotations + +import networkx as nx + +from graphify.affected import stale_from_changed_files, format_stale + + +def _g(): + g = nx.DiGraph() + g.add_node("base_retry", label="BaseHandler.retry()", source_file="base.py") + g.add_node("worker_run", label="Worker.run()", source_file="worker.py") + g.add_node("worker_helper", label="Worker._helper()", source_file="worker.py") + g.add_node("unrelated", label="Other.noop()", source_file="other.py") + g.add_edge("worker_run", "base_retry", relation="calls", source_file="worker.py", source_location="L12") + g.add_edge("worker_helper", "worker_run", relation="calls", source_file="worker.py", source_location="L20") + g.add_edge("unrelated", "worker_run", relation="calls", source_file="other.py", source_location="L5") + return g + + +def test_changed_file_surfaces_external_caller(): + g = _g() + by_file = stale_from_changed_files(g, ["base.py"], depth=1) + assert "base.py" in by_file + hit_ids = {h.node_id for h in by_file["base.py"]} + assert hit_ids == {"worker_run"} + + +def test_depth_controls_how_far_the_walk_goes(): + g = _g() + depth1 = stale_from_changed_files(g, ["base.py"], depth=1) + depth2 = stale_from_changed_files(g, ["base.py"], depth=2) + assert {h.node_id for h in depth1["base.py"]} == {"worker_run"} + assert {h.node_id for h in depth2["base.py"]} == {"worker_run", "worker_helper", "unrelated"} + + +def test_unchanged_file_with_no_dependents_is_omitted(): + g = _g() + by_file = stale_from_changed_files(g, ["other.py"], depth=1) + assert by_file == {} + + +def test_changed_file_not_in_graph_is_omitted(): + g = _g() + by_file = stale_from_changed_files(g, ["never_extracted.py"], depth=1) + assert by_file == {} + + +def test_seed_nodes_from_the_changed_file_itself_never_reported_as_hits(): + # worker.py contains both worker_run and worker_helper, and unrelated.py + # (other.py) calls into worker_run. Changing worker.py must surface the + # external caller (unrelated) but never report worker_run/worker_helper + # as hits of their own file's change. + g = _g() + by_file = stale_from_changed_files(g, ["worker.py"], depth=2) + hit_ids = {h.node_id for h in by_file["worker.py"]} + assert hit_ids == {"unrelated"} + + +def test_format_stale_reports_no_hits_plainly(): + g = _g() + out = format_stale(g, ["other.py"], depth=1) + assert "nothing flagged" in out + + +def test_format_stale_includes_relation_and_location(): + g = _g() + out = format_stale(g, ["base.py"], depth=1) + assert "base.py changed" in out + assert "Worker.run()" in out + assert "[calls]" in out + assert "worker.py:L12" in out