mirror of
https://github.com/safishamsi/graphify.git
synced 2026-07-14 19:37:12 +00:00
fix(build): never prune a re-extracted source in build_merge (#1796)
build_merge already drops a re-extracted file's stale base nodes before merging (replace-per-source), so on current code an EDITED file passed only in new_chunks is handled correctly. But the prune step still removed every node whose source_file was in prune_sources, with no guard for re-extracted files — so a caller following the old edit-workflow (pass the changed file in BOTH new_chunks and prune_sources) had its freshly-built nodes deleted after the merge, silently losing a concept whose label survived the edit. Exclude new_sources (files present in new_chunks) from prune_set: a re-extracted file is being replaced, never deleted, so "replace" wins over a contradictory "delete" of the same source. Genuine deletions (in prune_sources but not new_chunks) still prune. Regression tests for both. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,8 @@ Full release notes with details on each version: [GitHub Releases](https://githu
|
||||
|
||||
## 0.9.13 (unreleased)
|
||||
|
||||
- Fix: `build_merge` no longer silently deletes a re-extracted file's fresh nodes when that file is also passed in `prune_sources` (#1796, thanks @erichkusuki). A file present in `new_chunks` is being replaced, not deleted, so it's now excluded from the prune set — "replace" wins over a contradictory "delete" of the same source. Previously, following the old edit-workflow (pass the changed file in `prune_sources`) deleted the just-built concept whenever an edit kept a node's label. Genuine deletions (a file in `prune_sources` but not `new_chunks`) still prune.
|
||||
|
||||
- Fix: `graphify path` resolves each endpoint to the first candidate whose label contains every query token, instead of blindly taking the top-scored node (#1785, thanks @CJNA). `_score_nodes`' full-query bonus only fires when the query equals/prefixes a label, so a query that is a token *subset* of the intended label (`"Reject-everything judge"` vs `"Degenerate Reject-Everything Judge"`) got no bonus and a node prefix-matching one rare token could outscore it — anchoring the path on an unrelated, often disconnected node and yielding a false "No path found". When the top candidate already full-matches (the common case) the pick is unchanged. Applied to both the `path` CLI and the MCP shortest-path tool; the close-runner-up ambiguity warning now fires only when the score head is what was actually picked.
|
||||
|
||||
- Fix: the report's "Suggested Questions" weakly-connected-node count now matches its "Knowledge Gaps" count (#1768, thanks @balloon72). `suggest_questions()` omitted the `file_type != "rationale"` filter that `report.py`'s Knowledge Gaps section applies, so the same `GRAPH_REPORT.md` showed two different numbers for the same concept (e.g. 757 vs 245), making a healthy graph look like it had a major documentation gap. Both computations now use the same filter.
|
||||
|
||||
@@ -908,6 +908,13 @@ def build_merge(
|
||||
norm = _norm_source_file(p, _eff_root)
|
||||
if norm:
|
||||
prune_set.add(norm)
|
||||
# A file that was just re-extracted (present in new_chunks) is being REPLACED,
|
||||
# never deleted — so never prune it, even if the caller also lists it in
|
||||
# prune_sources. Otherwise its fresh, just-built nodes are silently removed
|
||||
# (data loss): common when an edit keeps a node's label and the caller follows
|
||||
# the old edit-workflow of passing the changed file in prune_sources (#1796).
|
||||
# "replace" wins over a contradictory "delete" of the same source.
|
||||
prune_set -= new_sources
|
||||
|
||||
# Carry forward hyperedges from files that were neither re-extracted nor
|
||||
# deleted (#1574). build() only sees the new chunks' hyperedges, so without
|
||||
|
||||
@@ -149,3 +149,61 @@ def test_prune_matches_across_symlinked_root(tmp_path):
|
||||
prune_sources=[str(link / "HANDOFF.md")], root=str(real), dedup=False)
|
||||
labels = {d["label"] for _, d in G.nodes(data=True)}
|
||||
assert "handoff" not in labels and "keep" in labels
|
||||
|
||||
|
||||
def test_reextracted_file_in_prune_sources_is_not_deleted(tmp_path):
|
||||
"""#1796: a file present in BOTH new_chunks (re-extracted) and prune_sources
|
||||
must be REPLACED, not deleted. The old edit-workflow passed the changed file
|
||||
in prune_sources; combined with dedup keeping a same-label node, that used to
|
||||
silently delete the freshly re-extracted concept. Replace wins over delete."""
|
||||
graph_path = tmp_path / "graphify-out" / "graph.json"
|
||||
graph_path.parent.mkdir(parents=True)
|
||||
_write_graph(
|
||||
graph_path,
|
||||
nodes=[
|
||||
{"id": "foo_widget_cache", "label": "Widget Cache Design",
|
||||
"file_type": "concept", "source_file": "docs/foo.md", "source_location": "L1"},
|
||||
{"id": "bar_other", "label": "Other",
|
||||
"file_type": "concept", "source_file": "docs/bar.md", "source_location": "L1"},
|
||||
],
|
||||
edges=[],
|
||||
hyperedges=[],
|
||||
)
|
||||
# foo.md edited: same-label node re-extracted (new content/line)
|
||||
new_chunk = {"nodes": [
|
||||
{"id": "foo_widget_cache", "label": "Widget Cache Design",
|
||||
"file_type": "concept", "source_file": "docs/foo.md", "source_location": "L2"}
|
||||
], "edges": []}
|
||||
|
||||
G = build_merge([new_chunk], graph_path=str(graph_path),
|
||||
prune_sources=["docs/foo.md"], root=str(tmp_path))
|
||||
labels = {G.nodes[n].get("label") for n in G.nodes()}
|
||||
assert "Widget Cache Design" in labels, "re-extracted node was wrongly pruned"
|
||||
|
||||
|
||||
def test_genuine_deletion_still_prunes(tmp_path):
|
||||
"""#1796 guard must not break real deletions: a file in prune_sources but NOT
|
||||
in new_chunks is still removed."""
|
||||
graph_path = tmp_path / "graphify-out" / "graph.json"
|
||||
graph_path.parent.mkdir(parents=True)
|
||||
_write_graph(
|
||||
graph_path,
|
||||
nodes=[
|
||||
{"id": "foo_widget_cache", "label": "Widget Cache Design",
|
||||
"file_type": "concept", "source_file": "docs/foo.md", "source_location": "L1"},
|
||||
{"id": "bar_other", "label": "Other",
|
||||
"file_type": "concept", "source_file": "docs/bar.md", "source_location": "L1"},
|
||||
],
|
||||
edges=[],
|
||||
hyperedges=[],
|
||||
)
|
||||
new_chunk = {"nodes": [
|
||||
{"id": "foo_widget_cache", "label": "Widget Cache Design",
|
||||
"file_type": "concept", "source_file": "docs/foo.md", "source_location": "L2"}
|
||||
], "edges": []}
|
||||
# bar.md genuinely deleted (not re-extracted)
|
||||
G = build_merge([new_chunk], graph_path=str(graph_path),
|
||||
prune_sources=["docs/bar.md"], root=str(tmp_path))
|
||||
labels = {G.nodes[n].get("label") for n in G.nodes()}
|
||||
assert "Other" not in labels, "genuinely deleted file's node should be pruned"
|
||||
assert "Widget Cache Design" in labels
|
||||
|
||||
Reference in New Issue
Block a user