mirror of
https://github.com/safishamsi/graphify.git
synced 2026-07-17 04:47:23 +00:00
b7ddee3c28
`graphify extract --mode deep` over a warm tree was a silent no-op, for
three stacked reasons:
1. The semantic cache ignored mode: deep runs were served standard-mode
entries (and vice versa). check_semantic_cache/save_semantic_cache now
take `mode` (default None, byte-identical when omitted so older
installed callers keep working) and map it to a namespaced kind —
cache/semantic/ for None, cache/semantic-{mode}/ otherwise. The
per-chunk checkpoint in llm.extract_corpus_parallel and the extract /
cache-check consumers thread the run's mode through (cache-check grows
--mode/--deep). cached_files, clear_cache, and prune_semantic_cache
sweep BOTH namespaces; prune uses the same live-hash set for both
(liveness is content-based, mode-independent) so semantic-deep/ can't
regrow the #1527 unbounded-orphan problem and inherits the
files_by_type-derived exclusion gating for free.
2. extract had no --force — the flag was silently swallowed by the
parser's unknown-arg fallthrough. It is now real (plus GRAPHIFY_FORCE
env parity with `update`): force disables the incremental gate so
detection is a full scan and skips the semantic cache READ so every
semantic file re-dispatches, while the post-run save and manifest
stamping still happen.
3. The incremental gate dispatched zero files on a warm unchanged tree
before the cache was ever consulted, so namespacing alone couldn't fix
the repro. In deep+incremental runs the semantic pass now widens to the
full live doc/paper/image set from detect_incremental's files_by_type
(already exclusion-filtered, #1908/#1909) and lets the mode-namespaced
cache decide hits/misses, with a loud count line so the first deep
run's full re-dispatch is visible.
Skill-side threading of mode is deliberately deferred to PR-2; mode
defaults keep generated skills byte-compatible.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
906 lines
36 KiB
Python
906 lines
36 KiB
Python
"""Tests for graphify/cache.py."""
|
|
import pytest
|
|
from pathlib import Path
|
|
from graphify.cache import file_hash, cache_dir, load_cached, save_cached, cached_files, clear_cache, _body_content
|
|
|
|
|
|
@pytest.fixture
|
|
def tmp_file(tmp_path):
|
|
f = tmp_path / "sample.txt"
|
|
f.write_text("hello world")
|
|
return f
|
|
|
|
|
|
@pytest.fixture
|
|
def cache_root(tmp_path):
|
|
return tmp_path
|
|
|
|
|
|
def test_file_hash_consistent(tmp_file):
|
|
"""Same file gives same hash on repeated calls."""
|
|
h1 = file_hash(tmp_file)
|
|
h2 = file_hash(tmp_file)
|
|
assert h1 == h2
|
|
assert isinstance(h1, str)
|
|
assert len(h1) == 64 # SHA256 hex digest length
|
|
|
|
|
|
def test_file_hash_changes(tmp_path):
|
|
"""Different file contents give different hashes."""
|
|
f1 = tmp_path / "a.txt"
|
|
f2 = tmp_path / "b.txt"
|
|
f1.write_text("content one")
|
|
f2.write_text("content two")
|
|
assert file_hash(f1) != file_hash(f2)
|
|
|
|
|
|
def test_cache_roundtrip(tmp_file, cache_root):
|
|
"""Save then load returns the same result dict."""
|
|
result = {"nodes": [{"id": "n1", "label": "Node1"}], "edges": []}
|
|
save_cached(tmp_file, result, root=cache_root)
|
|
loaded = load_cached(tmp_file, root=cache_root)
|
|
assert loaded == result
|
|
|
|
|
|
def test_cache_miss_on_change(tmp_file, cache_root):
|
|
"""After file content changes, load_cached returns None."""
|
|
result = {"nodes": [], "edges": [{"source": "a", "target": "b"}]}
|
|
save_cached(tmp_file, result, root=cache_root)
|
|
# Modify the file
|
|
tmp_file.write_text("completely different content")
|
|
assert load_cached(tmp_file, root=cache_root) is None
|
|
|
|
|
|
def test_cached_files(tmp_path, cache_root):
|
|
"""cached_files returns the set of cached hashes."""
|
|
f1 = tmp_path / "file1.py"
|
|
f2 = tmp_path / "file2.py"
|
|
f1.write_text("alpha")
|
|
f2.write_text("beta")
|
|
|
|
save_cached(f1, {"nodes": [], "edges": []}, root=cache_root)
|
|
save_cached(f2, {"nodes": [], "edges": []}, root=cache_root)
|
|
|
|
hashes = cached_files(cache_root)
|
|
assert file_hash(f1, cache_root) in hashes
|
|
assert file_hash(f2, cache_root) in hashes
|
|
|
|
|
|
def test_clear_cache(tmp_file, cache_root):
|
|
"""clear_cache removes all .json files from graphify-out/cache/ (all subdirs)."""
|
|
save_cached(tmp_file, {"nodes": [], "edges": []}, root=cache_root)
|
|
# Since v0.5.3 entries go into cache/ast/, not the flat cache/ dir
|
|
cache_base = cache_root / "graphify-out" / "cache"
|
|
assert len(list(cache_base.rglob("*.json"))) > 0
|
|
clear_cache(cache_root)
|
|
assert len(list(cache_base.rglob("*.json"))) == 0
|
|
|
|
|
|
def test_md_frontmatter_only_change_same_hash(tmp_path):
|
|
"""Changing only frontmatter fields in a .md file does not change the hash."""
|
|
f = tmp_path / "doc.md"
|
|
f.write_text("---\nreviewed: 2026-01-01\n---\n\n# Title\n\nBody text.")
|
|
h1 = file_hash(f)
|
|
f.write_text("---\nreviewed: 2026-04-09\n---\n\n# Title\n\nBody text.")
|
|
h2 = file_hash(f)
|
|
assert h1 == h2
|
|
|
|
|
|
def test_md_body_change_different_hash(tmp_path):
|
|
"""Changing the body of a .md file produces a different hash."""
|
|
f = tmp_path / "doc.md"
|
|
f.write_text("---\nreviewed: 2026-01-01\n---\n\n# Title\n\nOriginal body.")
|
|
h1 = file_hash(f)
|
|
f.write_text("---\nreviewed: 2026-01-01\n---\n\n# Title\n\nChanged body.")
|
|
h2 = file_hash(f)
|
|
assert h1 != h2
|
|
|
|
|
|
def test_md_no_frontmatter_hashed_normally(tmp_path):
|
|
"""A .md file with no frontmatter is hashed by its full content."""
|
|
f = tmp_path / "doc.md"
|
|
f.write_text("# Just a heading\n\nNo frontmatter here.")
|
|
h1 = file_hash(f)
|
|
f.write_text("# Just a heading\n\nDifferent content.")
|
|
h2 = file_hash(f)
|
|
assert h1 != h2
|
|
|
|
|
|
def test_non_md_file_hashed_fully(tmp_path):
|
|
"""Non-.md files are still hashed by their full content."""
|
|
f = tmp_path / "script.py"
|
|
f.write_text("# comment\nx = 1")
|
|
h1 = file_hash(f)
|
|
f.write_text("# changed comment\nx = 1")
|
|
h2 = file_hash(f)
|
|
assert h1 != h2
|
|
|
|
|
|
def test_body_content_strips_frontmatter():
|
|
"""_body_content correctly strips YAML frontmatter."""
|
|
content = b"---\ntitle: Test\n---\n\nActual body."
|
|
assert _body_content(content) == b"\n\nActual body."
|
|
|
|
|
|
def test_body_content_no_frontmatter():
|
|
"""_body_content returns content unchanged when no frontmatter present."""
|
|
content = b"No frontmatter here."
|
|
assert _body_content(content) == content
|
|
|
|
|
|
# --- #1259: frontmatter delimiters must be whole `---` lines -----------------
|
|
|
|
def test_body_content_hr_start_is_not_frontmatter():
|
|
"""A document opening with a ``----`` thematic break has no frontmatter;
|
|
a later ``---`` hr must not be mistaken for a close delimiter."""
|
|
content = b"----\nIntro paragraph that must be hashed.\n\n---\nbody"
|
|
assert _body_content(content) == content
|
|
|
|
|
|
def test_body_content_dash_title_start_is_not_frontmatter():
|
|
"""``--- title`` on the first line is prose, not an open delimiter."""
|
|
content = b"--- title\nIntro that must be hashed.\n\n---\nbody"
|
|
assert _body_content(content) == content
|
|
|
|
|
|
def test_body_content_dash_text_line_is_not_close_delimiter():
|
|
"""``--- text`` and ``----`` lines inside opened frontmatter are not the
|
|
close; without a proper close the content passes through unchanged."""
|
|
content = b"---\ntitle: Test\nbody starts here\n--- not a delimiter\n----\nreal content"
|
|
assert _body_content(content) == content
|
|
|
|
|
|
def test_body_content_later_proper_close_skips_dash_text_lines():
|
|
"""A ``--- text`` line is skipped; the next whole ``---`` line closes."""
|
|
content = b"---\ntitle: Test\nnote: --- inline\n---\nreal body"
|
|
assert _body_content(content) == b"\nreal body"
|
|
|
|
|
|
def test_body_content_well_formed_output_byte_identical():
|
|
"""For well-formed frontmatter the stripped body must stay byte-identical
|
|
to the historical substring implementation, so existing semantic-cache
|
|
hashes do not churn (re-extraction is billed LLM work)."""
|
|
cases = [
|
|
# (input, output of the historical text.find("\n---")+4 algorithm)
|
|
(b"---\ntitle: Test\n---\n\nActual body.", b"\n\nActual body."),
|
|
(b"---\nreviewed: 2026-01-01\n---\n\n# Title\n\nBody text.", b"\n\n# Title\n\nBody text."),
|
|
# close delimiter with trailing whitespace keeps it in the body
|
|
(b"---\ntitle: Test\n--- \nbody", b" \nbody"),
|
|
# CRLF line endings
|
|
(b"---\r\ntitle: Test\r\n---\r\nbody", b"\r\nbody"),
|
|
# empty frontmatter block
|
|
(b"---\n---\nbody", b"\nbody"),
|
|
# close as the very last line, no trailing newline
|
|
(b"---\ntitle: Test\n---", b""),
|
|
]
|
|
for content, expected in cases:
|
|
assert _body_content(content) == expected, content
|
|
|
|
|
|
def test_md_edit_above_hr_changes_hash(tmp_path):
|
|
"""Editing content above a mid-document ``----`` break must change the
|
|
hash -- previously that region was silently excluded from hashing."""
|
|
f = tmp_path / "doc.md"
|
|
f.write_text("----\nIntro paragraph.\n\n---\nbody")
|
|
h1 = file_hash(f)
|
|
f.write_text("----\nEdited intro paragraph.\n\n---\nbody")
|
|
h2 = file_hash(f)
|
|
assert h1 != h2
|
|
|
|
|
|
# --- #777: portable cache source_file fields --------------------------------
|
|
# ``save_cached`` relativizes ``source_file`` entries inside the cache file
|
|
# so a committed ``graphify-out/cache/`` is portable across machines and
|
|
# CI runners. ``load_cached`` re-absolutizes them so consumers (extract,
|
|
# merge into graph.json) see the same shape that fresh extraction emits.
|
|
|
|
def test_save_cached_relativizes_source_file(tmp_path):
|
|
"""The on-disk cache JSON contains forward-slash relative source_file
|
|
entries — no absolute prefix from the saving machine leaks in."""
|
|
import json
|
|
from graphify.cache import save_cached, file_hash, cache_dir
|
|
|
|
(tmp_path / "src").mkdir()
|
|
src = tmp_path / "src" / "foo.py"
|
|
src.write_text("def x(): pass\n")
|
|
abs_src = str(src.resolve())
|
|
result = {
|
|
"nodes": [{"id": "n1", "label": "foo", "source_file": abs_src}],
|
|
"edges": [{"source": "n1", "target": "n1", "source_file": abs_src}],
|
|
}
|
|
save_cached(src, result, root=tmp_path, kind="ast")
|
|
|
|
h = file_hash(src, tmp_path)
|
|
entry = cache_dir(tmp_path, "ast") / f"{h}.json"
|
|
on_disk = json.loads(entry.read_text(encoding="utf-8"))
|
|
node_sources = {n["source_file"] for n in on_disk["nodes"]}
|
|
edge_sources = {e["source_file"] for e in on_disk["edges"]}
|
|
assert node_sources == {"src/foo.py"}, (
|
|
f"cache nodes must store relative source_file; got {node_sources}"
|
|
)
|
|
assert edge_sources == {"src/foo.py"}
|
|
|
|
|
|
def test_load_cached_absolutizes_source_file(tmp_path):
|
|
"""``load_cached`` returns the same absolute-path shape that a fresh
|
|
extraction produces, so consumers don't need to special-case cache
|
|
hits vs. fresh extraction."""
|
|
from graphify.cache import save_cached, load_cached
|
|
|
|
(tmp_path / "src").mkdir()
|
|
src = tmp_path / "src" / "foo.py"
|
|
src.write_text("def x(): pass\n")
|
|
abs_src = str(src.resolve())
|
|
save_cached(src, {
|
|
"nodes": [{"id": "n1", "source_file": abs_src}],
|
|
"edges": [{"source": "n1", "target": "n1", "source_file": abs_src}],
|
|
}, root=tmp_path, kind="ast")
|
|
|
|
loaded = load_cached(src, root=tmp_path, kind="ast")
|
|
assert loaded is not None
|
|
assert loaded["nodes"][0]["source_file"] == abs_src
|
|
assert loaded["edges"][0]["source_file"] == abs_src
|
|
|
|
|
|
def test_load_cached_passes_through_legacy_absolute_source_file(tmp_path):
|
|
"""Cache entries written by an older graphify (with absolute source_file
|
|
inside) must still load correctly: the absolutize step is a no-op for
|
|
already-absolute values."""
|
|
import json
|
|
from graphify.cache import load_cached, file_hash, cache_dir
|
|
|
|
(tmp_path / "src").mkdir()
|
|
src = tmp_path / "src" / "foo.py"
|
|
src.write_text("pass\n")
|
|
abs_src = str(src.resolve())
|
|
|
|
# Hand-write a legacy-format cache entry (absolute source_file).
|
|
h = file_hash(src, tmp_path)
|
|
entry = cache_dir(tmp_path, "ast") / f"{h}.json"
|
|
entry.write_text(json.dumps({
|
|
"nodes": [{"id": "n1", "source_file": abs_src}],
|
|
"edges": [],
|
|
}))
|
|
|
|
loaded = load_cached(src, root=tmp_path, kind="ast")
|
|
assert loaded is not None
|
|
assert loaded["nodes"][0]["source_file"] == abs_src
|
|
|
|
|
|
def test_cache_portable_across_roots(tmp_path):
|
|
"""End-to-end portability: a cache entry written at one root can be
|
|
consumed at a different absolute root because the file is content-hashed
|
|
AND its embedded source_file is stored relative."""
|
|
import json
|
|
import shutil
|
|
from graphify.cache import save_cached, load_cached, file_hash, cache_dir
|
|
|
|
repo_a = tmp_path / "repo_a"
|
|
repo_a.mkdir()
|
|
(repo_a / "src").mkdir()
|
|
src_a = repo_a / "src" / "foo.py"
|
|
src_a.write_text("def x(): pass\n")
|
|
save_cached(src_a, {
|
|
"nodes": [{"id": "n1", "source_file": str(src_a.resolve())}],
|
|
"edges": [],
|
|
}, root=repo_a, kind="ast")
|
|
|
|
# Copy corpus + cache to a second location with a different absolute prefix.
|
|
repo_b = tmp_path / "repo_b"
|
|
shutil.copytree(repo_a, repo_b)
|
|
|
|
src_b = repo_b / "src" / "foo.py"
|
|
loaded = load_cached(src_b, root=repo_b, kind="ast")
|
|
assert loaded is not None, (
|
|
"cache must port across absolute prefixes (content hash + relative source_file)"
|
|
)
|
|
# Source path re-anchored to the new root, not the old one.
|
|
assert loaded["nodes"][0]["source_file"] == str(src_b.resolve())
|
|
assert not str(repo_a) in loaded["nodes"][0]["source_file"]
|
|
|
|
|
|
# --- AST cache versioning ----------------------------------------------------
|
|
# AST cache entries are the output of graphify's own extractor code, so they
|
|
# are only valid for the graphify version that wrote them. Keying purely on
|
|
# file content meant extractor fixes shipped in a new release kept serving
|
|
# stale pre-fix results. The AST cache is therefore namespaced by package
|
|
# version; the semantic cache is NOT (invalidating it would re-bill LLM
|
|
# extraction for unchanged files).
|
|
|
|
def test_ast_cache_invalidated_on_version_bump(tmp_path, monkeypatch):
|
|
"""An AST entry written by version X must not be served after upgrading
|
|
to version Y — the file is unchanged but the extractor is not."""
|
|
import graphify.cache as cache_mod
|
|
|
|
f = tmp_path / "mod.py"
|
|
f.write_text("def f(): pass\n")
|
|
|
|
monkeypatch.setattr(cache_mod, "_EXTRACTOR_VERSION", "0.8.0", raising=False)
|
|
save_cached(f, {"nodes": [{"id": "n1"}], "edges": []}, root=tmp_path, kind="ast")
|
|
assert load_cached(f, root=tmp_path, kind="ast") is not None
|
|
|
|
monkeypatch.setattr(cache_mod, "_EXTRACTOR_VERSION", "0.8.1", raising=False)
|
|
assert load_cached(f, root=tmp_path, kind="ast") is None, (
|
|
"AST cache entry from a previous graphify version must not be served"
|
|
)
|
|
|
|
|
|
def test_ast_cache_version_bump_cleans_stale_entries(tmp_path, monkeypatch):
|
|
"""Upgrading removes AST entries left behind by previous versions so the
|
|
cache directory does not grow one full copy per release."""
|
|
import graphify.cache as cache_mod
|
|
|
|
f = tmp_path / "mod.py"
|
|
f.write_text("def f(): pass\n")
|
|
|
|
monkeypatch.setattr(cache_mod, "_EXTRACTOR_VERSION", "0.8.0", raising=False)
|
|
save_cached(f, {"nodes": [{"id": "n1"}], "edges": []}, root=tmp_path, kind="ast")
|
|
old_dir = cache_dir(tmp_path, "ast")
|
|
assert any(old_dir.glob("*.json"))
|
|
|
|
monkeypatch.setattr(cache_mod, "_EXTRACTOR_VERSION", "0.8.1", raising=False)
|
|
monkeypatch.setattr(cache_mod, "_cleaned_ast_dirs", set(), raising=False)
|
|
cache_dir(tmp_path, "ast")
|
|
assert not old_dir.exists(), (
|
|
"stale AST version directory must be removed on upgrade"
|
|
)
|
|
|
|
|
|
def test_legacy_unversioned_ast_entries_not_served(tmp_path):
|
|
"""Entries written by pre-versioning graphify (flat cache/ or unversioned
|
|
cache/ast/) are by definition from an older extractor and must not be
|
|
served — that staleness is exactly what version namespacing fixes."""
|
|
import json
|
|
from graphify.cache import file_hash, _GRAPHIFY_OUT
|
|
|
|
f = tmp_path / "mod.py"
|
|
f.write_text("def f(): pass\n")
|
|
h = file_hash(f, tmp_path)
|
|
payload = json.dumps({"nodes": [{"id": "stale"}], "edges": []})
|
|
|
|
# Unversioned cache/ast/{hash}.json (pre-versioning layout)
|
|
unversioned = tmp_path / _GRAPHIFY_OUT / "cache" / "ast"
|
|
unversioned.mkdir(parents=True)
|
|
(unversioned / f"{h}.json").write_text(payload)
|
|
# Legacy flat cache/{hash}.json (pre-0.5.3 layout)
|
|
(unversioned.parent / f"{h}.json").write_text(payload)
|
|
|
|
assert load_cached(f, root=tmp_path, kind="ast") is None
|
|
|
|
|
|
def test_semantic_cache_survives_version_bump(tmp_path, monkeypatch):
|
|
"""The semantic cache is deliberately not versioned: entries are produced
|
|
by the LLM from file contents, and re-extraction costs real money."""
|
|
import graphify.cache as cache_mod
|
|
|
|
f = tmp_path / "doc.md"
|
|
f.write_text("# Title\n\nBody.\n")
|
|
|
|
monkeypatch.setattr(cache_mod, "_EXTRACTOR_VERSION", "0.8.0", raising=False)
|
|
save_cached(f, {"nodes": [{"id": "n1"}], "edges": []}, root=tmp_path, kind="semantic")
|
|
semantic_dir = cache_dir(tmp_path, "semantic")
|
|
|
|
monkeypatch.setattr(cache_mod, "_EXTRACTOR_VERSION", "0.8.1", raising=False)
|
|
monkeypatch.setattr(cache_mod, "_cleaned_ast_dirs", set(), raising=False)
|
|
cache_dir(tmp_path, "ast") # triggers stale-AST cleanup
|
|
assert load_cached(f, root=tmp_path, kind="semantic") is not None
|
|
assert any(semantic_dir.glob("*.json")), (
|
|
"semantic entries must survive both the version bump and AST cleanup"
|
|
)
|
|
|
|
|
|
def test_save_cached_in_root_symlink_keeps_symlink_name(tmp_path):
|
|
"""``source_file`` for an in-root symlink must be stored under the
|
|
symlink's own name, not the resolved target. Lower-impact than the
|
|
manifest case (cache lookup is content-hashed, not key-matched), but
|
|
keeps the on-disk shape consistent with what callers passed in."""
|
|
import json
|
|
from graphify.cache import save_cached, file_hash, cache_dir
|
|
|
|
(tmp_path / "sub").mkdir()
|
|
target = tmp_path / "sub" / "target.py"
|
|
target.write_text("pass\n")
|
|
alias = tmp_path / "alias.py"
|
|
try:
|
|
alias.symlink_to(target)
|
|
except (OSError, NotImplementedError):
|
|
import pytest
|
|
pytest.skip("filesystem does not support symlinks")
|
|
|
|
abs_alias = str(alias) # caller's view — the symlink path, unresolved
|
|
save_cached(alias, {
|
|
"nodes": [{"id": "n1", "source_file": abs_alias}],
|
|
"edges": [],
|
|
}, root=tmp_path, kind="ast")
|
|
|
|
h = file_hash(alias, tmp_path)
|
|
entry = cache_dir(tmp_path, "ast") / f"{h}.json"
|
|
on_disk = json.loads(entry.read_text(encoding="utf-8"))
|
|
assert on_disk["nodes"][0]["source_file"] == "alias.py", (
|
|
f"cache must store symlink name, not resolved target; got "
|
|
f"{on_disk['nodes'][0]['source_file']!r}"
|
|
)
|
|
|
|
|
|
def test_semantic_prune_removes_orphan_entries(tmp_path):
|
|
"""Changing a file's content leaves the old content-hash entry orphaned;
|
|
pruning against the new live hash removes the stale entry and keeps the
|
|
current one."""
|
|
from graphify.cache import prune_semantic_cache
|
|
|
|
f = tmp_path / "doc.md"
|
|
f.write_text("# A\n\nContent A.\n")
|
|
h_a = file_hash(f, tmp_path)
|
|
save_cached(f, {"nodes": [{"id": "a"}], "edges": []}, root=tmp_path, kind="semantic")
|
|
|
|
f.write_text("# B\n\nContent B.\n")
|
|
h_b = file_hash(f, tmp_path)
|
|
save_cached(f, {"nodes": [{"id": "b"}], "edges": []}, root=tmp_path, kind="semantic")
|
|
|
|
semantic_dir = cache_dir(tmp_path, "semantic")
|
|
assert (semantic_dir / f"{h_a}.json").exists()
|
|
assert (semantic_dir / f"{h_b}.json").exists()
|
|
|
|
pruned = prune_semantic_cache(tmp_path, {h_b})
|
|
assert pruned == 1
|
|
assert not (semantic_dir / f"{h_a}.json").exists()
|
|
assert (semantic_dir / f"{h_b}.json").exists()
|
|
|
|
|
|
def test_semantic_prune_keeps_live_unchanged_entries(tmp_path):
|
|
"""Pruning against the FULL live set must keep every live entry — guards
|
|
the trap of pruning against an incremental changed-subset, which would
|
|
delete all unchanged docs' valid entries."""
|
|
from graphify.cache import prune_semantic_cache
|
|
|
|
live_hashes = set()
|
|
for i in range(5):
|
|
f = tmp_path / f"doc{i}.md"
|
|
f.write_text(f"# Doc {i}\n\nBody {i}.\n")
|
|
save_cached(f, {"nodes": [{"id": str(i)}], "edges": []}, root=tmp_path, kind="semantic")
|
|
live_hashes.add(file_hash(f, tmp_path))
|
|
|
|
semantic_dir = cache_dir(tmp_path, "semantic")
|
|
assert len(list(semantic_dir.glob("*.json"))) == 5
|
|
|
|
pruned = prune_semantic_cache(tmp_path, live_hashes)
|
|
assert pruned == 0
|
|
assert len(list(semantic_dir.glob("*.json"))) == 5
|
|
|
|
|
|
def test_semantic_prune_handles_deleted_file(tmp_path):
|
|
"""An entry for a file that no longer exists (dropped from the live set) is
|
|
pruned."""
|
|
from graphify.cache import prune_semantic_cache
|
|
|
|
f = tmp_path / "gone.md"
|
|
f.write_text("# Gone\n\nWill be deleted.\n")
|
|
h = file_hash(f, tmp_path)
|
|
save_cached(f, {"nodes": [{"id": "g"}], "edges": []}, root=tmp_path, kind="semantic")
|
|
semantic_dir = cache_dir(tmp_path, "semantic")
|
|
assert (semantic_dir / f"{h}.json").exists()
|
|
|
|
f.unlink()
|
|
# Live set is empty: the file is gone, so its entry must be pruned.
|
|
pruned = prune_semantic_cache(tmp_path, set())
|
|
assert pruned == 1
|
|
assert not (semantic_dir / f"{h}.json").exists()
|
|
|
|
|
|
def test_semantic_prune_ignores_ast_and_tmp(tmp_path):
|
|
"""Prune touches only cache/semantic/*.json: AST entries and atomic-write
|
|
*.tmp temporaries are left untouched."""
|
|
from graphify.cache import prune_semantic_cache
|
|
|
|
f = tmp_path / "doc.md"
|
|
f.write_text("# Doc\n\nBody.\n")
|
|
# AST entry (different subtree) must survive.
|
|
save_cached(f, {"nodes": [{"id": "ast"}], "edges": []}, root=tmp_path, kind="ast")
|
|
ast_dir = cache_dir(tmp_path, "ast")
|
|
assert len(list(ast_dir.glob("*.json"))) == 1
|
|
|
|
# A semantic orphan .json (to be pruned) plus a .tmp temporary (to survive).
|
|
semantic_dir = cache_dir(tmp_path, "semantic")
|
|
(semantic_dir / "deadbeef.json").write_text('{"nodes": [], "edges": []}')
|
|
tmp_entry = semantic_dir / "deadbeef.tmp"
|
|
tmp_entry.write_text("partial")
|
|
|
|
pruned = prune_semantic_cache(tmp_path, set())
|
|
assert pruned == 1
|
|
assert not (semantic_dir / "deadbeef.json").exists()
|
|
assert tmp_entry.exists(), "*.tmp temporaries must not be swept"
|
|
assert len(list(ast_dir.glob("*.json"))) == 1, "AST entries must not be touched"
|
|
|
|
|
|
def test_save_semantic_cache_overwrites_by_default(tmp_path):
|
|
"""Default save_semantic_cache replaces a file's cached entry (the final,
|
|
authoritative write in the extract pipeline)."""
|
|
from graphify.cache import save_semantic_cache
|
|
f = tmp_path / "doc.md"; f.write_text("# Doc\n")
|
|
save_semantic_cache([{"id": "a", "source_file": "doc.md"}], [], root=tmp_path)
|
|
save_semantic_cache([{"id": "b", "source_file": "doc.md"}], [], root=tmp_path)
|
|
cached = load_cached(f, root=tmp_path, kind="semantic")
|
|
ids = {n["id"] for n in cached["nodes"]}
|
|
assert ids == {"b"}, "default must overwrite, not accumulate"
|
|
|
|
|
|
def test_save_semantic_cache_rejects_out_of_scope_source_file(tmp_path):
|
|
"""#1757: an undispatched file must keep its complete cache entry when a
|
|
semantic result misattributes a node to it."""
|
|
from graphify.cache import save_semantic_cache
|
|
|
|
intended = tmp_path / "intended.md"
|
|
intended.write_text("# Intended\n")
|
|
protected = tmp_path / "protected.md"
|
|
protected.write_text("# Protected\n")
|
|
|
|
save_semantic_cache(
|
|
[{"id": "original", "source_file": "protected.md"}],
|
|
[],
|
|
root=tmp_path,
|
|
)
|
|
|
|
nodes = [
|
|
{"id": "expected", "source_file": str(intended.resolve())},
|
|
{"id": "stray", "source_file": "protected.md"},
|
|
]
|
|
edges = [
|
|
{"source": "stray", "target": "expected", "source_file": "protected.md"},
|
|
]
|
|
hyperedges = [
|
|
{"id": "stray_hyperedge", "nodes": ["stray"], "source_file": "protected.md"},
|
|
]
|
|
|
|
with pytest.warns(RuntimeWarning, match="out-of-scope source_file 'protected.md'"):
|
|
saved = save_semantic_cache(
|
|
nodes,
|
|
edges,
|
|
hyperedges,
|
|
root=tmp_path,
|
|
allowed_source_files=["intended.md"],
|
|
)
|
|
|
|
assert saved == 1
|
|
intended_cache = load_cached(intended, root=tmp_path, kind="semantic")
|
|
assert {node["id"] for node in intended_cache["nodes"]} == {"expected"}
|
|
|
|
protected_cache = load_cached(protected, root=tmp_path, kind="semantic")
|
|
assert {node["id"] for node in protected_cache["nodes"]} == {"original"}
|
|
assert protected_cache["edges"] == []
|
|
assert protected_cache["hyperedges"] == []
|
|
|
|
|
|
# --- #1894: mode-namespaced semantic cache -----------------------------------
|
|
# `extract --mode deep` produces richer results than standard extraction, so
|
|
# deep entries live in their own namespace (cache/semantic-deep/). mode=None
|
|
# must stay byte-identical to the historical behavior: older installed skill
|
|
# flows call check/save without the parameter and must be unaffected.
|
|
|
|
def test_semantic_cache_deep_mode_roundtrip_under_deep_namespace(tmp_path):
|
|
"""mode='deep' saves under cache/semantic-deep/ and reads back from it."""
|
|
from graphify.cache import check_semantic_cache, save_semantic_cache
|
|
|
|
f = tmp_path / "doc.md"
|
|
f.write_text("# Doc\n\nBody.\n")
|
|
saved = save_semantic_cache(
|
|
[{"id": "deep_n", "source_file": "doc.md"}], [], root=tmp_path, mode="deep"
|
|
)
|
|
assert saved == 1
|
|
|
|
deep_dir = tmp_path / "graphify-out" / "cache" / "semantic-deep"
|
|
h = file_hash(f, tmp_path)
|
|
assert (deep_dir / f"{h}.json").exists(), (
|
|
"deep entry must land under cache/semantic-deep/"
|
|
)
|
|
# And NOT in the plain namespace.
|
|
plain_dir = tmp_path / "graphify-out" / "cache" / "semantic"
|
|
assert not (plain_dir / f"{h}.json").exists()
|
|
|
|
nodes, edges, hyper, uncached = check_semantic_cache(
|
|
[str(f)], root=tmp_path, mode="deep"
|
|
)
|
|
assert [n["id"] for n in nodes] == ["deep_n"]
|
|
assert uncached == []
|
|
|
|
|
|
def test_semantic_cache_deep_invisible_to_plain_reads_and_vice_versa(tmp_path):
|
|
"""Deep entries must not satisfy mode=None reads (and plain entries must
|
|
not satisfy deep reads) — the namespaces are fully isolated."""
|
|
from graphify.cache import check_semantic_cache, save_semantic_cache
|
|
|
|
deep_doc = tmp_path / "deep.md"
|
|
deep_doc.write_text("# Deep\n")
|
|
plain_doc = tmp_path / "plain.md"
|
|
plain_doc.write_text("# Plain\n")
|
|
|
|
save_semantic_cache([{"id": "d", "source_file": "deep.md"}], [],
|
|
root=tmp_path, mode="deep")
|
|
save_semantic_cache([{"id": "p", "source_file": "plain.md"}], [],
|
|
root=tmp_path) # mode omitted: historical call shape
|
|
|
|
# Plain read: deep entry is a miss, plain entry is a hit.
|
|
nodes, _, _, uncached = check_semantic_cache(
|
|
[str(deep_doc), str(plain_doc)], root=tmp_path
|
|
)
|
|
assert [n["id"] for n in nodes] == ["p"]
|
|
assert uncached == [str(deep_doc)]
|
|
|
|
# Deep read: mirror image.
|
|
nodes, _, _, uncached = check_semantic_cache(
|
|
[str(deep_doc), str(plain_doc)], root=tmp_path, mode="deep"
|
|
)
|
|
assert [n["id"] for n in nodes] == ["d"]
|
|
assert uncached == [str(plain_doc)]
|
|
|
|
|
|
def test_semantic_cache_mode_none_layout_unchanged(tmp_path):
|
|
"""Omitting mode writes exactly the historical cache/semantic/ layout —
|
|
forward-compat for older installed callers that never pass mode."""
|
|
from graphify.cache import check_semantic_cache, save_semantic_cache
|
|
|
|
f = tmp_path / "doc.md"
|
|
f.write_text("# Doc\n")
|
|
save_semantic_cache([{"id": "n", "source_file": "doc.md"}], [], root=tmp_path)
|
|
h = file_hash(f, tmp_path)
|
|
assert (tmp_path / "graphify-out" / "cache" / "semantic" / f"{h}.json").exists()
|
|
assert not (tmp_path / "graphify-out" / "cache" / "semantic-deep").exists(), (
|
|
"mode=None must never create the deep namespace"
|
|
)
|
|
nodes, _, _, uncached = check_semantic_cache([str(f)], root=tmp_path)
|
|
assert [n["id"] for n in nodes] == ["n"] and uncached == []
|
|
|
|
|
|
def test_clear_cache_removes_deep_namespace(tmp_path):
|
|
"""clear_cache sweeps cache/semantic-deep/ alongside semantic/ and ast/."""
|
|
from graphify.cache import save_semantic_cache
|
|
|
|
f = tmp_path / "doc.md"
|
|
f.write_text("# Doc\n")
|
|
save_semantic_cache([{"id": "p", "source_file": "doc.md"}], [], root=tmp_path)
|
|
save_semantic_cache([{"id": "d", "source_file": "doc.md"}], [],
|
|
root=tmp_path, mode="deep")
|
|
base = tmp_path / "graphify-out" / "cache"
|
|
assert list((base / "semantic").glob("*.json"))
|
|
assert list((base / "semantic-deep").glob("*.json"))
|
|
|
|
clear_cache(tmp_path)
|
|
assert not list(base.rglob("*.json")), (
|
|
"clear_cache must remove entries in BOTH semantic namespaces"
|
|
)
|
|
|
|
|
|
def test_cached_files_includes_deep_namespace(tmp_path):
|
|
"""cached_files reports deep-namespace entries too."""
|
|
from graphify.cache import save_semantic_cache
|
|
|
|
f = tmp_path / "doc.md"
|
|
f.write_text("# Doc\n")
|
|
save_semantic_cache([{"id": "d", "source_file": "doc.md"}], [],
|
|
root=tmp_path, mode="deep")
|
|
assert file_hash(f, tmp_path) in cached_files(tmp_path)
|
|
|
|
|
|
def test_semantic_prune_sweeps_both_namespaces_against_same_live_set(tmp_path):
|
|
"""#1894 follow-up to #1527: prune must sweep cache/semantic/ AND
|
|
cache/semantic-deep/ against the SAME live-hash set (liveness is
|
|
content-based, mode-independent). Orphans go in both namespaces; live
|
|
entries survive in both."""
|
|
from graphify.cache import prune_semantic_cache, save_semantic_cache
|
|
|
|
f = tmp_path / "doc.md"
|
|
f.write_text("# A\n\nContent A.\n")
|
|
h_old = file_hash(f, tmp_path)
|
|
save_semantic_cache([{"id": "pa", "source_file": "doc.md"}], [], root=tmp_path)
|
|
save_semantic_cache([{"id": "da", "source_file": "doc.md"}], [],
|
|
root=tmp_path, mode="deep")
|
|
|
|
f.write_text("# B\n\nContent B.\n")
|
|
h_live = file_hash(f, tmp_path)
|
|
save_semantic_cache([{"id": "pb", "source_file": "doc.md"}], [], root=tmp_path)
|
|
save_semantic_cache([{"id": "db", "source_file": "doc.md"}], [],
|
|
root=tmp_path, mode="deep")
|
|
|
|
plain_dir = tmp_path / "graphify-out" / "cache" / "semantic"
|
|
deep_dir = tmp_path / "graphify-out" / "cache" / "semantic-deep"
|
|
for d in (plain_dir, deep_dir):
|
|
assert (d / f"{h_old}.json").exists()
|
|
assert (d / f"{h_live}.json").exists()
|
|
|
|
pruned = prune_semantic_cache(tmp_path, {h_live})
|
|
assert pruned == 2, "one orphan in EACH namespace must be pruned"
|
|
for d in (plain_dir, deep_dir):
|
|
assert not (d / f"{h_old}.json").exists(), f"orphan survived in {d.name}"
|
|
assert (d / f"{h_live}.json").exists(), f"live entry pruned from {d.name}"
|
|
|
|
|
|
def test_save_semantic_cache_merge_existing_unions(tmp_path):
|
|
"""#1715: merge_existing=True unions with the prior entry so a file split
|
|
across chunks (checkpointed per chunk) keeps every slice."""
|
|
from graphify.cache import save_semantic_cache
|
|
f = tmp_path / "big.md"; f.write_text("# Big\n")
|
|
# chunk 1 slice
|
|
save_semantic_cache([{"id": "a", "source_file": "big.md"}],
|
|
[{"source": "a", "target": "x", "source_file": "big.md"}],
|
|
root=tmp_path, merge_existing=True)
|
|
# chunk 2 slice for the same file
|
|
save_semantic_cache([{"id": "b", "source_file": "big.md"}], [],
|
|
root=tmp_path, merge_existing=True)
|
|
cached = load_cached(f, root=tmp_path, kind="semantic")
|
|
ids = {n["id"] for n in cached["nodes"]}
|
|
assert ids == {"a", "b"}, "merge_existing must union both chunk slices"
|
|
assert len(cached["edges"]) == 1
|
|
|
|
|
|
def test_save_semantic_cache_drops_edges_to_out_of_scope_nodes(tmp_path):
|
|
"""#1916: an edge in an ALLOWED file's group referencing a node grouped
|
|
under an out-of-scope REAL file used to be written verbatim, so on replay
|
|
(check_semantic_cache) it dangled forever — the #1895 merged-result filter
|
|
runs after this checkpoint write and is bypassed entirely on replay. The
|
|
written entry must carry no reference to the skipped id, while a
|
|
duplicate-attribution node (also defined in a written group) must not be
|
|
over-pruned."""
|
|
from graphify.cache import check_semantic_cache, save_semantic_cache
|
|
|
|
allowed = tmp_path / "allowed.md"
|
|
allowed.write_text("# Allowed\n")
|
|
outside = tmp_path / "outside.md"
|
|
outside.write_text("# Outside\n")
|
|
|
|
nodes = [
|
|
{"id": "kept", "source_file": "allowed.md"},
|
|
{"id": "stray", "source_file": "outside.md"},
|
|
# duplicate attribution: same id defined in a written AND a skipped group
|
|
{"id": "dup", "source_file": "allowed.md"},
|
|
{"id": "dup", "source_file": "outside.md"},
|
|
]
|
|
edges = [
|
|
{"source": "kept", "target": "stray", "source_file": "allowed.md"},
|
|
{"source": "stray", "target": "kept", "source_file": "allowed.md"},
|
|
{"source": "kept", "target": "dup", "source_file": "allowed.md"},
|
|
]
|
|
with pytest.warns(RuntimeWarning, match="out-of-scope source_file"):
|
|
saved = save_semantic_cache(
|
|
nodes, edges, root=tmp_path, allowed_source_files=["allowed.md"]
|
|
)
|
|
assert saved == 1
|
|
|
|
cached_nodes, cached_edges, _, uncached = check_semantic_cache(
|
|
[str(allowed)], root=tmp_path
|
|
)
|
|
assert uncached == []
|
|
assert {n["id"] for n in cached_nodes} == {"kept", "dup"}
|
|
pairs = [(e["source"], e["target"]) for e in cached_edges]
|
|
assert pairs == [("kept", "dup")], "edges touching the skipped id must be dropped"
|
|
|
|
|
|
def test_save_semantic_cache_drops_edges_to_ghost_file_nodes(tmp_path):
|
|
"""#1916 (ghost variant): a node group whose source_file does not exist is
|
|
silently skipped by the write loop; edges in a written group referencing
|
|
its node ids must not survive into the cache."""
|
|
from graphify.cache import check_semantic_cache, save_semantic_cache
|
|
|
|
real = tmp_path / "real.md"
|
|
real.write_text("# Real\n")
|
|
|
|
nodes = [
|
|
{"id": "kept", "source_file": "real.md"},
|
|
{"id": "phantom", "source_file": "ghost.md"}, # no such file on disk
|
|
]
|
|
edges = [
|
|
{"source": "kept", "target": "phantom", "source_file": "real.md"},
|
|
{"source": "kept", "target": "kept", "relation": "self", "source_file": "real.md"},
|
|
]
|
|
saved = save_semantic_cache(
|
|
nodes, edges, root=tmp_path, allowed_source_files=["real.md"]
|
|
)
|
|
assert saved == 1
|
|
|
|
cached_nodes, cached_edges, _, uncached = check_semantic_cache(
|
|
[str(real)], root=tmp_path
|
|
)
|
|
assert uncached == []
|
|
assert {n["id"] for n in cached_nodes} == {"kept"}
|
|
pairs = [(e["source"], e["target"]) for e in cached_edges]
|
|
assert pairs == [("kept", "kept")]
|
|
|
|
|
|
def test_save_semantic_cache_drops_hyperedges_touching_skipped_nodes(tmp_path):
|
|
"""#1916: a hyperedge whose member list intersects the skipped ids is
|
|
dropped whole (mirroring the #1895 semantics), while hyperedges over
|
|
surviving nodes are kept."""
|
|
from graphify.cache import check_semantic_cache, save_semantic_cache
|
|
|
|
allowed = tmp_path / "allowed.md"
|
|
allowed.write_text("# Allowed\n")
|
|
outside = tmp_path / "outside.md"
|
|
outside.write_text("# Outside\n")
|
|
|
|
nodes = [
|
|
{"id": "kept", "source_file": "allowed.md"},
|
|
{"id": "kept2", "source_file": "allowed.md"},
|
|
{"id": "stray", "source_file": "outside.md"},
|
|
]
|
|
hyperedges = [
|
|
{"id": "he_bad", "nodes": ["kept", "stray"], "source_file": "allowed.md"},
|
|
{"id": "he_ok", "nodes": ["kept", "kept2"], "source_file": "allowed.md"},
|
|
]
|
|
with pytest.warns(RuntimeWarning, match="out-of-scope source_file"):
|
|
save_semantic_cache(
|
|
nodes, [], hyperedges, root=tmp_path, allowed_source_files=["allowed.md"]
|
|
)
|
|
|
|
_, _, cached_hyperedges, uncached = check_semantic_cache(
|
|
[str(allowed)], root=tmp_path
|
|
)
|
|
assert uncached == []
|
|
assert {h["id"] for h in cached_hyperedges} == {"he_ok"}
|
|
|
|
|
|
def test_save_semantic_cache_unscoped_preserves_dangling_refs_verbatim(tmp_path):
|
|
"""#1916 guard-rail: unscoped callers (allowed_source_files=None) must stay
|
|
byte-identical — no pruning happens even when an edge or hyperedge
|
|
references a node grouped under a ghost file."""
|
|
from graphify.cache import save_semantic_cache
|
|
|
|
doc = tmp_path / "doc.md"
|
|
doc.write_text("# Doc\n")
|
|
|
|
nodes = [
|
|
{"id": "a", "source_file": "doc.md"},
|
|
{"id": "ghost_n", "source_file": "ghost.md"}, # skipped group (no file)
|
|
]
|
|
edges = [{"source": "a", "target": "ghost_n", "source_file": "doc.md"}]
|
|
hyperedges = [{"id": "he", "nodes": ["a", "ghost_n"], "source_file": "doc.md"}]
|
|
|
|
saved = save_semantic_cache(nodes, edges, hyperedges, root=tmp_path)
|
|
assert saved == 1
|
|
|
|
import json
|
|
raw = json.loads(
|
|
(cache_dir(tmp_path, "semantic") / f"{file_hash(doc, tmp_path)}.json").read_text()
|
|
)
|
|
assert raw["edges"] == edges
|
|
assert raw["hyperedges"] == hyperedges
|
|
|
|
|
|
def test_save_semantic_cache_merge_existing_prunes_only_incoming(tmp_path):
|
|
"""#1916 + #1715: with merge_existing=True (the llm.py checkpoint path),
|
|
only the INCOMING slice is pruned before the union — the prior cached
|
|
entry's valid edges must survive untouched."""
|
|
from graphify.cache import save_semantic_cache
|
|
|
|
big = tmp_path / "big.md"
|
|
big.write_text("# Big\n")
|
|
other = tmp_path / "other.md"
|
|
other.write_text("# Other\n")
|
|
|
|
# checkpoint 1: a clean slice
|
|
save_semantic_cache(
|
|
[{"id": "a", "source_file": "big.md"}],
|
|
[{"source": "a", "target": "a", "relation": "self", "source_file": "big.md"}],
|
|
root=tmp_path,
|
|
merge_existing=True,
|
|
allowed_source_files=["big.md"],
|
|
)
|
|
# checkpoint 2: incoming slice with a dangling edge to an out-of-scope node
|
|
nodes2 = [
|
|
{"id": "b", "source_file": "big.md"},
|
|
{"id": "stray", "source_file": "other.md"},
|
|
]
|
|
edges2 = [
|
|
{"source": "b", "target": "stray", "source_file": "big.md"},
|
|
{"source": "a", "target": "b", "source_file": "big.md"},
|
|
]
|
|
with pytest.warns(RuntimeWarning, match="out-of-scope source_file"):
|
|
save_semantic_cache(
|
|
nodes2, edges2, root=tmp_path, merge_existing=True,
|
|
allowed_source_files=["big.md"],
|
|
)
|
|
|
|
cached = load_cached(big, root=tmp_path, kind="semantic")
|
|
assert {n["id"] for n in cached["nodes"]} == {"a", "b"}
|
|
pairs = [(e["source"], e["target"]) for e in cached["edges"]]
|
|
assert ("a", "a") in pairs, "prior entry's valid edge must survive the union"
|
|
assert ("a", "b") in pairs, "incoming valid edge must be kept"
|
|
assert not any("stray" in p for p in pairs)
|