fix(update): preserve surviving hyperedges on AST-only rebuild (#1755)

`graphify update` (the watch._rebuild_code / _reconcile_existing_graph path)
evicted every hyperedge whose source_file is in the corpus, because on a full
update every corpus file counts as "rebuilt" and hyperedge eviction reused the
node/edge eviction set. But the AST pass never emits hyperedges, so nothing
replaced them — doc-sourced hyperedges (what semantic extraction produces) were
permanently lost on the first update after a full build, even on a no-op run.

Split out a hyperedge_evicted_source_identities set scoped to genuinely deleted
(and symlink-target-outside) sources only, not merely-rebuilt ones. Replacement-
by-id (new_hyperedge_ids) and dangling-member cleanup are unchanged, so a real
semantic re-extraction still replaces its own hyperedges and orphaned ones are
still dropped. Parametrized regression test (full + incremental doc update).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
oleksii-tumanov
2026-07-10 10:48:57 +01:00
committed by safishamsi
co-authored by Claude Opus 4.8
parent dae602ccd1
commit 51b15a9ff1
3 changed files with 56 additions and 1 deletions
+2
View File
@@ -4,6 +4,8 @@ Full release notes with details on each version: [GitHub Releases](https://githu
## 0.9.12 (unreleased)
- Fix: `graphify update` no longer deletes semantic hyperedges on every run (#1755, thanks @oleksii-tumanov). The AST-only rebuild treated every rebuilt corpus file as grounds to evict hyperedges anchored to it, but the AST pass never re-emits hyperedges, so doc-sourced hyperedges (exactly what semantic extraction produces) were permanently lost on the first `update` after a full build — even a no-op run. Hyperedge eviction is now scoped to genuinely deleted (or symlink-outside) sources, mirroring node/edge handling; replacement-by-id and dangling-member cleanup are unchanged.
- Fix: Java member calls resolve against the receiver's declared type instead of a bare method-name match (#1696/#1697, thanks @oleksii-tumanov). `gw.charge()` where `gw: PaymentGateway` now binds to `PaymentGateway.charge`, not a same-named `AuditLog.charge` in another file. Explicit-type receivers and `this` are exact; current-class fields, method parameters, and explicitly-typed locals resolve via a method-scoped type table; a missing, ambiguous, inherited, or chained receiver is skipped rather than guessed (same god-node guard as the C#/Swift/Ruby resolvers). Fully-qualified and nested-type receivers are deferred (they need package/nesting-aware type identity).
- Fix: output/cache artifacts no longer land in the scanned corpus or CWD when `--out`/`--graph` point elsewhere (#1747, thanks @bbqboogiedwonsen). `extract <corpus> --out <dir>` correctly wrote the graph to `<dir>` but `detect()`'s word-count/stat-index cache still created a stray `graphify-out/cache/` inside the corpus (it uses the scan root); it now honors the `--out` dir via a threaded `cache_root`. And `cluster-only --graph <elsewhere>/graphify-out/graph.json` wrote `GRAPH_REPORT.md`/labels/analysis/re-clustered graph to the CWD instead of beside the input; it now writes beside `--graph` when that graph lives in a `graphify-out/` dir, while still restoring into the CWD for an archived `backup/graph.json` (#934).
+3 -1
View File
@@ -407,6 +407,7 @@ def _reconcile_existing_graph(
source_paths.absolute_identity(str(path), project_root) for path in extract_targets
}
node_evicted_source_identities = set(deleted_source_identities)
hyperedge_evicted_source_identities = set(deleted_source_identities)
if not full_rebuild:
node_evicted_source_identities.update(rebuilt_source_identities)
edge_evicted_source_identities = (
@@ -431,6 +432,7 @@ def _reconcile_existing_graph(
if identity:
node_evicted_source_identities.add(identity)
edge_evicted_source_identities.add(identity)
hyperedge_evicted_source_identities.add(identity)
# A full re-extraction owns every AST node under watch_root. Incremental
# extraction owns only nodes from rebuilt or deleted sources. Semantic
@@ -473,7 +475,7 @@ def _reconcile_existing_graph(
for edge in existing.get("hyperedges", []):
members = edge.get("nodes", edge.get("members", edge.get("node_ids", [])))
if edge.get("id") in new_hyperedge_ids or source_paths.is_evicted(
edge, edge_evicted_source_identities
edge, hyperedge_evicted_source_identities
):
continue
if isinstance(members, list) and any(member not in all_ids for member in members):
+51
View File
@@ -276,6 +276,57 @@ def _add_unrelated_semantic_pair(graph_path):
graph_path.write_text(json.dumps(data), encoding="utf-8")
@pytest.mark.parametrize(
"changed_paths",
[None, [Path("doc.md")]],
ids=["full-update", "incremental-doc-update"],
)
def test_rebuild_code_preserves_hyperedges_for_rebuilt_surviving_source(
tmp_path, changed_paths
):
"""#1755: AST-only updates must not drop semantic hyperedges whose members survive."""
from graphify.watch import _rebuild_code
corpus = tmp_path / "corpus"
corpus.mkdir()
(corpus / "doc.md").write_text(
"# Design\n\n## Flow\n\nDetails.\n", 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"))
assert {"doc", "doc_design"} <= {node["id"] for node in data["nodes"]}
data["hyperedges"] = [{
"id": "doc_flow_group",
"label": "Doc flow group",
"nodes": ["doc", "doc_design"],
"relation": "implements",
"confidence": "EXTRACTED",
"confidence_score": 1.0,
"source_file": "doc.md",
}]
graph_path.write_text(json.dumps(data), encoding="utf-8")
assert _rebuild_code(
corpus,
changed_paths=changed_paths,
no_cluster=True,
acquire_lock=False,
) is True
after = json.loads(graph_path.read_text(encoding="utf-8"))
assert after["hyperedges"] == [{
"id": "doc_flow_group",
"label": "Doc flow group",
"nodes": ["doc", "doc_design"],
"relation": "implements",
"confidence": "EXTRACTED",
"confidence_score": 1.0,
"source_file": "doc.md",
}]
@pytest.mark.parametrize(
"changed_paths",
[None, [Path("only.py")]],