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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>/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.)
Expand Down
20 changes: 20 additions & 0 deletions graphify/watch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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}

Expand Down
62 changes: 62 additions & 0 deletions tests/test_watch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"