From c0be44f99cd3eaf594fcc5e70501c1351c818cd1 Mon Sep 17 00:00:00 2001 From: "Roberto D. Garcia" Date: Mon, 13 Jul 2026 08:04:43 -0600 Subject: [PATCH] fix(watch): evict legacy AST ghost nodes lacking the _origin marker on full rebuild MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A full `_rebuild_code`/`graphify update` left legacy AST nodes stranded as permanent zero-degree orphans when they predated the `_origin` provenance marker (#1116) or an earlier node-id scheme. `_reconcile_existing_graph`'s AST-ownership eviction gates on `_origin == "ast"`, so a marker-less node escaped it, and `build_from_json`'s ghost-merge keys on `(basename, label)` — ambiguous in a monorepo where same-named files (app_a/solicitud.component.ts, app_b/solicitud.component.ts) both define `SolicitudComponent` — so it refused to reconcile. On a real Nx monorepo this stranded ~52k ghost nodes (35% of the graph), inflating community count and diluting cohesion. The reconcile pass now also evicts a preserved node when the fresh AST pass 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. Adds a regression test reproducing the monorepo collision that defeats the (basename, label) ghost-merge. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 4 +++ graphify/watch.py | 20 +++++++++++++++ tests/test_watch.py | 62 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 86 insertions(+) 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"