diff --git a/CHANGELOG.md b/CHANGELOG.md index 277bc85..043594d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 --out ` correctly wrote the graph to `` 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 /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). diff --git a/graphify/watch.py b/graphify/watch.py index 94d4e8b..45a4a67 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 f0a5469..22021eb 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")]],