mirror of
https://github.com/safishamsi/graphify.git
synced 2026-10-13 15:40:05 +00:00
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)
210 lines
6.7 KiB
Python
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
|