#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:
co-authored by
Claude Opus 4.8
parent
07b9143d4b
commit
b8d609732c
+274
-70
@@ -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
|
||||
|
||||
@@ -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)"
|
||||
Reference in New Issue
Block a user