diff --git a/graphify/build.py b/graphify/build.py index 6931b7b..f738fdf 100644 --- a/graphify/build.py +++ b/graphify/build.py @@ -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. - ``/graphify-out/graph.json`` -> ````). Returns None if neither - resolves, in which case normalization is a no-op (prior behavior). + ``/graphify-out/graph.json`` -> ````). 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 + (``/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 ``/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 `_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 diff --git a/tests/test_build_merge_shrink_guard.py b/tests/test_build_merge_shrink_guard.py new file mode 100644 index 0000000..e99fe57 --- /dev/null +++ b/tests/test_build_merge_shrink_guard.py @@ -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 + /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 (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)"