Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu
- Fix: a PHP `use` import written with a leading-backslash / fully-qualified prefix now resolves to its target definition instead of being dropped (#2661, thanks @ousamabenyounes).
- Fix: an unresolved local JS/TS import (to a file absent from the scan) now emits a stable, portable `ref` target id instead of leaking a per-checkout absolute-path slug (#2457, thanks @rohit-jsfreaky).
- Fix: `graphify benchmark` no longer crashes on a node whose label is `None` (#2674, thanks @Arthuro0103).
- Fix: a corrupt (invalid-JSON) semantic cache entry is no longer swallowed as a silent miss; `check_semantic_cache` now emits one aggregate warning naming how many entries failed to parse, so a cache that fails on every run (silently re-billing the extraction) is diagnosable instead of invisible (#2405, thanks @jorgefusterr).

## 0.9.40 (2026-08-11)

Expand Down
30 changes: 28 additions & 2 deletions graphify/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,12 @@ def _cleanup_stale_ast_entries(ast_base: Path, current_dir: Path) -> None:
# check_semantic_cache can report N to the user (#1939).
_legacy_semantic_hits = 0

# Count of cache entries that failed to parse as JSON this process. A corrupt
# entry is not a miss: left in place it fails on every future run, silently
# re-extracting (and, for semantic kinds, re-billing) the file forever. The
# counter lets check_semantic_cache surface one aggregate warning (#2405).
_corrupt_cache_entries = 0

# Prompt-file fingerprints already computed, keyed by (path, size, mtime_ns) —
# the same stat signature the hash index uses. check_semantic_cache resolves the
# prompt once per FILE in the corpus, so without this a 500-doc run re-reads and
Expand Down Expand Up @@ -896,7 +902,7 @@ def load_cached(path: Path, root: Path = Path("."), kind: str = "ast",
``merge_existing``) pass allow_legacy=False.
Returns None if no cache entry or file has changed.
"""
global _legacy_semantic_hits
global _legacy_semantic_hits, _corrupt_cache_entries
location = cache_root if cache_root is not None else root
try:
h = file_hash(path, root, cache_root=cache_root)
Expand All @@ -912,7 +918,14 @@ def load_cached(path: Path, root: Path = Path("."), kind: str = "ast",
if entry.exists():
try:
result = json.loads(entry.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
except json.JSONDecodeError:
# Corrupt entry, not a miss: a truncated write or a bad producer
# (e.g. unescaped Windows backslashes in source_file) leaves JSON
# that fails to parse on every future run, so the file is silently
# re-extracted forever. Count it so the run can report it (#2405).
_corrupt_cache_entries += 1
return None
except OSError:
return None
# A ``partial`` entry was produced from a truncated LLM response and
# covers only part of the file's symbols. Serving it as authoritative
Expand Down Expand Up @@ -1154,6 +1167,7 @@ def check_semantic_cache(
cached_hyperedges: list[dict] = []
uncached: list[str] = []
legacy_before = _legacy_semantic_hits
corrupt_before = _corrupt_cache_entries

for fpath in files:
p = Path(fpath)
Expand All @@ -1180,6 +1194,18 @@ def check_semantic_cache(
stacklevel=2,
)

corrupt = _corrupt_cache_entries - corrupt_before
if corrupt:
warnings.warn(
f"{corrupt} semantic cache entr{'y' if corrupt == 1 else 'ies'} could "
"not be parsed as JSON and were treated as misses, so those files were "
"re-extracted. A corrupt entry stays on disk and fails again every run; "
"run with --force (or GRAPHIFY_FORCE=1) to rewrite them, or clear the "
"cache to stop paying for the re-extraction (#2405).",
RuntimeWarning,
stacklevel=2,
)

return cached_nodes, cached_edges, cached_hyperedges, uncached


Expand Down
31 changes: 31 additions & 0 deletions tests/test_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -1455,3 +1455,34 @@ def test_file_hash_fastpath_still_serves_a_settled_file(tmp_path, monkeypatch):
second = file_hash(f, tmp_path)
assert second == first
assert reads == [], "settled file was re-read; the stat fastpath is dead"


def test_corrupt_semantic_entry_warns_and_is_a_miss(tmp_path):
"""A corrupt (invalid-JSON) cache entry must not be silently swallowed
(#2405). Left unreported it fails to parse on every future run, re-billing
the semantic extraction forever with no diagnostic. check_semantic_cache
treats it as a miss (uncached) AND emits one aggregate warning naming the
count, mirroring the pre-fingerprint legacy-hit warning."""
from graphify.cache import (
check_semantic_cache,
save_semantic_cache,
cache_dir,
)

f = tmp_path / "doc.md"
f.write_text("# Doc\n\nBody.\n")
save_semantic_cache([{"id": "n", "source_file": "doc.md"}], [], root=tmp_path)

# Corrupt the on-disk entry (e.g. an old producer wrote unescaped
# backslashes, or a partial write left truncated JSON).
h = file_hash(f, tmp_path)
entry = cache_dir(tmp_path, "semantic") / f"{h}.json"
assert entry.exists()
entry.write_text('{"nodes": [ this is not valid json')

with pytest.warns(RuntimeWarning, match="corrupt"):
nodes, _, _, uncached = check_semantic_cache([str(f)], root=tmp_path)

# The corrupt entry is a miss, so the file is re-dispatched for extraction.
assert nodes == []
assert uncached == [str(f)]
Loading