mirror of
https://github.com/safishamsi/graphify.git
synced 2026-08-26 16:26:42 +00:00
fix(cache): retry zero-node semantic results (#2927)
A semantic result with no nodes and no hyperedges (only edges, or nothing) was cached and stamped into the manifest, so an empty/degenerate LLM reply for a file froze that file: detect_incremental saw it unchanged and never re-dispatched it. Reject zero-node results from the cache read and write, drop edges from the manifest stamp tuple, and heal an existing manifest by re-queueing files that were already stamped with a zero-node result.
This commit is contained in:
@@ -992,6 +992,18 @@ def load_cached(path: Path, root: Path = Path("."), kind: str = "ast",
|
||||
# across chunks without losing the truncated one (it stays partial).
|
||||
if not allow_partial and isinstance(result, dict) and result.get("partial"):
|
||||
return None
|
||||
# A semantic entry with zero nodes and zero hyperedges is invalid (#2927):
|
||||
# an edge-only or empty result (e.g. LLM omitted entities for the file)
|
||||
# is not a valid standalone extraction. Treating it as a cache MISS
|
||||
# ensures the file is re-dispatched and retried (#933/#1666).
|
||||
if (
|
||||
not allow_partial
|
||||
and kind.startswith("semantic")
|
||||
and isinstance(result, dict)
|
||||
and not result.get("nodes")
|
||||
and not result.get("hyperedges")
|
||||
):
|
||||
return None
|
||||
if (
|
||||
kind.startswith("semantic")
|
||||
and isinstance(result, dict)
|
||||
@@ -1543,6 +1555,11 @@ def save_semantic_cache(
|
||||
)
|
||||
if is_partial:
|
||||
result = {**result, "partial": True}
|
||||
# A semantic extraction with zero nodes and zero hyperedges is not a valid
|
||||
# standalone extraction (#2927): edge-only or empty results must not be
|
||||
# cached, so that subsequent runs can re-dispatch and retry the file (#933/#1666).
|
||||
if not is_partial and not (result.get("nodes") or result.get("hyperedges")):
|
||||
continue
|
||||
save_cached(cache_path, result, root, kind=kind, cache_root=cache_root,
|
||||
prompt=prompt, prompt_file=prompt_file)
|
||||
saved += 1
|
||||
|
||||
+127
-1
@@ -133,7 +133,10 @@ def _stamped_manifest_files(
|
||||
return p
|
||||
|
||||
sem_extracted: set[Path] = set()
|
||||
for coll in ("nodes", "edges", "hyperedges"):
|
||||
# #2927: only nodes and hyperedges count as valid semantic output that stamps
|
||||
# the manifest. An edge-only result has no entity representation in the graph
|
||||
# and must be left unstamped so detect_incremental re-queues it (#933/#1666).
|
||||
for coll in ("nodes", "hyperedges"):
|
||||
for item in sem_result.get(coll, []):
|
||||
sf = item.get("source_file", "")
|
||||
if sf:
|
||||
@@ -421,6 +424,101 @@ def _zero_node_stamped_code_sources(
|
||||
return healed
|
||||
|
||||
|
||||
def _zero_node_stamped_semantic_sources(
|
||||
graph_path: Path,
|
||||
scan_root: Path,
|
||||
unchanged_semantic: list[str],
|
||||
) -> list[str]:
|
||||
"""Manifest-stamped semantic files (doc/paper/image) with ZERO nodes
|
||||
and ZERO hyperedges in the existing graph.json (#2927 heal).
|
||||
|
||||
A manifest poisoned before #2927 (edge-only result cached and stamped)
|
||||
keeps reporting the file unchanged forever, freezing it out of the graph.
|
||||
Re-queue any unchanged semantic file that has neither nodes nor hyperedges
|
||||
in graph.json. If it succeeds, its nodes enter graph.json; if it produces
|
||||
no nodes or fails, it is now left unstamped, so this cannot wedge.
|
||||
|
||||
Membership mirrors the ``source_file`` spellings extracts store (#1897/
|
||||
#1941: scan-root-relative, forward slash; absolute for out-of-root) and
|
||||
compares NFC-normalized (#2210/#2221).
|
||||
"""
|
||||
if not unchanged_semantic:
|
||||
return []
|
||||
from graphify.paths import nfc
|
||||
try:
|
||||
data = json.loads(graph_path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return []
|
||||
if not isinstance(data, dict):
|
||||
return []
|
||||
try:
|
||||
root_res = scan_root.resolve()
|
||||
except (OSError, RuntimeError):
|
||||
root_res = scan_root
|
||||
out_base = graph_path.parent.parent
|
||||
try:
|
||||
out_base = out_base.resolve()
|
||||
except (OSError, RuntimeError):
|
||||
pass
|
||||
|
||||
present: set[str] = set()
|
||||
for n in data.get("nodes", []):
|
||||
if not isinstance(n, dict):
|
||||
continue
|
||||
sf = n.get("source_file")
|
||||
if not sf or not isinstance(sf, str):
|
||||
continue
|
||||
present.add(nfc(sf))
|
||||
p = Path(sf)
|
||||
if p.is_absolute():
|
||||
try:
|
||||
present.add(nfc(str(p.resolve())))
|
||||
except (OSError, RuntimeError):
|
||||
pass
|
||||
else:
|
||||
rel = sf.replace("\\", "/")
|
||||
for base in (root_res, out_base):
|
||||
present.add(nfc(os.path.normpath(str(base / rel))))
|
||||
|
||||
hyper_items = list(data.get("hyperedges", []) or [])
|
||||
if isinstance((data.get("graph") or {}).get("hyperedges"), list):
|
||||
hyper_items.extend(data["graph"]["hyperedges"])
|
||||
for h in hyper_items:
|
||||
if not isinstance(h, dict):
|
||||
continue
|
||||
sf = h.get("source_file")
|
||||
if not sf or not isinstance(sf, str):
|
||||
continue
|
||||
present.add(nfc(sf))
|
||||
p = Path(sf)
|
||||
if p.is_absolute():
|
||||
try:
|
||||
present.add(nfc(str(p.resolve())))
|
||||
except (OSError, RuntimeError):
|
||||
pass
|
||||
else:
|
||||
rel = sf.replace("\\", "/")
|
||||
for base in (root_res, out_base):
|
||||
present.add(nfc(os.path.normpath(str(base / rel))))
|
||||
|
||||
healed: list[str] = []
|
||||
for f in unchanged_semantic:
|
||||
p = Path(f)
|
||||
spellings = {nfc(str(p))}
|
||||
try:
|
||||
spellings.add(nfc(str(p.resolve())))
|
||||
except (OSError, RuntimeError):
|
||||
pass
|
||||
try:
|
||||
spellings.add(nfc(p.resolve().relative_to(root_res).as_posix()))
|
||||
except (ValueError, OSError, RuntimeError):
|
||||
pass
|
||||
if spellings & present:
|
||||
continue # the graph has nodes or hyperedges for this file: stamp is honest
|
||||
healed.append(f)
|
||||
return healed
|
||||
|
||||
|
||||
def _prune_graph_json_sources(graph_path: Path, stale_sources: list[str]) -> int:
|
||||
"""Drop nodes/edges/hyperedges owned by ``stale_sources`` from graph.json
|
||||
in place. Returns the number of nodes removed.
|
||||
@@ -3209,6 +3307,34 @@ def dispatch_command(cmd: str) -> None:
|
||||
f"(prior failed extraction, #2543)"
|
||||
)
|
||||
code_files.extend(Path(p) for p in _healed_sources)
|
||||
# #2927 heal: manifests poisoned BEFORE zero-node semantic cache rejection
|
||||
# existed carry live hashes for semantic files (doc/paper/image) whose
|
||||
# extraction produced zero nodes and zero hyperedges (e.g. edge-only).
|
||||
# Re-queue any such file so it is re-dispatched and self-heals.
|
||||
_unchanged_sem: list[str] = []
|
||||
for _k in ("document", "paper", "image"):
|
||||
_unchanged_sem.extend(detection.get("unchanged_files", {}).get(_k, []))
|
||||
_healed_sem_sources = _zero_node_stamped_semantic_sources(
|
||||
existing_graph_path,
|
||||
target,
|
||||
_unchanged_sem,
|
||||
)
|
||||
if _healed_sem_sources:
|
||||
print(
|
||||
f"[graphify extract] re-queuing {len(_healed_sem_sources)} "
|
||||
f"manifest-stamped semantic file(s) with no nodes or hyperedges in graph.json "
|
||||
f"(prior empty/edge-only extraction, #2927)"
|
||||
)
|
||||
_healed_sem_set = set(_healed_sem_sources)
|
||||
for _p in detection.get("unchanged_files", {}).get("document", []):
|
||||
if _p in _healed_sem_set:
|
||||
doc_files.append(Path(_p))
|
||||
for _p in detection.get("unchanged_files", {}).get("paper", []):
|
||||
if _p in _healed_sem_set:
|
||||
paper_files.append(Path(_p))
|
||||
for _p in detection.get("unchanged_files", {}).get("image", []):
|
||||
if _p in _healed_sem_set:
|
||||
image_files.append(Path(_p))
|
||||
else:
|
||||
print(f"[graphify extract] scanning {target}")
|
||||
detection = _detect(
|
||||
|
||||
@@ -1749,3 +1749,131 @@ def test_corrupt_semantic_entry_warns_and_is_a_miss(tmp_path):
|
||||
# The corrupt entry is a miss, so the file is re-dispatched for extraction.
|
||||
assert nodes == []
|
||||
assert uncached == [str(f)]
|
||||
|
||||
|
||||
# --- #2927: zero-node semantic cache rejection and healing -------------------
|
||||
|
||||
def test_edge_only_semantic_result_not_cached(tmp_path):
|
||||
"""#2927: an edge-only semantic result (0 nodes, 0 hyperedges) represents an
|
||||
omission by the model and must NOT be written to cache, so subsequent runs
|
||||
can re-dispatch and retry the file (#933/#1666)."""
|
||||
from graphify.cache import check_semantic_cache, load_cached, save_semantic_cache
|
||||
|
||||
f = tmp_path / "doc.md"
|
||||
f.write_text("# Architecture\nSome prose.\n", encoding="utf-8")
|
||||
edges = [{"source": "auth_a", "target": "auth_b", "source_file": "doc.md"}]
|
||||
|
||||
saved = save_semantic_cache([], edges, root=tmp_path, prompt="PROMPT V1")
|
||||
assert saved == 0, "edge-only result must not be saved to cache"
|
||||
|
||||
# load_cached must return None (miss)
|
||||
assert load_cached(f, root=tmp_path, kind="semantic", prompt="PROMPT V1") is None
|
||||
# check_semantic_cache must treat it as uncached
|
||||
nodes, edges_out, hyper_out, uncached = check_semantic_cache([str(f)], root=tmp_path, prompt="PROMPT V1")
|
||||
assert nodes == [] and edges_out == [] and hyper_out == []
|
||||
assert uncached == [str(f)]
|
||||
|
||||
|
||||
def test_node_only_and_node_edge_semantic_results_cached(tmp_path):
|
||||
"""Normal extractions (nodes-only and nodes+edges) continue to cache normally."""
|
||||
from graphify.cache import load_cached, save_semantic_cache
|
||||
|
||||
f1 = tmp_path / "doc1.md"
|
||||
f1.write_text("# Doc 1\n", encoding="utf-8")
|
||||
f2 = tmp_path / "doc2.md"
|
||||
f2.write_text("# Doc 2\n", encoding="utf-8")
|
||||
|
||||
# Node-only
|
||||
saved1 = save_semantic_cache([{"id": "n1", "source_file": "doc1.md"}], [], root=tmp_path, prompt="P")
|
||||
assert saved1 == 1
|
||||
loaded1 = load_cached(f1, root=tmp_path, kind="semantic", prompt="P")
|
||||
assert loaded1 is not None and len(loaded1["nodes"]) == 1
|
||||
|
||||
# Node + edge
|
||||
saved2 = save_semantic_cache(
|
||||
[{"id": "n2", "source_file": "doc2.md"}],
|
||||
[{"source": "n2", "target": "n2", "source_file": "doc2.md"}],
|
||||
root=tmp_path,
|
||||
prompt="P",
|
||||
)
|
||||
assert saved2 == 1
|
||||
loaded2 = load_cached(f2, root=tmp_path, kind="semantic", prompt="P")
|
||||
assert loaded2 is not None and len(loaded2["nodes"]) == 1 and len(loaded2["edges"]) == 1
|
||||
|
||||
|
||||
def test_hyperedge_only_semantic_result_cached(tmp_path):
|
||||
"""#1920: hyperedge-only documents are valid semantic output and must be cached."""
|
||||
from graphify.cache import check_semantic_cache, load_cached, save_semantic_cache
|
||||
|
||||
f = tmp_path / "hyper.md"
|
||||
f.write_text("# Pipeline Concept\n", encoding="utf-8")
|
||||
hyperedges = [
|
||||
{"id": "h1", "label": "Pipeline", "nodes": ["a", "b", "c"], "source_file": "hyper.md"}
|
||||
]
|
||||
|
||||
saved = save_semantic_cache([], [], hyperedges, root=tmp_path, prompt="PROMPT V1")
|
||||
assert saved == 1, "hyperedge-only result must be saved to cache (#1920)"
|
||||
|
||||
loaded = load_cached(f, root=tmp_path, kind="semantic", prompt="PROMPT V1")
|
||||
assert loaded is not None
|
||||
assert len(loaded["hyperedges"]) == 1
|
||||
|
||||
_, _, cached_hyper, uncached = check_semantic_cache([str(f)], root=tmp_path, prompt="PROMPT V1")
|
||||
assert uncached == []
|
||||
assert len(cached_hyper) == 1
|
||||
|
||||
|
||||
def test_poisoned_edge_only_cache_entry_treated_as_miss(tmp_path):
|
||||
"""#2927 healing: a legacy on-disk cache entry containing edges but no nodes
|
||||
or hyperedges must be rejected by load_cached as a cache MISS."""
|
||||
import json
|
||||
from graphify.cache import cache_dir, file_hash, load_cached, prompt_fingerprint
|
||||
|
||||
f = tmp_path / "poisoned.md"
|
||||
f.write_text("# Poisoned\n", encoding="utf-8")
|
||||
|
||||
# Manually seed a legacy poisoned cache file (nodes: [], edges: [...])
|
||||
prompt = "PROMPT V1"
|
||||
fp = prompt_fingerprint(prompt)
|
||||
cdir = cache_dir(tmp_path, "semantic", fp)
|
||||
cdir.mkdir(parents=True, exist_ok=True)
|
||||
h = file_hash(f, tmp_path)
|
||||
(cdir / f"{h}.json").write_text(
|
||||
json.dumps({
|
||||
"nodes": [],
|
||||
"edges": [{"source": "x", "target": "y", "source_file": "poisoned.md"}],
|
||||
"hyperedges": [],
|
||||
}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
# load_cached must reject the poisoned entry
|
||||
assert load_cached(f, root=tmp_path, kind="semantic", prompt=prompt) is None
|
||||
|
||||
|
||||
def test_existing_hyperedge_only_cache_entry_remains_hit(tmp_path):
|
||||
"""#1920 / #2927: an existing on-disk cache entry with hyperedges but no nodes
|
||||
remains a valid cache hit."""
|
||||
import json
|
||||
from graphify.cache import cache_dir, file_hash, load_cached, prompt_fingerprint
|
||||
|
||||
f = tmp_path / "valid_hyper.md"
|
||||
f.write_text("# Hyper\n", encoding="utf-8")
|
||||
|
||||
prompt = "PROMPT V1"
|
||||
fp = prompt_fingerprint(prompt)
|
||||
cdir = cache_dir(tmp_path, "semantic", fp)
|
||||
cdir.mkdir(parents=True, exist_ok=True)
|
||||
h = file_hash(f, tmp_path)
|
||||
(cdir / f"{h}.json").write_text(
|
||||
json.dumps({
|
||||
"nodes": [],
|
||||
"edges": [],
|
||||
"hyperedges": [{"id": "h1", "label": "Group", "nodes": ["a", "b", "c"], "source_file": "valid_hyper.md"}],
|
||||
}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
loaded = load_cached(f, root=tmp_path, kind="semantic", prompt=prompt)
|
||||
assert loaded is not None
|
||||
assert len(loaded["hyperedges"]) == 1
|
||||
|
||||
+188
-5
@@ -416,28 +416,35 @@ def test_manifest_stamps_freshly_extracted_semantic_docs(monkeypatch, tmp_path):
|
||||
def test_stamped_manifest_files_normalizes_both_sides(tmp_path):
|
||||
"""Unit test for the #1897 helper: relative (fresh) and absolute (cache-hit)
|
||||
source_file values must both match detect()'s absolute file lists; docs with
|
||||
no output are filtered; code files pass through untouched."""
|
||||
no valid output (or edge-only output, #2927) are filtered; code files pass through untouched."""
|
||||
from graphify.cli import _stamped_manifest_files
|
||||
|
||||
fresh_doc = tmp_path / "fresh.md"; fresh_doc.write_text("# fresh")
|
||||
cached_doc = tmp_path / "cached.md"; cached_doc.write_text("# cached")
|
||||
edge_only_doc = tmp_path / "edge_only.md"; edge_only_doc.write_text("# edge only")
|
||||
omitted_doc = tmp_path / "omitted.md"; omitted_doc.write_text("# omitted")
|
||||
code = tmp_path / "app.py"; code.write_text("x = 1")
|
||||
|
||||
files_by_type = {
|
||||
"code": [str(code)],
|
||||
"document": [str(fresh_doc), str(cached_doc), str(omitted_doc)],
|
||||
"document": [str(fresh_doc), str(cached_doc), str(edge_only_doc), str(omitted_doc)],
|
||||
}
|
||||
sem_result = {
|
||||
# fresh extraction: root-relative source_file
|
||||
"nodes": [{"id": "n1", "source_file": "fresh.md"}],
|
||||
# cache replay: absolute source_file (edge-only coverage counts too)
|
||||
"edges": [{"source": "a", "target": "b", "source_file": str(cached_doc)}],
|
||||
"nodes": [
|
||||
{"id": "n1", "source_file": "fresh.md"},
|
||||
# cache replay: absolute source_file
|
||||
{"id": "n2", "source_file": str(cached_doc)},
|
||||
],
|
||||
# edge-only: must NOT count toward stamping (#2927)
|
||||
"edges": [{"source": "a", "target": "b", "source_file": str(edge_only_doc)}],
|
||||
}
|
||||
|
||||
out = _stamped_manifest_files(files_by_type, sem_result, tmp_path)
|
||||
assert out["code"] == [str(code)]
|
||||
assert out["document"] == [str(fresh_doc), str(cached_doc)]
|
||||
assert str(edge_only_doc) not in out["document"]
|
||||
assert str(omitted_doc) not in out["document"]
|
||||
|
||||
|
||||
def test_stamped_manifest_files_counts_hyperedge_only_docs(tmp_path):
|
||||
@@ -1369,3 +1376,179 @@ def test_cache_check_prompt_file_scopes_hits_to_that_prompt(monkeypatch, tmp_pat
|
||||
os.utime(spec, ns=(0, 0))
|
||||
_run_extract(monkeypatch, base + ["--prompt-file", str(spec)])
|
||||
assert "Cache: 0 hit, 1 miss" in capsys.readouterr().out
|
||||
|
||||
|
||||
# --- #2927: zero-node / edge-only semantic retry and manifest healing --------
|
||||
|
||||
def test_edge_only_semantic_extraction_not_stamped_and_retried(monkeypatch, tmp_path):
|
||||
"""#2927 end-to-end: a semantic extraction where a dispatched doc produces
|
||||
edges but zero nodes must NOT be cached or stamped into manifest.json, so
|
||||
subsequent incremental runs re-dispatch and retry the file."""
|
||||
import json
|
||||
from graphify.cache import check_semantic_cache
|
||||
|
||||
corpus = _make_corpus(tmp_path) # main.go + README.md
|
||||
arch = corpus / "ARCH.md"
|
||||
arch.write_text("# Architecture\nDetailed arch notes.\n", encoding="utf-8")
|
||||
out_dir = tmp_path / "out"
|
||||
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-test-fake-key")
|
||||
monkeypatch.setattr(mainmod, "_check_skill_version", lambda _: None)
|
||||
|
||||
call_count = {"count": 0}
|
||||
|
||||
def _mock_llm(paths, **kwargs):
|
||||
call_count["count"] += 1
|
||||
on_chunk = kwargs.get("on_chunk_done")
|
||||
if on_chunk:
|
||||
on_chunk(0, 1, {"nodes": [], "edges": [], "hyperedges": []})
|
||||
|
||||
path_strs = [p.name for p in paths]
|
||||
nodes = []
|
||||
edges = []
|
||||
if "README.md" in path_strs:
|
||||
nodes.append({
|
||||
"id": "readme_concept",
|
||||
"label": "README Concept",
|
||||
"source_file": "README.md",
|
||||
"file_type": "document",
|
||||
})
|
||||
if "ARCH.md" in path_strs:
|
||||
if call_count["count"] == 1:
|
||||
# Run 1: Model responds with edge only (omits nodes for ARCH.md)
|
||||
edges.append({
|
||||
"source": "readme_concept",
|
||||
"target": "readme_concept",
|
||||
"relation": "relates_to",
|
||||
"source_file": "ARCH.md",
|
||||
})
|
||||
else:
|
||||
# Run 2: Retry succeeds with actual nodes for ARCH.md
|
||||
nodes.append({
|
||||
"id": "arch_concept",
|
||||
"label": "Arch Concept",
|
||||
"source_file": "ARCH.md",
|
||||
"file_type": "document",
|
||||
})
|
||||
return {
|
||||
"nodes": nodes,
|
||||
"edges": edges,
|
||||
"hyperedges": [],
|
||||
"input_tokens": 10,
|
||||
"output_tokens": 5,
|
||||
}
|
||||
|
||||
monkeypatch.setattr("graphify.llm.extract_corpus_parallel", _mock_llm)
|
||||
argv = ["graphify", "extract", str(corpus), "--backend", "claude",
|
||||
"--no-cluster", "--out", str(out_dir)]
|
||||
|
||||
# --- Run 1: ARCH.md produces only edges ---
|
||||
_run_extract(monkeypatch, argv)
|
||||
|
||||
manifest_path = out_dir / "graphify-out" / "manifest.json"
|
||||
assert manifest_path.exists()
|
||||
m1 = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
|
||||
# README.md has nodes -> stamped
|
||||
assert m1.get("README.md", {}).get("semantic_hash") != ""
|
||||
# ARCH.md has only edges -> NOT stamped in manifest (#2927)
|
||||
assert not m1.get("ARCH.md", {}).get("semantic_hash"), (
|
||||
f"ARCH.md had zero nodes and must stay unstamped: {m1}"
|
||||
)
|
||||
|
||||
# Cache check: README.md is cached, ARCH.md is NOT cached
|
||||
from graphify.cache import file_hash
|
||||
cache_sem_dir = out_dir / "graphify-out" / "cache" / "semantic"
|
||||
arch_h = file_hash(arch, corpus)
|
||||
readme_h = file_hash(corpus / "README.md", corpus)
|
||||
assert not list(cache_sem_dir.rglob(f"{arch_h}.json")), "ARCH.md must not have a cache file"
|
||||
assert list(cache_sem_dir.rglob(f"{readme_h}.json")), "README.md must have a cache file"
|
||||
|
||||
# --- Run 2: Incremental run without modifying files on disk ---
|
||||
# Because ARCH.md was left unstamped and uncached, it must be re-dispatched!
|
||||
_run_extract(monkeypatch, argv)
|
||||
assert call_count["count"] == 2, "ARCH.md must have triggered a second LLM call"
|
||||
|
||||
m2 = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
assert m2.get("ARCH.md", {}).get("semantic_hash") != "", (
|
||||
"ARCH.md must now be stamped after successful node extraction"
|
||||
)
|
||||
|
||||
graph_path = out_dir / "graphify-out" / "graph.json"
|
||||
g2 = json.loads(graph_path.read_text(encoding="utf-8"))
|
||||
node_ids = {n["id"] for n in g2.get("nodes", [])}
|
||||
assert "arch_concept" in node_ids, "ARCH.md nodes must be present in graph.json"
|
||||
|
||||
|
||||
def test_stale_poisoned_manifest_semantic_source_healed(monkeypatch, tmp_path, capsys):
|
||||
"""#2927 healing: a manifest poisoned BEFORE #2927 (carrying a live semantic_hash
|
||||
for a semantic file with zero nodes/hyperedges in graph.json) is healed by
|
||||
re-queuing the file on an incremental extract."""
|
||||
import json
|
||||
from graphify.detect import save_manifest
|
||||
|
||||
project = tmp_path / "proj"
|
||||
project.mkdir()
|
||||
doc = project / "guide.md"
|
||||
doc.write_text("# Guide\nSome instructions.\n", encoding="utf-8")
|
||||
app = project / "app.py"
|
||||
app.write_text("def run(): pass\n", encoding="utf-8")
|
||||
|
||||
out_dir = tmp_path / "out"
|
||||
graphify_out = out_dir / "graphify-out"
|
||||
graphify_out.mkdir(parents=True, exist_ok=True)
|
||||
graph_path = graphify_out / "graph.json"
|
||||
manifest_path = graphify_out / "manifest.json"
|
||||
|
||||
# 1) Seed a graph where guide.md has 0 nodes and 0 hyperedges
|
||||
graph_path.write_text(
|
||||
json.dumps({
|
||||
"nodes": [{"id": "app_run", "label": "run", "source_file": "app.py", "file_type": "code"}],
|
||||
"edges": [],
|
||||
"hyperedges": [],
|
||||
}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
# 2) Seed a poisoned manifest where guide.md has a valid semantic_hash
|
||||
save_manifest(
|
||||
{"code": [str(app)], "document": [str(doc)]},
|
||||
manifest_path=str(manifest_path),
|
||||
kind="both",
|
||||
root=project,
|
||||
)
|
||||
m = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
assert m.get("guide.md", {}).get("semantic_hash") != ""
|
||||
|
||||
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-test-fake-key")
|
||||
monkeypatch.setattr(mainmod, "_check_skill_version", lambda _: None)
|
||||
|
||||
dispatched_files = []
|
||||
|
||||
def _mock_llm(paths, **kwargs):
|
||||
dispatched_files.extend(p.name for p in paths)
|
||||
on_chunk = kwargs.get("on_chunk_done")
|
||||
if on_chunk:
|
||||
on_chunk(0, 1, {"nodes": [], "edges": [], "hyperedges": []})
|
||||
return {
|
||||
"nodes": [{"id": "guide_doc", "label": "Guide Doc", "source_file": "guide.md", "file_type": "document"}],
|
||||
"edges": [],
|
||||
"hyperedges": [],
|
||||
"input_tokens": 10,
|
||||
"output_tokens": 5,
|
||||
}
|
||||
|
||||
monkeypatch.setattr("graphify.llm.extract_corpus_parallel", _mock_llm)
|
||||
argv = ["graphify", "extract", str(project), "--backend", "claude",
|
||||
"--no-cluster", "--out", str(out_dir)]
|
||||
|
||||
_run_extract(monkeypatch, argv)
|
||||
out_text = capsys.readouterr().out
|
||||
assert "re-queuing 1 manifest-stamped semantic file" in out_text, (
|
||||
f"poisoned semantic file must be healed via re-queue: {out_text}"
|
||||
)
|
||||
assert "guide.md" in dispatched_files, "guide.md must have been dispatched"
|
||||
|
||||
# Verify guide.md is now in graph.json
|
||||
g = json.loads(graph_path.read_text(encoding="utf-8"))
|
||||
node_ids = {n["id"] for n in g.get("nodes", [])}
|
||||
assert "guide_doc" in node_ids, "guide.md node must be present in graph.json after healing"
|
||||
|
||||
Reference in New Issue
Block a user