fix(cache): resolve FileSlice via unit_path in checkpoint allowlist (#1870)

The #1757 batch-scoping followup built the per-chunk allowlist by reading
FileSlice.rel, which does not exist (a FileSlice carries its parent file
in .path). So every chunk containing a sliced oversized document leaked
the FileSlice object into the allowlist, save_semantic_cache raised
TypeError on Path(FileSlice), and the best-effort except swallowed it:
extraction finished but those chunks were never checkpointed, so a
re-run or a crash/rate-limit resume re-billed them.

Resolve each unit through the canonical unit_path() helper so a slice
maps to its parent file. Adds a regression test that slices a real
oversized .md and asserts the checkpoint writes without swallowing a
TypeError.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
safishamsi
2026-07-14 00:25:34 +01:00
parent 961b78e57a
commit cfc7cf2c93
4 changed files with 49 additions and 4 deletions
+4
View File
@@ -2,6 +2,10 @@
Full release notes with details on each version: [GitHub Releases](https://github.com/safishamsi/graphify/releases)
## 0.9.16 (unreleased)
- Fix: the incremental semantic-cache checkpoint no longer fails on oversized (sliced) documents (#1870). The 0.9.14 batch-scoping fix built its per-chunk allowlist by reading `FileSlice.rel`, an attribute that does not exist (a `FileSlice` carries its parent file in `.path`), so every chunk containing a sliced document leaked the `FileSlice` object into the allowlist, `save_semantic_cache` raised `TypeError`, and the best-effort handler swallowed it: extraction still finished but those chunks were never checkpointed, so a re-run or a run resumed after a crash/rate-limit re-billed them. The allowlist now resolves each unit through `unit_path`, so a slice maps to its parent file and the checkpoint writes as intended.
## 0.9.15 (2026-07-13)
- Fix: detection now honors nested `.gitignore`/`.graphifyignore` files below the scan root, not just those at the scan root and above (#1847, thanks @Mohak-Agrawal). git applies a `.gitignore` to everything under its own directory, but graphify only loaded ignore files from the VCS-root-down-to-scan-root chain — so a `vendor/sub/.gitignore` deeper in the tree was never read and its exclusions leaked into the graph. Each directory's own ignore files are now read during the walk and anchored to that directory, preserving last-match-wins precedence (nearer files win, including over `.git/info/exclude`) and the parent-exclusion rule.
+6 -3
View File
@@ -1918,9 +1918,12 @@ def extract_corpus_parallel(
# (#1757). The model can attribute a node's source_file to another
# corpus file; without this bound, that stray node would clobber the
# other file's complete cache entry (or, with merge_existing, pollute
# it). A FileSlice reports its file via `.rel`; a bare Path is the
# relative source_file itself.
allowed = [getattr(item, "rel", None) or item for item in chunk]
# it). Use unit_path so a FileSlice (one slice of an oversized doc)
# resolves to its parent file; a bare Path passes through. (#1870: the
# old `.rel` attribute does not exist on FileSlice, so every sliced
# chunk leaked the FileSlice object into the allowlist and the write
# raised TypeError, silently defeating the checkpoint.)
allowed = [unit_path(item) for item in chunk]
_scs(
result.get("nodes", []),
result.get("edges", []),
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "graphifyy"
version = "0.9.15"
version = "0.9.16"
description = "AI coding assistant skill (Claude Code, CodeBuddy, Codex, OpenCode, Kilo Code, Cursor, Gemini CLI, Aider, OpenClaw, Factory Droid, Trae, Hermes, Kiro, Pi, Devin CLI, Google Antigravity) - turn any folder of code, docs, papers, images, or videos into a queryable knowledge graph"
readme = "README.md"
license = { file = "LICENSE" }
+38
View File
@@ -326,6 +326,44 @@ def test_checkpoint_scopes_cache_writes_to_chunk_files(tmp_path):
assert a_cache and any(n["id"] == "a_ok" for n in a_cache["nodes"])
def test_checkpoint_caches_sliced_document_chunks(tmp_path, capsys):
"""#1870: the checkpoint's allowlist must resolve a FileSlice to its parent
path (via unit_path), not read a non-existent `.rel`. An oversized doc is
split into FileSlice units; before the fix each sliced chunk leaked the
FileSlice object into the allowlist, so save_semantic_cache raised TypeError,
the best-effort except swallowed it, and the slice was never checkpointed."""
from graphify.llm import extract_corpus_parallel, expand_oversized_files, _FILE_CHAR_CAP
from graphify.file_slice import FileSlice
from graphify.cache import load_cached
doc = tmp_path / "big.md"
doc.write_text("# Title\n" + ("word " * 12000) + "\n## Section\n" + ("more " * 12000))
# sanity: the doc really does slice into FileSlice units
units = expand_oversized_files([doc], _FILE_CHAR_CAP)
assert len(units) > 1 and all(isinstance(u, FileSlice) for u in units)
def sliced(chunk, **kwargs):
assert any(isinstance(c, FileSlice) for c in chunk)
return {
"nodes": [{"id": "big_title", "source_file": "big.md", "file_type": "document"}],
"edges": [], "hyperedges": [], "input_tokens": 1, "output_tokens": 1,
}
with patch("graphify.llm.extract_files_direct", side_effect=sliced):
extract_corpus_parallel(
[doc], backend="kimi", root=tmp_path,
token_budget=None, chunk_size=1, max_concurrency=1,
)
assert "incremental cache checkpoint failed" not in capsys.readouterr().err, (
"checkpoint raised on a FileSlice chunk (#1870)"
)
cached = load_cached(doc, tmp_path, kind="semantic")
assert cached and any(n["id"] == "big_title" for n in cached["nodes"]), (
"sliced document was never checkpointed (#1870)"
)
def test_corpus_parallel_legacy_mode_when_token_budget_is_none(tmp_path):
"""token_budget=None should fall back to legacy fixed-count chunking."""
from graphify.llm import extract_corpus_parallel