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:
erichkusuki
2026-07-11 17:22:25 +01:00
committed by safishamsi
parent 0d0e5780c8
commit 091f6095b4
3 changed files with 67 additions and 0 deletions
+2
View File
@@ -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.
+7
View File
@@ -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