From 947ecd6524f4d501edc14d04255ec1b135600bea Mon Sep 17 00:00:00 2001 From: oleksii-tumanov Date: Fri, 10 Jul 2026 00:00:23 -0500 Subject: [PATCH] fix(update): preserve surviving hyperedges --- graphify/watch.py | 4 +++- tests/test_watch.py | 51 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/graphify/watch.py b/graphify/watch.py index 94d4e8b20..45a4a673c 100644 --- a/graphify/watch.py +++ b/graphify/watch.py @@ -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): diff --git a/tests/test_watch.py b/tests/test_watch.py index f0a54690f..22021ebd4 100644 --- a/tests/test_watch.py +++ b/tests/test_watch.py @@ -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")]],