Files
graphify/tests/test_dedup_shrink_refuses_force_write.py
T
SrijanSriv e8b5d276a4 fix(extract): keep shrink accounting out of graph.json
The prune counter included a stub created in this run, so it could
excuse a dedup shrink. Count only nodes that were already saved.
Strip both counters inside to_json so other writers cannot persist them.

Co-Authored-By: Grok 4.7 <noreply@x.ai>
(cherry picked from commit 294946d4ce)
2026-10-04 02:09:34 +01:00

210 lines
6.7 KiB
Python

"""A complete `graphify extract` must not force-write a shrink that dedup caused (#3774).
A full build sets force=True, and to_json then skips the #479 node-count check.
Dedup can merge a same-file duplicate and replace graph.json with no message.
A deleted file is a different shrink: that write still happens.
`--allow-dedup-shrink` is the only flag that accepts the dedup shrink.
"""
from __future__ import annotations
import json
import pytest
import graphify.__main__ as mainmod
def _notes(corpus):
readme = str(corpus / "README.md")
return [
{
"id": "readme_decisions_a",
"label": "Decisions",
"file_type": "document",
"source_file": readme,
"source_location": "L1",
},
{
"id": "readme_decisions_b",
"label": "Decisions",
"file_type": "document",
"source_file": readme,
"source_location": "L2",
},
{
"id": "readme_overview",
"label": "Overview",
"file_type": "document",
"source_file": readme,
"source_location": "L3",
},
]
def _write_graph(path, nodes):
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
json.dumps({"nodes": nodes, "links": []}),
encoding="utf-8",
)
def _node_ids(path):
data = json.loads(path.read_text(encoding="utf-8"))
return [n["id"] for n in data["nodes"]]
def _corpus(tmp_path):
(tmp_path / "README.md").write_text("# Notes\nThe entry point overview.\n", encoding="utf-8")
(tmp_path / "GUIDE.md").write_text("# Guide\nHow to use the thing.\n", encoding="utf-8")
return tmp_path
def _arm(monkeypatch, tmp_path, *, nodes, extra_argv=()):
corpus = _corpus(tmp_path)
out_dir = tmp_path / "out"
graph_path = out_dir / "graphify-out" / "graph.json"
_write_graph(graph_path, nodes)
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-test-fake-key")
def _stub_corpus(paths, **kwargs):
on_chunk = kwargs.get("on_chunk_done")
if on_chunk:
on_chunk(0, 1, {"nodes": [], "edges": [], "hyperedges": []})
return {
"nodes": nodes,
"edges": [],
"hyperedges": [],
"input_tokens": 10,
"output_tokens": 5,
}
monkeypatch.setattr("graphify.llm.extract_corpus_parallel", _stub_corpus)
monkeypatch.setattr(mainmod, "_check_skill_version", lambda _: None)
monkeypatch.setattr(
mainmod.sys, "argv",
["graphify", "extract", str(corpus), "--backend", "claude",
"--out", str(out_dir), *extra_argv],
)
return graph_path
def _run():
try:
mainmod.main()
except SystemExit as exc:
return exc.code
return 0
def test_complete_run_refuses_a_same_file_dedup_shrink(monkeypatch, tmp_path, capsys):
"""Two same-file duplicates plus one keeper. Dedup merges the pair.
The file on disk must keep all three nodes."""
graph_path = _arm(monkeypatch, tmp_path, nodes=_notes(tmp_path))
code = _run()
err = capsys.readouterr().err
assert code not in (None, 0)
assert _node_ids(graph_path) == [
"readme_decisions_a",
"readme_decisions_b",
"readme_overview",
]
assert "3" in err and "2" in err
# the message attributes the shrink to the actual dedup contribution
# (1 merged node), not the whole 3->2 delta
assert "merged 1" in err
assert "--allow-dedup-shrink" in err
def test_allow_dedup_shrink_writes_the_smaller_graph(monkeypatch, tmp_path):
graph_path = _arm(
monkeypatch, tmp_path, nodes=_notes(tmp_path),
extra_argv=["--allow-dedup-shrink"],
)
code = _run()
ids = _node_ids(graph_path)
assert code in (None, 0)
assert len(ids) == 2
assert "readme_overview" in ids
assert not {"readme_decisions_a", "readme_decisions_b"} <= set(ids)
raw = graph_path.read_text(encoding="utf-8")
assert "_dedup_collapsed" not in raw
assert "_pruned_node_count" not in raw
def test_a_deleted_file_still_writes(monkeypatch, tmp_path):
"""The shrink is one missing file. Dedup merges nothing. The write proceeds."""
corpus = tmp_path
readme = str(corpus / "README.md")
gone = str(corpus / "gone.md")
nodes = [
{
"id": "readme_overview",
"label": "Overview",
"file_type": "document",
"source_file": readme,
"source_location": "L1",
},
{
"id": "gone_notes",
"label": "Gone notes",
"file_type": "document",
"source_file": gone,
"source_location": "L1",
},
]
fresh = [nodes[0]]
graph_path = _arm(monkeypatch, tmp_path, nodes=fresh)
# Replace the seeded graph with both files. The stub still returns only README.
_write_graph(graph_path, nodes)
code = _run()
ids = _node_ids(graph_path)
assert code in (None, 0)
assert "gone_notes" not in ids
assert "readme_overview" in ids
def test_to_json_does_not_store_shrink_accounting(tmp_path):
"""build() records the counters on the graph object. to_json must not
write them. Callers other than extract follow that path (#3774)."""
from graphify.build import build
from graphify.export import to_json
G = build([{"nodes": _notes(tmp_path), "edges": [], "hyperedges": []}])
assert G.graph.get("_dedup_collapsed") == 1
out = tmp_path / "graph.json"
assert to_json(G, {}, str(out), force=True) is True
raw = out.read_text(encoding="utf-8")
assert "_dedup_collapsed" not in raw
assert "_pruned_node_count" not in raw
def test_pruned_node_count_ignores_a_stub_created_this_run(tmp_path):
"""A source-less stub minted this run and then orphaned by the prune is
not a saved node. It must not excuse a dedup shrink."""
from graphify.build import build_merge
root = tmp_path / "repo"
root.mkdir()
kept = "README.md"
gone = "gone.md"
disk = [
{"id": "kept", "label": "Overview", "file_type": "document", "source_file": kept},
{"id": "gone", "label": "Gone", "file_type": "document", "source_file": gone},
]
graph_path = root / "graphify-out" / "graph.json"
_write_graph(graph_path, disk)
stub_edge = {"source": "gone", "target": "fresh_stub", "source_file": gone}
chunk = {
"nodes": [
{"id": "kept", "label": "Overview", "file_type": "document", "source_file": kept},
{"id": "fresh_stub", "label": "Path", "file_type": "code"},
],
"edges": [stub_edge],
"hyperedges": [],
}
G = build_merge([chunk], graph_path, prune_sources=[gone], dedup=False, root=root)
assert "gone" not in G
assert "fresh_stub" not in G
assert G.graph.get("_pruned_node_count") == 1