fix(build_merge): revive the #479 shrink guard and fix absolute-path prune (#2497, #2446)

#2497 (thanks @sortakool): the shrink guard read the post-replace node
count and was skipped under prune_sources, so a broken partial re-extract
could silently destroy nodes. It now diffs the on-disk baseline by node
identity and refuses any loss from a source neither re-extracted nor
pruned this run, and reports replaced-node counts.
#2446 (thanks @AI-invest): prune_sources given absolute paths under a
non-standard layout now derives the scan root by suffix-matching stored
source paths, reports the accurate matched count, and warns instead of
saying 'already clean' when nothing matched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
safishamsi
2026-08-06 17:29:18 +01:00
co-authored by Claude Opus 4.8
parent 07b9143d4b
commit b8d609732c
2 changed files with 511 additions and 70 deletions
+274 -70
View File
@@ -402,21 +402,108 @@ def _infer_merge_root(graph_path: Path) -> str | None:
Prefers the committed ``graphify-out/.graphify_root`` marker — the authoritative
scan root graphify records at build/watch time (#686/#1423) — then falls back to
the directory that contains the output dir (``graph.json``'s grandparent, i.e.
``<root>/graphify-out/graph.json`` -> ``<root>``). Returns None if neither
resolves, in which case normalization is a no-op (prior behavior).
``<root>/graphify-out/graph.json`` -> ``<root>``). The grandparent heuristic is
applied only when ``graph.json``'s own directory actually looks like a graphify
out-dir (named like one, or holding the marker/manifest); for arbitrary layouts
(``<root>/graph.json``, #2446) the grandparent is NOT the scan root, and guessing
it made absolute prune_sources silently no-op. Returns None if neither resolves,
in which case normalization is a no-op (prior behavior) and prune matching can
still recover via :func:`_derive_prune_root`.
"""
parent = graph_path.parent
try:
marker = graph_path.parent / ".graphify_root"
marker = parent / ".graphify_root"
if marker.exists():
recorded = marker.read_text(encoding="utf-8").strip()
if recorded:
return str(Path(recorded).resolve())
except OSError:
pass
from .paths import GRAPHIFY_OUT
try:
return str(graph_path.parent.parent.resolve())
if (
parent.name == Path(GRAPHIFY_OUT).name
or (parent / ".graphify_root").exists()
or (parent / "manifest.json").exists()
):
return str(parent.parent.resolve())
except Exception:
pass
return None
def _build_prune_sets(
prune_sources: "list[str] | None",
eff_root: "str | None",
new_sources: "set[str]",
) -> "tuple[dict[str, str], dict[str, str]]":
"""Prune match sets for build_merge / merge_raw_extraction.
Returns ``(prune_set, prune_abs)`` mapping every match form of each prune
entry — the raw string, the :func:`_norm_source_file` relative form, and the
:func:`_abs_identity` absolute form — back to the ORIGINAL entry, so callers
can report which entries actually matched something (#2446). Membership
tests read like the old set-based code (``sf in prune_set``).
"Replace" wins over a contradictory "delete" of the same source (#1796), in
both string and absolute-identity space (#2012): every form belonging to a
source re-extracted this run is removed.
"""
prune_set: dict[str, str] = {}
prune_abs: dict[str, str] = {}
for p in (prune_sources or []):
if not p:
continue
prune_set.setdefault(p, p)
norm = _norm_source_file(p, eff_root)
if norm:
prune_set.setdefault(norm, p)
a = _abs_identity(p, eff_root)
if a:
prune_abs.setdefault(a, p)
for s in new_sources:
prune_set.pop(s, None)
a = _abs_identity(s, eff_root)
if a:
prune_abs.pop(a, None)
return prune_set, prune_abs
def _derive_prune_root(prune_sources: "list[str]", stored_sfs: "set[str]") -> "str | None":
"""Derive the scan root from absolute prune paths that matched nothing (#2446).
When build_merge/merge_raw_extraction receive absolute prune_sources but no
``root``, :func:`_infer_merge_root`'s guess can be wrong for non-standard
layouts (``graph.json`` not under ``<root>/graphify-out/``), so every prune
entry silently no-ops. Each absolute prune path ``P`` that ends with a
stored RELATIVE source_file ``S`` implies the candidate root
``P[:-len(S)-1]``. Within one graph all relative source_files share a single
scan root, so the candidate is accepted only when it is unique and
consistent across every suffix-matched entry; on ambiguity (or no suffix
match at all) returns None and the caller falls through to the zero-match
warning.
"""
rel_sfs = [
sf.replace("\\", "/")
for sf in stored_sfs
if sf and isinstance(sf, str) and not os.path.isabs(sf.replace("\\", "/"))
]
if not rel_sfs:
return None
roots: set[str] = set()
for p in prune_sources:
if not p or not isinstance(p, str):
continue
q = p.replace("\\", "/")
if not os.path.isabs(q):
continue
hits = {q[: -len(s) - 1] for s in rel_sfs if q.endswith("/" + s)}
if len(hits) > 1:
return None # one entry implies two different roots — ambiguous
roots |= hits
if len(roots) == 1:
return next(iter(roots))
return None
def edge_data(G: nx.Graph, u: str, v: str) -> dict:
@@ -1398,24 +1485,41 @@ def merge_raw_extraction(
tier_sources.add(norm)
new_sources: set[str] = new_ast_sources | new_sem_sources
prune_set: set[str] = set()
prune_abs: set[str] = set()
for p in (prune_sources or []):
if not p:
continue
prune_set.add(p)
norm = _norm_source_file(p, _eff_root)
if norm:
prune_set.add(norm)
a = _abs_identity(p, _eff_root)
if a:
prune_abs.add(a)
# "Replace" wins over a contradictory "delete" of the same source (#1796),
# in both string and absolute-identity space (#2012) — as in build_merge.
prune_set -= new_sources
new_abs = {_abs_identity(s, _eff_root) for s in new_sources}
new_abs.discard(None)
prune_abs -= new_abs
prune_set, prune_abs = _build_prune_sets(prune_sources, _eff_root, new_sources)
_prune_root = _eff_root
def _prune_hit(sf: "str | None") -> bool:
if not sf:
return False
if sf in prune_set:
return True
norm = _norm_source_file(sf, _prune_root)
if norm and norm in prune_set:
return True
a = _abs_identity(sf, _prune_root)
return bool(a) and a in prune_abs
# #2446: when NONE of the prune entries match anything stored, the guessed
# _eff_root is usually wrong (non-standard layout, no marker) and every
# prune silently no-ops. Derive the root by suffix-matching the absolute
# prune paths against the stored relative source_files and retry — same
# fallback as build_merge.
if prune_set or prune_abs:
_stored_sfs = {
item.get("source_file")
for seq in (existing_nodes, existing_edges, existing_hyperedges)
for item in seq if isinstance(item, dict)
}
_stored_sfs.discard(None)
if not any(_prune_hit(sf) for sf in _stored_sfs):
_derived = _derive_prune_root(prune_sources or [], _stored_sfs)
if _derived is not None and _derived != _prune_root:
_prune_root = _derived
prune_set, prune_abs = _build_prune_sets(
prune_sources, _prune_root, new_sources
)
def _dropped(item: dict) -> bool:
if not isinstance(item, dict):
@@ -1430,13 +1534,7 @@ def merge_raw_extraction(
return True # re-extracted this run — replaced by the new chunk
if not sf:
return False # unowned — carry forward
if sf in prune_set:
return True
norm = _norm_source_file(sf, _eff_root)
if norm and norm in prune_set:
return True
a = _abs_identity(sf, _eff_root)
return bool(a) and a in prune_abs
return _prune_hit(sf)
new["nodes"] = [n for n in existing_nodes if not _dropped(n)] + list(new.get("nodes", []))
new["edges"] = [e for e in existing_edges if not _dropped(e)] + list(new.get("edges", []))
@@ -1528,6 +1626,12 @@ def build_merge(
if norm:
tier_sources.add(norm)
new_sources: set[str] = new_ast_sources | new_sem_sources
# True on-disk baseline for the #479 shrink accounting at the end (#2497):
# the rebind below removes the re-extracted sources' old nodes from
# existing_nodes, so any later size comparison against the rebound list can
# never see the loss it is meant to catch.
_disk_nodes = existing_nodes
_disk_n = len(existing_nodes)
if new_sources:
def _kept(item: dict) -> bool:
sf = item.get("source_file")
@@ -1535,6 +1639,13 @@ def build_merge(
return sf not in own and _norm_source_file(sf, _replace_root) not in own
existing_nodes = [n for n in existing_nodes if _kept(n)]
existing_edges = [e for e in existing_edges if _kept(e)]
replaced_n = _disk_n - len(existing_nodes)
if replaced_n:
print(
f"[graphify] Replaced {replaced_n} node(s) from re-extracted "
f"source file(s).",
file=sys.stderr,
)
base = [{"nodes": existing_nodes, "edges": existing_edges}] if had_graph else []
@@ -1546,18 +1657,7 @@ def build_merge(
# relativised by _norm_source_file at build time). .resolve() (via _eff_root)
# handles symlinked roots and ".." / "./" segments so Path.relative_to()
# succeeds even when the scan root is a symlink. (#1007, #1571)
prune_set: set[str] = set()
prune_abs: set[str] = set()
for p in (prune_sources or []):
if not p:
continue
prune_set.add(p)
norm = _norm_source_file(p, _eff_root)
if norm:
prune_set.add(norm)
a = _abs_identity(p, _eff_root)
if a:
prune_abs.add(a)
#
# 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
@@ -1566,24 +1666,53 @@ def build_merge(
# "replace" wins over a contradictory "delete" of the same source. Applied in
# both string and absolute-identity space so the third-form fallback below
# can't resurrect the delete for a re-extracted file (#2012).
prune_set -= new_sources
new_abs = {_abs_identity(s, _eff_root) for s in new_sources}
new_abs.discard(None)
prune_abs -= new_abs
_prune_root = _eff_root
prune_set, prune_abs = _build_prune_sets(prune_sources, _prune_root, new_sources)
_matched_prune_entries: set[str] = set()
def _prune_match(sf: "str | None") -> bool:
# Match a node/edge/hyperedge source_file against the prune set in a
# form-insensitive way: exact string, normalised-relative, then the
# absolute-identity fallback for the third-form case (#2012).
# absolute-identity fallback for the third-form case (#2012). Records
# WHICH prune entry matched, so the prune report can count only the
# entries that actually hit something (#2446).
if not sf:
return False
if sf in prune_set:
return True
norm = _norm_source_file(sf, _eff_root)
if norm and norm in prune_set:
return True
a = _abs_identity(sf, _eff_root)
return bool(a) and a in prune_abs
hit = prune_set.get(sf)
if hit is None:
norm = _norm_source_file(sf, _prune_root)
if norm:
hit = prune_set.get(norm)
if hit is None:
a = _abs_identity(sf, _prune_root)
if a:
hit = prune_abs.get(a)
if hit is None:
return False
_matched_prune_entries.add(hit)
return True
# #2446: when NONE of the prune entries match anything stored, the guessed
# _eff_root is usually wrong (non-standard layout, no marker) and every
# prune would silently no-op. Derive the root by suffix-matching the
# absolute prune paths against the stored relative source_files and redo
# the prune sets with it; on ambiguity fall through to the zero-match
# warning below. Runs before the hyperedge carry so hyperedge pruning
# benefits too.
if prune_set or prune_abs:
_stored_sfs = {
item.get("source_file")
for seq in (_disk_nodes, existing_edges, existing_hyperedges)
for item in seq if isinstance(item, dict)
}
_stored_sfs.discard(None)
if not any(_prune_match(sf) for sf in _stored_sfs):
_derived = _derive_prune_root(prune_sources or [], _stored_sfs)
if _derived is not None and _derived != _prune_root:
_prune_root = _derived
prune_set, prune_abs = _build_prune_sets(
prune_sources, _prune_root, new_sources
)
# Carry forward hyperedges from files that were neither re-extracted nor
# deleted (#1574). build() only sees the new chunks' hyperedges, so without
@@ -1619,14 +1748,7 @@ def build_merge(
if _prune_match(d.get("source_file"))
]
G.remove_nodes_from(to_remove)
n_files = len(prune_sources)
n_nodes = len(to_remove)
if n_nodes:
print(
f"[graphify] Pruned {n_nodes} node(s) from {n_files} deleted or "
f"excluded source file(s).",
file=sys.stderr,
)
edges_to_remove = [
(u, v) for u, v, d in G.edges(data=True)
@@ -1634,6 +1756,18 @@ def build_merge(
]
if edges_to_remove:
G.remove_edges_from(edges_to_remove)
# Report only the prune entries that ACTUALLY matched something — not
# len(prune_sources), which counted every entry as pruned-from even
# when a root mismatch made most of them no-ops (#2446).
n_files = len(_matched_prune_entries)
if n_nodes:
print(
f"[graphify] Pruned {n_nodes} node(s) from {n_files} deleted or "
f"excluded source file(s).",
file=sys.stderr,
)
if edges_to_remove:
print(
f"[graphify] Pruned {len(edges_to_remove)} edge(s) from deleted or "
f"excluded source file(s).",
@@ -1641,21 +1775,91 @@ def build_merge(
)
if not n_nodes and not edges_to_remove:
print(
f"[graphify] {n_files} source file(s) deleted or excluded since "
f"last run — no matching nodes or edges in graph, already clean.",
file=sys.stderr,
if (prune_set or prune_abs) and not _matched_prune_entries:
# Live prune entries that matched NOTHING usually mean the
# effective root is wrong (and the derived-root fallback above
# found no consistent candidate) — warn instead of claiming the
# graph is "already clean" (#2446).
_p0 = next((p for p in prune_sources if p), None)
sample_prune = _norm_source_file(_p0, _prune_root) or _p0
sample_sf = next(
iter(sorted(
str(d.get("source_file"))
for _, d in G.nodes(data=True) if d.get("source_file")
)),
None,
)
print(
f"[graphify] WARNING: {len(prune_sources)} prune source(s) "
f"matched no nodes or edges — nothing was removed. Prune "
f"entry {sample_prune!r} does not correspond to any stored "
f"source_file (e.g. {sample_sf!r}). If these files should "
f"have been pruned, pass root= to build_merge so absolute "
f"paths relativize to the graph's source_file keys. (#2446)",
file=sys.stderr,
)
else:
print(
f"[graphify] {len(prune_sources)} source file(s) deleted or "
f"excluded since last run — no matching nodes or edges in "
f"graph, already clean.",
file=sys.stderr,
)
# Safety check: refuse to SILENTLY drop nodes (#479, reworked in #2497).
# The old count comparison ran against the post-replace `existing_nodes`,
# which had already lost the re-extracted sources' old nodes — so it could
# never fire when it mattered, and was skipped outright whenever
# prune_sources was passed. Mirror watch._check_shrink instead: diff the
# on-disk baseline by node identity and excuse only losses explained by
# this run's own re-extraction (same tier) or an explicit prune. Skipped
# under dedup, where fuzzy merging collapses ids legitimately.
#
# Residual tradeoff (accepted): a partial re-extraction that under-produces
# for ITS OWN file (>= 1 node still present in new_chunks) is excused here —
# that failure mode is owned by the extraction layer's incomplete-build
# guard (#1951), and refusing it here would reintroduce the #1116
# false-refuse for legitimate edits that remove symbols from a file.
if had_graph and not dedup:
def _in_new_graph(n: dict) -> bool:
nid = n.get("id")
if nid is None or not _hashable(nid):
return True # untrackable identity — never count as lost
if nid in G:
return True
# Doc-twin heal (#1799): build_from_json merges a markdown
# quick-scan's bare doc node into its semantic `<id>_doc` twin for
# the same file — a legitimate collapse, not a loss.
return (
isinstance(nid, str)
and not nid.endswith("_doc")
and n.get("file_type") == "document"
and f"{nid}_doc" in G
and G.nodes[f"{nid}_doc"].get("source_file") == n.get("source_file")
)
# Safety check: refuse to shrink the graph silently (#479)
# Skip when dedup or prune_sources is active — shrinkage is intentional there.
if graph_path.exists() and not dedup and not prune_sources:
existing_n = len(existing_nodes)
new_n = G.number_of_nodes()
if new_n < existing_n:
lost = [
n for n in _disk_nodes
if isinstance(n, dict) and not _in_new_graph(n)
]
def _explained(n: dict) -> bool:
sf = n.get("source_file")
if not sf:
return True
own = new_ast_sources if _is_ast_tier(n) else new_sem_sources
if sf in own or _norm_source_file(sf, _replace_root) in own:
return True # replaced by this run's re-extract (same tier)
return _prune_match(sf) # deliberately pruned this run
unexplained = [n for n in lost if not _explained(n)]
if unexplained:
raise ValueError(
f"graphify: build_merge would shrink graph from {existing_n} → {new_n} nodes. "
f"Pass prune_sources explicitly if you intend to remove nodes."
f"graphify: build_merge would drop {len(unexplained)} node(s) "
f"from sources that were neither re-extracted nor pruned this "
f"run (e.g. {unexplained[0].get('id')!r}); graph would go "
f"{_disk_n} → {G.number_of_nodes()} nodes. "
f"Pass prune_sources explicitly if you intend to remove them. (#479)"
)
return G
+237
View File
@@ -0,0 +1,237 @@
"""build_merge safety rails: the #479 shrink guard rework (#2497) and
root-mismatch prune fallbacks (#2446).
- #2497: the original shrink guard compared the new node count against
`existing_nodes` AFTER the replace-on-re-extract rebind had already removed
the re-extracted sources' old nodes — so it could never fire when it
mattered, and was skipped outright whenever prune_sources was passed. A
broken partial re-extract silently destroyed the graph (12->3 observed).
The guard now diffs the ON-DISK baseline by node identity (mirroring
watch._check_shrink) and excuses only losses explained by this run's own
re-extraction (same tier) or an explicit prune.
- #2446: with `root` omitted and a non-standard layout (graph.json not under
<root>/graphify-out/), the inferred root was wrong, absolute prune_sources
matched nothing, and build_merge reported "already clean" while counting
EVERY prune entry as pruned-from. Now: a derived-root fallback
(suffix-matching absolute prune paths against stored relative source_files),
an accurate matched-entry count, and a WARNING instead of "already clean"
on a true zero-match.
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
import graphify.build as buildmod
from graphify.build import build_merge, merge_raw_extraction
def _node(i: int, sf: str) -> dict:
"""Deterministic AST-tier node dict for source file *sf*."""
stem = sf.replace("/", "_").replace(".", "_")
return {
"id": f"{stem}_n{i}",
"label": f"{sf} node {i}",
"file_type": "document",
"source_file": sf,
"source_location": f"L{i + 1}",
"_origin": "ast",
}
def _write_graph(graph_path: Path, nodes, edges=(), hyperedges=()) -> None:
graph_path.parent.mkdir(parents=True, exist_ok=True)
graph_path.write_text(
json.dumps({"nodes": list(nodes), "edges": list(edges),
"hyperedges": list(hyperedges)}),
encoding="utf-8",
)
def _seed_12(tmp_path: Path) -> Path:
"""Standard layout, 12 nodes: 10 from a.md + 2 from b.md."""
gp = tmp_path / "graphify-out" / "graph.json"
_write_graph(
gp,
[_node(i, "a.md") for i in range(10)] + [_node(i, "b.md") for i in range(2)],
)
return gp
# ── #2497: identity-based shrink guard ─────────────────────────────────────────
def test_legit_prune_driven_reduction_allowed(tmp_path):
"""The guard is now ACTIVE with prune_sources, but a prune-explained loss
passes: 12 nodes, prune b.md -> 10, no raise."""
gp = _seed_12(tmp_path)
G = build_merge([], gp, prune_sources=["b.md"], dedup=False)
assert G.number_of_nodes() == 10
assert not any(d.get("source_file") == "b.md" for _, d in G.nodes(data=True))
def test_legit_replacement_reduction_allowed(tmp_path):
"""No #1116 false-refuse: a.md re-extracted with fewer symbols (10 -> 7)
legitimately shrinks 12 -> 9."""
gp = _seed_12(tmp_path)
chunk = {"nodes": [_node(i, "a.md") for i in range(7)], "edges": []}
G = build_merge([chunk], gp, prune_sources=None, dedup=False)
assert G.number_of_nodes() == 9
def test_unexplained_loss_blocked(tmp_path, monkeypatch):
"""A build that drops a node from an UNTOUCHED file (neither re-extracted
nor pruned this run) must raise instead of silently destroying it — the
exact failure the dead #479 guard waved through (#2497)."""
gp = tmp_path / "graphify-out" / "graph.json"
_write_graph(
gp,
[_node(i, "a.md") for i in range(10)]
+ [_node(0, "c.md"), _node(1, "c.md")],
)
chunk_for_a = {"nodes": [_node(i, "a.md") for i in range(10)], "edges": []}
real_build = buildmod.build
def _broken_build(chunks, **kwargs):
G = real_build(chunks, **kwargs)
G.remove_node("c_md_n0") # untouched c.md node silently lost
return G
monkeypatch.setattr(buildmod, "build", _broken_build)
with pytest.raises(ValueError, match="neither re-extracted nor pruned"):
build_merge([chunk_for_a], gp, dedup=False)
def test_grow_and_equal_unaffected(tmp_path):
gp = _seed_12(tmp_path)
# equal: a.md re-emits its 10 identical nodes -> still 12
chunk = {"nodes": [_node(i, "a.md") for i in range(10)], "edges": []}
assert build_merge([chunk], gp, dedup=False).number_of_nodes() == 12
# grow: a.md re-emits 11 nodes -> 13
chunk2 = {"nodes": [_node(i, "a.md") for i in range(11)], "edges": []}
assert build_merge([chunk2], gp, dedup=False).number_of_nodes() == 13
def test_replacement_is_reported_and_own_file_loss_excused(tmp_path, capsys):
"""Visibility (#2497): the replace-on-re-extract rebind is announced on
stderr, and a re-extract that under-produces for its OWN file is excused
(owned by the extraction layer's incomplete-build guard, #1951)."""
gp = _seed_12(tmp_path)
chunk = {"nodes": [_node(0, "a.md")], "edges": []}
G = build_merge([chunk], gp, dedup=False) # must not raise
assert G.number_of_nodes() == 3 # 2 b.md + 1 fresh a.md
assert "Replaced 10 node(s)" in capsys.readouterr().err
def test_dedup_skip_preserved(tmp_path, monkeypatch):
"""dedup=True legitimately merges ids, so the guard stays off there — even
for a loss the dedup=False path would refuse."""
gp = tmp_path / "graphify-out" / "graph.json"
_write_graph(
gp,
[_node(i, "a.md") for i in range(10)]
+ [_node(0, "c.md"), _node(1, "c.md")],
)
chunk_for_a = {"nodes": [_node(i, "a.md") for i in range(10)], "edges": []}
real_build = buildmod.build
def _broken_build(chunks, **kwargs):
G = real_build(chunks, **kwargs)
if "c_md_n0" in G:
G.remove_node("c_md_n0")
return G
monkeypatch.setattr(buildmod, "build", _broken_build)
G = build_merge([chunk_for_a], gp, dedup=True) # no raise
assert "c_md_n0" not in G
def test_no_existing_graph_path_unchanged(tmp_path):
"""Fresh/no-graph merges never trip the guard."""
gp = tmp_path / "graphify-out" / "graph.json" # does not exist
G = build_merge([{"nodes": [_node(0, "a.md")], "edges": []}], gp, dedup=False)
assert G.number_of_nodes() == 1
G2 = build_merge([], tmp_path / "elsewhere" / "graph.json", dedup=False)
assert G2.number_of_nodes() == 0
# ── #2446: absolute prune_sources with a wrong/missing inferred root ──────────
def test_absolute_prune_custom_layout_derives_root(tmp_path):
"""graph.json directly in <root> (no graphify-out dir, no marker): the
grandparent heuristic guesses wrong, so absolute prune entries used to
match nothing and silently no-op. The derived-root fallback must recover
the scan root by suffix-matching and prune correctly."""
root = tmp_path / "proj"
root.mkdir()
gp = root / "graph.json"
_write_graph(gp, [_node(i, "a.md") for i in range(2)] + [_node(0, "b.md")])
G = build_merge([], gp, prune_sources=[str(root / "b.md")], dedup=False)
sfs = {d.get("source_file") for _, d in G.nodes(data=True)}
assert "b.md" not in sfs, "absolute prune must remove the file's nodes (#2446)"
assert G.number_of_nodes() == 2
def test_absolute_prune_standard_layout_marker_still_prunes(tmp_path):
"""Regression guard for #1571/#2012: the standard layout with a
.graphify_root marker keeps working without root=."""
root = tmp_path / "repo"
out = root / "graphify-out"
out.mkdir(parents=True)
(out / ".graphify_root").write_text(str(root), encoding="utf-8")
gp = out / "graph.json"
_write_graph(gp, [_node(0, "keep.md"), _node(0, "gone.md")])
G = build_merge([], gp, prune_sources=[str(root / "gone.md")], dedup=False)
assert {d["source_file"] for _, d in G.nodes(data=True)} == {"keep.md"}
def test_zero_match_prune_warns_instead_of_already_clean(tmp_path, capsys):
"""A prune set that matches nothing must WARN with samples (prune entry +
stored source_file) and suggest root=, not claim the graph is clean."""
root = tmp_path / "repo"
(root / "graphify-out").mkdir(parents=True)
gp = root / "graphify-out" / "graph.json"
_write_graph(gp, [_node(0, "a.md")])
G = build_merge(
[], gp, prune_sources=["/nowhere/else/x.md"], dedup=False
)
assert G.number_of_nodes() == 1
err = capsys.readouterr().err
assert "WARNING" in err
assert "x.md" in err, "must name a sample prune entry"
assert "a.md" in err, "must name a sample stored source_file"
assert "root" in err
assert "already clean" not in err
def test_partial_match_reports_only_matched_entry_count(tmp_path, capsys):
"""With [abs(b.md), abs(nonexistent.md)], the pruned-from file count must
be 1 (the entry that matched), not len(prune_sources) == 2."""
root = tmp_path / "repo"
(root / "graphify-out").mkdir(parents=True)
gp = root / "graphify-out" / "graph.json"
_write_graph(gp, [_node(0, "a.md"), _node(0, "b.md"), _node(1, "b.md")])
G = build_merge(
[], gp,
prune_sources=[str(root / "b.md"), str(root / "nonexistent.md")],
dedup=False,
)
assert G.number_of_nodes() == 1
err = capsys.readouterr().err
assert "Pruned 2 node(s) from 1 deleted or excluded source file(s)" in err
def test_merge_raw_extraction_absolute_prune_custom_layout(tmp_path):
"""merge_raw_extraction (the raw --no-cluster incremental path) shares the
derived-root fallback (#2446 parity)."""
root = tmp_path / "proj"
root.mkdir()
gp = root / "graph.json"
_write_graph(gp, [_node(0, "a.md"), _node(0, "b.md")])
new = {"nodes": [], "edges": [], "hyperedges": []}
merged = merge_raw_extraction(new, gp, prune_sources=[str(root / "b.md")])
sfs = {n.get("source_file") for n in merged["nodes"]}
assert sfs == {"a.md"}, "raw path must prune the absolute entry too (#2446)"