fix(watch): evict legacy AST ghost nodes lacking the _origin marker on full rebuild#1853
Open
robertgarcia wants to merge 1 commit into
Open
fix(watch): evict legacy AST ghost nodes lacking the _origin marker on full rebuild#1853robertgarcia wants to merge 1 commit into
robertgarcia wants to merge 1 commit into
Conversation
…n full rebuild A full `_rebuild_code`/`graphify update` left legacy AST nodes stranded as permanent zero-degree orphans when they predated the `_origin` provenance marker (Graphify-Labs#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) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
On a large Nx monorepo, a full
_rebuild_code/graphify updateleaves tens of thousands of legacy AST nodes stranded as permanent zero-degree orphans. On one real repo this was ~52k ghost nodes — 35% of the graph — which inflated the community count (74k "communities" for 200k nodes) and drove average cohesion to ~0.01.Each ghost is a duplicate of a real symbol: same
source_file, samesource_location, samelabel, but a different (older-scheme)id, and no edges. Example pair from the affected repo:Root cause
Two reconciliation layers both miss these nodes:
_reconcile_existing_graph(watch.py) — the AST-ownership eviction rule gates on_origin == "ast". Nodes written by a graphify version that predates the_originmarker (graphify update leaks stale nodes: symbols removed from surviving files are never pruned #1116), or that predate a node-id-scheme change, carry no marker, so they escape eviction and are preserved.build_from_json's ghost-merge (build.py) — reconciles ghosts by(basename, label). In a monorepo this key is ambiguous:apps/aga/.../solicitud.component.tsandapps/se/.../solicitud.component.tsboth defineSolicitudComponent, so two fresh AST nodes share(solicitud.component.ts, SolicitudComponent). The merge conservatively marks the key ambiguous (Ghost-merge picks an arbitrary AST node when two same-named files define the same symbol #1257) and refuses to merge — so the legacy ghost survives.The result: a marker-less legacy node whose file is re-extracted on every full rebuild is never reconciled, and accumulates forever.
Fix
_reconcile_existing_graphnow 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 (viasource_paths.identity(...), robust to relative/absolute path spelling). This is self-scoping — the fresh output only contains re-extracted files — and does not depend on the_originmarker, so legacy graphs self-heal on the next full rebuild.Genuine semantic nodes on the same file are preserved, because they carry a distinct label that the AST never emits (a same-label collision is exactly the case
build_from_jsonalready treats as a ghost, so behavior stays consistent).Reproduction (before the fix)
Two same-named files defeat the
(basename, label)ghost-merge; a marker-less legacy node then survives a full rebuild:After the fix:
Tests
test_rebuild_code_evicts_legacy_ast_ghost_without_origin_markerintests/test_watch.pyreproduces the monorepo collision and asserts: ghost evicted, semantic node preserved, canonical node keeps its edges.tests/test_watch.py— 59 passed, 2 skipped.tests/test_incremental.py+tests/test_build.py— 53 passed.test_build_merge_hyperedges_and_prune,test_semantic_id_remap_root,test_semantic_cleanup,test_obsidian_dangling_member,test_extraction_spec_ids,test_id_normalization_contract,test_community_hub_labels) — 99 passed, 1 skipped.ruff checkclean on changed files.Scope / safety
graphify/watch.py,tests/test_watch.py,CHANGELOG.md.