diff --git a/CHANGELOG.md b/CHANGELOG.md index 5bc971b89..609034614 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ Full release notes with details on each version: [GitHub Releases](https://github.com/safishamsi/graphify/releases) +## Unreleased + +- Fix: a full `_rebuild_code`/`graphify update` now reconciles away legacy AST nodes that predate the `_origin` provenance marker (#1116) — or that were minted under an older node-id scheme — instead of leaving them as permanent orphans. Such a node carries no `_origin=="ast"`, so the AST-ownership eviction rule in `_reconcile_existing_graph` skipped it, and `build_from_json`'s ghost-merge (which keys on `(basename, label)`) could not reconcile it in a monorepo where that key is ambiguous across same-named files — e.g. `apps/aga/.../solicitud.component.ts` and `apps/se/.../solicitud.component.ts` both defining `SolicitudComponent`. On a real Nx monorepo this stranded ~52k zero-degree ghost nodes (35% of the graph), which inflated the community count and diluted cohesion. The reconcile pass now also evicts a preserved node when the fresh AST extraction re-produced the same symbol under the canonical id, matched by exact `(canonical source path, label)` identity; genuine semantic nodes on the same file (distinct label) are still preserved. + ## 0.9.14 (2026-07-12) - Fix: Visual Studio *solution folder* nodes no longer embed the absolute scan path (including the local username) in their `id` and `source_file` (#1789, thanks @fremat79). A solution folder is a virtual grouping, not a file — VS writes its name as both the display name and the "path" — but `extract_sln` resolved it to an absolute filesystem path anyway and keyed the node id off that. The CLI's id-relativization pass only remaps ids of real files in the scan set, so a virtual folder never matched and its absolute id survived into a committed `graph.json` (e.g. `id=/Users//proj/Plugins` instead of `id=plugins`). Solution folders are now detected (name == path) and keyed off the folder name only; real project files still resolve as before. (The earlier fix covered `.csproj`/`.sln` file nodes but missed the virtual folders — this completes it.) diff --git a/graphify/watch.py b/graphify/watch.py index db5d2a86d..b3e87c2ff 100644 --- a/graphify/watch.py +++ b/graphify/watch.py @@ -400,6 +400,21 @@ def _reconcile_existing_graph( normalize_source=_nsf, ) new_ast_ids = {n["id"] for n in result["nodes"]} + # Identity of every symbol the fresh AST pass just re-produced, keyed by + # (canonical source path, label). A preserved node that matches one of + # these but carries a *different* id is a stale twin of the freshly + # extracted node — typically a legacy node from a graph.json written + # before the `_origin` marker (#1116) or before an id-scheme change, so + # the AST-ownership rule below (which gates on `_origin == "ast"`) can't + # see it, and build_from_json's (basename, label) ghost-merge can't + # reconcile it in a monorepo where that key is ambiguous across + # same-named files. Evict it here by exact source+label identity so the + # next full rebuild self-heals instead of accumulating orphan ghosts. + fresh_ast_identities = { + (source_paths.identity(node.get("source_file")), node.get("label")) + for node in result["nodes"] + if node.get("source_file") and node.get("label") + } current_sources = { source_paths.absolute_identity(str(path), project_root) for path in code_files } @@ -482,6 +497,11 @@ def _reconcile_existing_graph( ) ) and not source_paths.is_evicted(node, node_evicted_source_identities) + and ( + source_paths.identity(node.get("source_file")), + node.get("label"), + ) + not in fresh_ast_identities ] all_ids = new_ast_ids | {node["id"] for node in preserved_nodes} diff --git a/tests/test_watch.py b/tests/test_watch.py index 24e698169..7f4694031 100644 --- a/tests/test_watch.py +++ b/tests/test_watch.py @@ -1466,3 +1466,65 @@ def test_rebuild_code_still_evicts_when_excluded_file_is_also_deleted(tmp_path): labels = {n["label"] for n in json.loads(graph_path.read_text(encoding="utf-8"))["nodes"]} assert "brainstorm.md" not in labels, "deleted file's nodes must still be evicted" assert "login()" in labels + + +def test_rebuild_code_evicts_legacy_ast_ghost_without_origin_marker(tmp_path): + """A full rebuild must reconcile away a legacy AST node that predates the + `_origin` marker (older graph.json, or a node-id-scheme migration) when the + fresh AST pass re-produces the same symbol under the canonical id. + + Regression: such a node carries no `_origin=="ast"`, so the AST-ownership + eviction rule misses it, and build_from_json's (basename, label) ghost-merge + cannot reconcile it in a monorepo where that key is ambiguous across + same-named files (app_a/... and app_b/... both define SolicitudComponent). + The node then survives forever as an orphan with zero edges. Genuine + semantic nodes on the same file (different label) must still be preserved. + """ + from graphify.watch import _rebuild_code + + corpus = tmp_path / "corpus" + (corpus / "app_a").mkdir(parents=True) + (corpus / "app_b").mkdir(parents=True) + src = "export class SolicitudComponent {\n constructor() {}\n}\n" + (corpus / "app_a" / "solicitud.component.ts").write_text(src, encoding="utf-8") + (corpus / "app_b" / "solicitud.component.ts").write_text(src, encoding="utf-8") + + assert _rebuild_code(corpus, no_cluster=True, acquire_lock=False) is True + graph_path = corpus / "graphify-out" / "graph.json" + data = json.loads(graph_path.read_text(encoding="utf-8")) + + # Seed a legacy ghost (no `_origin`, old doubled-stem id) for app_a's + # component, plus a genuine semantic node on the same file that must survive. + ghost_id = "app_a_solicitud_component_ts_solicitud_component_solicitudcomponent" + data["nodes"].append({ + "id": ghost_id, + "label": "SolicitudComponent", + "file_type": "code", + "source_file": "app_a/solicitud.component.ts", + "source_location": "L1", + }) + data["nodes"].append({ + "id": "app_a_solicitud_component_wizard_pattern", + "label": "Wizard Multi-Step Pattern", + "file_type": "rationale", + "source_file": "app_a/solicitud.component.ts", + "source_location": None, + }) + graph_path.write_text(json.dumps(data), encoding="utf-8") + + assert _rebuild_code(corpus, no_cluster=True, force=True, acquire_lock=False) is True + after = json.loads(graph_path.read_text(encoding="utf-8")) + ids = {n["id"] for n in after["nodes"]} + + assert ghost_id not in ids, "legacy AST ghost must be evicted on full rebuild" + assert "app_a_solicitud_component_wizard_pattern" in ids, ( + "genuine semantic node on the same file must be preserved" + ) + # The canonical freshly-extracted node survives and still carries its edges. + canonical = "app_a_solicitud_component_solicitudcomponent" + assert canonical in ids + degree = 0 + for edge in after.get("links", after.get("edges", [])): + if canonical in (edge.get("source"), edge.get("target")): + degree += 1 + assert degree > 0, "canonical node must retain its structural edges"