From 4528666ba6f37902cc1f95e829e832747ea6e4ff Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Mon, 14 Sep 2026 16:02:32 +0530 Subject: [PATCH 1/7] Add basename recovery helper for semantic cache groups save_semantic_cache groups nodes and edges by source_file, then skips any group whose path does not resolve to a real file. A weak or local backend's adaptive retry split path sometimes reports a source_file for a group that never resolves at all, silently dropping that group's nodes and edges from the cache on every incremental run. This adds a helper that recovers such a group by an unambiguous basename match against the known good, already dispatched allowlist, with no behavior change yet since nothing calls it. The write loop is switched over in the next commit. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh --- graphify/cache.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/graphify/cache.py b/graphify/cache.py index a70cff03dc..6b30d71a85 100644 --- a/graphify/cache.py +++ b/graphify/cache.py @@ -1490,6 +1490,31 @@ def resolved_source_path(value: str | Path) -> Path: if allowed_source_files is not None: allowed_paths = {source_path(path) for path in allowed_source_files} + def _recover_group_path(fpath: str) -> tuple[Path, Path]: + """Return ``(cache_path, resolved_path)`` for one ``by_file`` group, + recovering an unresolvable ``source_file`` via an unambiguous + basename match against ``allowed_paths`` when one is available (#2973). + + The adaptive-retry split path (``llm.py``'s bisect-and-retry on a + chunk that overflowed a weak/local backend's context) sometimes + re-prompts with a reduced file subset and loses track of which of + the original chunk's files a given node came from, so its + ``source_file`` never resolves to a real path at all. Recovering it + against the known-good, already-dispatched allowlist -- and ONLY + when the basename is unambiguous there -- lets that group's nodes + and edges reach the cache instead of silently vanishing on every + incremental run. A genuinely bogus or ambiguous basename still + falls through unrecovered to the existing skip behavior below. + """ + cache_path = source_path(fpath) + resolved = resolved_source_path(fpath) + if not resolved.is_file() and allowed_paths is not None: + candidates = [ap for ap in allowed_paths if ap.name == cache_path.name] + if len(candidates) == 1: + cache_path = candidates[0] + resolved = candidates[0] + return cache_path, resolved + partial_paths = None if partial_source_files is not None: partial_paths = {source_path(path) for path in partial_source_files} From 73f5a66370b0fbc42fe983fdc84a58c2d5fdcafa Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Mon, 14 Sep 2026 16:04:46 +0530 Subject: [PATCH 2/7] Recover malformed semantic cache paths by basename Fixes issue #2973. group_skipped and the save_semantic_cache write loop now call _recover_group_path instead of resolving fpath directly, so a group whose reported source_file never resolves to a real file gets one more chance: an unambiguous basename match against the already dispatched allowlist. An ambiguous or genuinely bogus basename still falls through to the previous skip behavior unchanged, and unscoped callers (no allowed_source_files) are byte identical to before since recovery only runs when an allowlist is available. This stops an incremental run from silently redoing already processed chunks every time the adaptive retry split path reports a source_file that a weak or local backend lost track of. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh --- graphify/cache.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/graphify/cache.py b/graphify/cache.py index 6b30d71a85..cc3a5a48bd 100644 --- a/graphify/cache.py +++ b/graphify/cache.py @@ -1531,9 +1531,9 @@ def _recover_group_path(fpath: str) -> tuple[Path, Path]: def group_skipped(fpath: str) -> bool: """Mirror the write-loop skip condition for one source_file group.""" - p = resolved_source_path(fpath) + cache_path, p = _recover_group_path(fpath) return not p.is_file() or ( - allowed_paths is not None and source_path(fpath) not in allowed_paths + allowed_paths is not None and cache_path not in allowed_paths ) # Dangling-reference pruning (#1916). A node group is skipped by the write @@ -1590,8 +1590,7 @@ def hyperedge_dangles(h: dict) -> bool: saved = 0 skipped_not_file = 0 for fpath, result in by_file.items(): - cache_path = source_path(fpath) - p = resolved_source_path(fpath) + cache_path, p = _recover_group_path(fpath) if p.is_file(): if allowed_paths is not None and cache_path not in allowed_paths: warnings.warn( From 4b91dcd462de21af61af6644ccbdb4bd8fd26cf1 Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Mon, 14 Sep 2026 16:08:02 +0530 Subject: [PATCH 3/7] Correct recovered groups own source_file before writing them A recovered group's write KEY pointed at the right file, but every node, edge, and hyperedge inside it still carried the original unresolvable source_file string. load_cached rejects an entry on read if any item's source_file does not match the path it was loaded under, so the previous commit alone wrote a group that reported as saved but could never actually be read back, silently reproducing the exact loss this fix exists for. This corrects every item's source_file to the recovered path's portable form before the group is written, only when recovery actually redirected the key, so an unrecovered group stays byte identical to before. Includes the regression test proving the full round trip: a group recovered by basename now survives a subsequent load_cached call. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh --- graphify/cache.py | 18 +++++++++++ .../test_semantic_cache_basename_recovery.py | 30 +++++++++++++++++++ 2 files changed, 48 insertions(+) create mode 100644 tests/test_semantic_cache_basename_recovery.py diff --git a/graphify/cache.py b/graphify/cache.py index cc3a5a48bd..63afc98eed 100644 --- a/graphify/cache.py +++ b/graphify/cache.py @@ -1592,6 +1592,24 @@ def hyperedge_dangles(h: dict) -> bool: for fpath, result in by_file.items(): cache_path, p = _recover_group_path(fpath) if p.is_file(): + if cache_path != source_path(fpath): + # #2973: recovery only redirected the WRITE KEY. Each item in + # this group still carries the original unresolvable + # source_file string, and _semantic_entry_matches_path + # rejects an entry on read if any item's source_file doesn't + # match the path it was loaded under -- so leaving the old + # value in place would write a "successful" entry that can + # never actually be read back, silently reproducing the same + # loss this recovery exists to fix. + corrected = _normalize_value(str(cache_path)) + result = { + **result, + "nodes": [{**n, "source_file": corrected} for n in result["nodes"]], + "edges": [{**e, "source_file": corrected} for e in result["edges"]], + "hyperedges": [ + {**h, "source_file": corrected} for h in result["hyperedges"] + ], + } if allowed_paths is not None and cache_path not in allowed_paths: warnings.warn( "semantic cache skipped out-of-scope source_file " diff --git a/tests/test_semantic_cache_basename_recovery.py b/tests/test_semantic_cache_basename_recovery.py new file mode 100644 index 0000000000..8aa204415b --- /dev/null +++ b/tests/test_semantic_cache_basename_recovery.py @@ -0,0 +1,30 @@ +"""#2973 — save_semantic_cache must recover a group whose reported +source_file never resolves to a real file, by an unambiguous basename match +against the already dispatched allowlist, instead of silently dropping it. + +A weak or local backend's adaptive-retry split path (llm.py bisecting a +chunk that overflowed context and retrying) sometimes re-prompts with a +reduced file subset and loses track of which of the original chunk's files +a given node came from, so its source_file drifts to something that never +resolves at all. Without recovery this silently discarded the group's nodes +and edges from the cache on every incremental run. +""" +from __future__ import annotations + +import pytest + +from graphify.cache import load_cached, save_semantic_cache + + +def test_malformed_but_basename_unique_path_recovers(tmp_path): + real = tmp_path / "sub" / "weird_named_file.py" + real.parent.mkdir(parents=True) + real.write_text("def f(): pass\n") + + nodes = [{"id": "n1", "label": "f", "source_file": "lost_dir/weird_named_file.py"}] + saved = save_semantic_cache(nodes, [], root=tmp_path, allowed_source_files=[real]) + assert saved == 1 + + cached = load_cached(real, root=tmp_path, kind="semantic") + assert cached is not None + assert {n["id"] for n in cached["nodes"]} == {"n1"} From f59acf7c36584c9af680d848a19450bfeec69a51 Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Mon, 14 Sep 2026 16:08:15 +0530 Subject: [PATCH 4/7] Add regression test for an ambiguous basename staying skipped Two allowed files sharing a basename must not let recovery guess between them: an unresolvable source_file with an ambiguous basename still falls through to the previous skip behavior unchanged. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh --- tests/test_semantic_cache_basename_recovery.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/test_semantic_cache_basename_recovery.py b/tests/test_semantic_cache_basename_recovery.py index 8aa204415b..f498e095d3 100644 --- a/tests/test_semantic_cache_basename_recovery.py +++ b/tests/test_semantic_cache_basename_recovery.py @@ -28,3 +28,19 @@ def test_malformed_but_basename_unique_path_recovers(tmp_path): cached = load_cached(real, root=tmp_path, kind="semantic") assert cached is not None assert {n["id"] for n in cached["nodes"]} == {"n1"} + + +def test_ambiguous_basename_stays_skipped(tmp_path): + a = tmp_path / "pkg_a" / "shared.py" + b = tmp_path / "pkg_b" / "shared.py" + a.parent.mkdir(parents=True) + b.parent.mkdir(parents=True) + a.write_text("def f(): pass\n") + b.write_text("def g(): pass\n") + + nodes = [{"id": "n1", "label": "f", "source_file": "lost_dir/shared.py"}] + with pytest.warns(RuntimeWarning, match="do not resolve to real files"): + saved = save_semantic_cache(nodes, [], root=tmp_path, allowed_source_files=[a, b]) + assert saved == 0 + assert load_cached(a, root=tmp_path, kind="semantic") is None + assert load_cached(b, root=tmp_path, kind="semantic") is None From 12999a80f6e288b2c780f6c34f0a4f797c0e6753 Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Mon, 14 Sep 2026 16:08:29 +0530 Subject: [PATCH 5/7] Add regression test for unscoped callers with no allowlist Confirms recovery never runs when allowed_source_files is omitted: a genuinely bogus path is skipped exactly as before this fix, and a normal well formed path in the same batch still saves and loads back. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh --- .../test_semantic_cache_basename_recovery.py | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/test_semantic_cache_basename_recovery.py b/tests/test_semantic_cache_basename_recovery.py index f498e095d3..62fea22364 100644 --- a/tests/test_semantic_cache_basename_recovery.py +++ b/tests/test_semantic_cache_basename_recovery.py @@ -44,3 +44,23 @@ def test_ambiguous_basename_stays_skipped(tmp_path): assert saved == 0 assert load_cached(a, root=tmp_path, kind="semantic") is None assert load_cached(b, root=tmp_path, kind="semantic") is None + + +def test_unscoped_call_with_no_allowlist_is_unaffected(tmp_path): + # No allowed_source_files at all: recovery must never run, so a + # genuinely bogus path is skipped exactly as before this fix, and a + # normal well formed path still resolves and saves. + real = tmp_path / "sub" / "weird_named_file.py" + real.parent.mkdir(parents=True) + real.write_text("def f(): pass\n") + + nodes = [ + {"id": "n1", "label": "f", "source_file": "sub/weird_named_file.py"}, + {"id": "n2", "label": "g", "source_file": "totally/does/not/exist.py"}, + ] + saved = save_semantic_cache(nodes, [], root=tmp_path) + assert saved == 1 + + cached = load_cached(real, root=tmp_path, kind="semantic") + assert cached is not None + assert {n["id"] for n in cached["nodes"]} == {"n1"} From 8a23b305fe2dbfae630a4b948125578029b606da Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Mon, 14 Sep 2026 16:09:06 +0530 Subject: [PATCH 6/7] Add regression test for dangling edge pruning consistency group_skipped feeds the dangling reference pruning pass and the write loop must agree with it on whether a recovered group is written or skipped. This proves an edge between two nodes inside the same recovered group survives instead of being pruned as referencing a supposedly skipped id. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh --- .../test_semantic_cache_basename_recovery.py | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/test_semantic_cache_basename_recovery.py b/tests/test_semantic_cache_basename_recovery.py index 62fea22364..7ee0c1b8fa 100644 --- a/tests/test_semantic_cache_basename_recovery.py +++ b/tests/test_semantic_cache_basename_recovery.py @@ -30,6 +30,31 @@ def test_malformed_but_basename_unique_path_recovers(tmp_path): assert {n["id"] for n in cached["nodes"]} == {"n1"} +def test_recovered_group_edges_are_not_pruned_as_dangling(tmp_path): + # group_skipped (used by the dangling-reference pruning pass) and the + # write loop must agree a recovered group is WRITTEN, not skipped -- + # otherwise an edge between two nodes in that same recovered group would + # be wrongly pruned as referencing a "skipped" id. + real = tmp_path / "sub" / "weird_named_file.py" + real.parent.mkdir(parents=True) + real.write_text("def f(): pass\ndef g(): pass\n") + + nodes = [ + {"id": "n1", "label": "f", "source_file": "lost_dir/weird_named_file.py"}, + {"id": "n2", "label": "g", "source_file": "lost_dir/weird_named_file.py"}, + ] + edges = [ + {"source": "n1", "target": "n2", "relation": "calls", + "source_file": "lost_dir/weird_named_file.py"}, + ] + saved = save_semantic_cache(nodes, edges, root=tmp_path, allowed_source_files=[real]) + assert saved == 1 + + cached = load_cached(real, root=tmp_path, kind="semantic") + assert cached is not None + assert len(cached["edges"]) == 1 + + def test_ambiguous_basename_stays_skipped(tmp_path): a = tmp_path / "pkg_a" / "shared.py" b = tmp_path / "pkg_b" / "shared.py" From 20453d885ad321e5fd00be06655e023094728fb6 Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Mon, 14 Sep 2026 16:09:06 +0530 Subject: [PATCH 7/7] Add changelog entry for issue 2973 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index cb65991657..c9dcc22c25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu ## 0.9.61 (unreleased) +- Fix: `save_semantic_cache` now recovers a group whose reported `source_file` never resolves to a real file, by an unambiguous basename match against the already dispatched allowlist, instead of silently dropping it — an incremental run driven by a weak or local backend no longer redoes already processed chunks forever when the adaptive-retry split path loses track of a source file (#2973, thanks @ayushcodes10). - Fix: `graphify.serve` now imports cleanly on Python 3.12 and 3.13. The `chinese` extra pins `jieba-py` from 3.12 onward (0.9.60 mistakenly kept the old `jieba` until 3.14, and its invalid regex escapes are a hard error on 3.12+), and the jieba import now suppresses the tokenizer's `SyntaxWarning` regardless of message or line so it never escalates under `-W error`. - Fix: the git hook's rebuild-root guard now rejects a symlink-loop or dangling `.graphify_root` on Python 3.13, whose `Path.resolve()` no longer raises on a loop — the saved root must resolve to a real directory inside the repo before it is adopted.