diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e0b84c22..bd7bb85bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/graphify/cache.py b/graphify/cache.py index ea94227ab..bdb6cbd1e 100644 --- a/graphify/cache.py +++ b/graphify/cache.py @@ -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 @@ -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) @@ -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 @@ -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) @@ -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 diff --git a/tests/test_cache.py b/tests/test_cache.py index 510ca430d..67135b393 100644 --- a/tests/test_cache.py +++ b/tests/test_cache.py @@ -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)]