diff --git a/CHANGELOG.md b/CHANGELOG.md index 076d602c..228a5b3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/graphify/llm.py b/graphify/llm.py index 23b30204..05e2186e 100644 --- a/graphify/llm.py +++ b/graphify/llm.py @@ -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", []), diff --git a/pyproject.toml b/pyproject.toml index 1b2b818d..3e5662a7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" } diff --git a/tests/test_chunking.py b/tests/test_chunking.py index b39262ad..42b4651a 100644 --- a/tests/test_chunking.py +++ b/tests/test_chunking.py @@ -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