Skip to content
Open
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
5 changes: 5 additions & 0 deletions graphify/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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> 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> 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> path to graph.json (default graphify-out/graph.json)")
Expand Down
83 changes: 83 additions & 0 deletions graphify/affected.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
95 changes: 95 additions & 0 deletions graphify/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
76 changes: 76 additions & 0 deletions tests/test_stale_from_changed_files.py
Original file line number Diff line number Diff line change
@@ -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