Skip to content

Commit 23346c8

Browse files
jgravelleclaude
andcommitted
release: v1.108.170 — the file and symbol tools can see a rebuild too
Third and last instance of the class fixed this week. v1.108.168 added the 5th absence-refusal rule to build_verdict; v1.108.169 wired the last search tool into it. But the file and symbol tools never call build_verdict. get_file_content / get_file_outline / get_symbol_source route through symbol_verdict_for_index / file_verdict_for_index, which accept a live index and then delegate to build_symbol_verdict / build_file_verdict — neither of which had an index_changed parameter at all. Both reach state:"absent", and the absence chokepoint is generic (it fires on any _meta.verdict dict), so both were minting citable absent:<sha> refs with the rebuilding rule structurally unable to fire. A rebuild deletes and reinserts rows, so a genuinely-present file reads as missing for the duration. This is the likeliest way to observe a false absence, not the rarest. Both builders now take index_changed and downgrade an absence to degraded + channels.index:"rebuilding"; both wrappers pass index_changed_since_load. Two judgment calls: did_you_mean is suppressed on the degraded path, because offering near-miss names for a scan that could not see the index is worse than saying nothing; and the empty_symbols case degrades too, because its note claims "re-requesting the outline will not change this", which is actively wrong mid-rewrite. New tests/test_absence_wiring_guard.py (7) encodes the invariant rather than the instances: every build_verdict call in tools/ must pass index_changed=, the scan is alias-aware (each tool imports it renamed) and fails if it ever finds no calls, and the index-aware wrappers sit in a ratchet that fails when a listed wrapper turns out to be wired. That set is now empty and kept as the declaration point for any future gap. Known limit: the guard covers build_verdict call sites and the two wrappers. A future tool that invents a third way to emit _meta.verdict would still be served by the chokepoint unchecked; enforcing at the chokepoint itself is the larger fix. No schema, tool-count, or INDEX_VERSION change. Suite 5680 passed (+ the known 12 local-ONNX semantic env failures, green in CI). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent f28b3cb commit 23346c8

6 files changed

Lines changed: 254 additions & 12 deletions

File tree

CHANGELOG.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,47 @@
22

33
All notable changes to jcodemunch-mcp are documented here.
44

5+
## [1.108.170] - 2026-07-25 - the file and symbol tools can see a rebuild too
6+
7+
### Fixed
8+
9+
- **`get_file_content`, `get_file_outline` and `get_symbol_source` could mint
10+
absence evidence over an index being rewritten.** v1.108.168 added the 5th
11+
refusal rule to `build_verdict`, and v1.108.169 wired the last search tool
12+
into it — but the file and symbol tools do not use `build_verdict`. They go
13+
through `symbol_verdict_for_index` / `file_verdict_for_index`, which accept a
14+
live index and then delegate to `build_symbol_verdict` / `build_file_verdict`,
15+
neither of which had an `index_changed` parameter at all.
16+
17+
Both reach `state: "absent"` (a missing symbol, a missing path), and the
18+
absence chokepoint in `server.py` is generic — it fires on any
19+
`_meta.verdict` dict — so both were minting citable `absent:<sha>` refs with
20+
the rebuilding rule structurally unable to fire. A rebuild deletes and
21+
reinserts rows, so a genuinely-present file reads as missing for the
22+
duration; this is the likeliest way to observe a false absence, not the
23+
rarest.
24+
25+
Both builders now take `index_changed` and downgrade an absence to
26+
`degraded` + `channels.index: "rebuilding"`, and both wrappers pass
27+
`index_changed_since_load(index)`. `did_you_mean` is suppressed on the
28+
degraded path — suggesting near-miss names for a scan that could not see the
29+
index is worse than saying nothing.
30+
31+
The `empty_symbols` case is degraded too: its note tells the agent that
32+
"re-requesting the outline will not change this", which is actively wrong
33+
mid-rewrite.
34+
35+
### Notes
36+
37+
- New `tests/test_absence_wiring_guard.py` (7) encodes the invariant rather
38+
than the instances: every `build_verdict` call in `tools/` must pass
39+
`index_changed=`, the scan is alias-aware (each tool imports it renamed) and
40+
fails if it ever finds no calls, and the index-aware wrappers are held in a
41+
ratchet that fails when a listed wrapper turns out to be wired. That set is
42+
now empty and kept as the declaration point for any future gap.
43+
- No schema, tool-count, or INDEX_VERSION change. `"rebuilding"` was already
44+
published in `schemas/retrieval-verdict.schema.json` by v1.108.168.
45+
546
## [1.108.169] - 2026-07-25 - the retrieval verdict survives compaction
647

748
### Fixed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,9 +129,9 @@ is a byte the agent doesn't pay to read.
129129
<!-- WHATSNEW:START -->
130130
#### What's new
131131

132+
- **[v1.108.170](https://github.com/jgravelle/jcodemunch-mcp/releases/tag/v1.108.170)** (2026-07-25) — the file and symbol tools can see a rebuild too
132133
- **[v1.108.169](https://github.com/jgravelle/jcodemunch-mcp/releases/tag/v1.108.169)** (2026-07-25) — the retrieval verdict survives compaction
133134
- **[v1.108.168](https://github.com/jgravelle/jcodemunch-mcp/releases/tag/v1.108.168)** (2026-07-24) — a rebuild underneath a scan cannot prove absence (5th refusal rule)
134-
- **[v1.108.167](https://github.com/jgravelle/jcodemunch-mcp/releases/tag/v1.108.167)** (2026-07-24) — cue-anchored delivery ledger: measure what we hand over twice
135135
<!-- WHATSNEW:END -->
136136

137137
![License](https://img.shields.io/badge/license-dual--use-blue)

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "jcodemunch-mcp"
3-
version = "1.108.169"
3+
version = "1.108.170"
44
description = "Token-efficient MCP server for source code exploration via tree-sitter AST parsing"
55
readme = "README.md"
66
requires-python = ">=3.10"

src/jcodemunch_mcp/retrieval/verdict.py

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -343,6 +343,7 @@ def build_file_verdict(
343343
source_files: Optional[Sequence[str]] = None,
344344
index_stale: bool = False,
345345
empty_symbols: bool = False,
346+
index_changed: bool = False,
346347
) -> dict:
347348
"""`_meta.verdict` for the file-read tools.
348349
@@ -369,9 +370,21 @@ def build_file_verdict(
369370
state = STATE_OK
370371
note = _NOTES[STATE_OK]
371372
suggestions = []
373+
if index_changed and state == STATE_ABSENT:
374+
# #93 class: a rebuild deletes and reinserts rows, so a present file
375+
# can read as missing — and an indexed file can transiently expose no
376+
# symbols — for the duration. Neither absence is provable here, and the
377+
# empty_symbols note ("re-requesting will not change this") would be
378+
# actively wrong mid-rewrite.
379+
state = STATE_DEGRADED
380+
note = _NOTES[STATE_DEGRADED]
381+
suggestions = []
372382
verdict = {
373383
"state": state,
374-
"channels": {"index": "stale" if index_stale else "fresh"},
384+
"channels": {
385+
"index": "rebuilding" if index_changed
386+
else ("stale" if index_stale else "fresh")
387+
},
375388
"note": note,
376389
}
377390
if suggestions:
@@ -391,6 +404,7 @@ def symbol_verdict_for_index(
391404
requested_id=requested_id,
392405
symbols=getattr(index, "symbols", None) if found_count == 0 else None,
393406
index_stale=_index_is_stale(index),
407+
index_changed=index_changed_since_load(index),
394408
)
395409
_attach_coverage(verdict, index_coverage_meta(index))
396410
return verdict
@@ -464,6 +478,7 @@ def file_verdict_for_index(
464478
source_files=_index_source_files(index) if not present else None,
465479
index_stale=_index_is_stale(index),
466480
empty_symbols=empty_symbols,
481+
index_changed=index_changed_since_load(index),
467482
)
468483
_attach_coverage(verdict, index_coverage_meta(index))
469484
return verdict
@@ -475,14 +490,22 @@ def build_symbol_verdict(
475490
requested_id: Optional[str] = None,
476491
symbols: Optional[Sequence[dict]] = None,
477492
index_stale: bool = False,
493+
index_changed: bool = False,
478494
) -> dict:
479495
"""`_meta.verdict` for ``get_symbol_source``.
480496
481497
``found_count == 0`` yields ``absent`` plus ``did_you_mean`` symbol ids that
482498
share the requested name; any resolved symbol yields ``ok`` (a partial batch
483499
is still a hit).
484500
"""
485-
if found_count == 0:
501+
if found_count == 0 and index_changed:
502+
# jdoc/jcm #93 class: a rebuild deletes and reinserts rows, so a
503+
# genuinely-present symbol reads as missing for the duration. Absence
504+
# is not provable against an index being rewritten.
505+
state = STATE_DEGRADED
506+
note = _NOTES[STATE_DEGRADED]
507+
suggestions = []
508+
elif found_count == 0:
486509
state = STATE_ABSENT
487510
note = "Symbol id is not in the index. " + _NOTES[STATE_ABSENT]
488511
suggestions = suggest_symbol_ids(requested_id, symbols)
@@ -492,7 +515,10 @@ def build_symbol_verdict(
492515
suggestions = []
493516
verdict = {
494517
"state": state,
495-
"channels": {"index": "stale" if index_stale else "fresh"},
518+
"channels": {
519+
"index": "rebuilding" if index_changed
520+
else ("stale" if index_stale else "fresh")
521+
},
496522
"note": note,
497523
}
498524
if suggestions:

tests/test_absence_wiring_guard.py

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
"""Recurrence guard: every verdict that can prove absence must be able to see a rebuild.
2+
3+
The absence chokepoint in ``server.py`` is deliberately **generic** — it fires on
4+
any ``_meta.verdict`` dict and mints a citable ``absent:<sha>`` ref whenever the
5+
state is ``absent``. That generality is what makes the contract cheap to extend,
6+
and it is also what makes it easy to break: a tool only has to *emit* a verdict
7+
to start minting absence proofs, whether or not anyone wired it for the v1.108.168
8+
rebuilding rule.
9+
10+
That has now gone wrong twice for the same structural reason:
11+
12+
* v1.108.168 wired ``search_symbols`` and ``get_ranked_context`` and missed
13+
``search_text`` (fixed in v1.108.169).
14+
* The index-aware wrappers ``symbol_verdict_for_index`` / ``file_verdict_for_index``
15+
accept an index but delegate to builders that have no ``index_changed`` parameter
16+
at all, so the file/symbol tools have never been able to refuse an absence claim
17+
over an index being rewritten.
18+
19+
These tests encode the invariant itself rather than re-checking the instances, so
20+
the *next* verdict-emitting tool cannot quietly join the list.
21+
"""
22+
23+
import ast
24+
import pathlib
25+
26+
import pytest
27+
28+
SRC = pathlib.Path(__file__).resolve().parents[1] / "src" / "jcodemunch_mcp"
29+
TOOLS = SRC / "tools"
30+
VERDICT_MODULE = SRC / "retrieval" / "verdict.py"
31+
32+
# Verdict builders that take a live index and can therefore, in principle,
33+
# re-stat it to detect a rebuild underneath the scan.
34+
INDEX_AWARE_WRAPPERS = {"symbol_verdict_for_index", "file_verdict_for_index"}
35+
36+
# KNOWN GAP — a ratchet, not an exemption. CLOSED in v1.108.170.
37+
#
38+
# Both wrappers now thread `index_changed` into their builders, so the file and
39+
# symbol tools refuse an absence claim over an index being rewritten just as the
40+
# search tools do. The set is kept (empty) rather than deleted: it is the
41+
# mechanism that forces a future gap to be declared here instead of shipping
42+
# silently, and `test_index_aware_wrapper_gap_does_not_widen` fails if a listed
43+
# wrapper turns out to be wired — so a stale entry can never rot into an
44+
# exemption.
45+
KNOWN_UNWIRED_WRAPPERS: set[str] = set()
46+
47+
48+
def _call_name(node: ast.Call) -> str:
49+
f = node.func
50+
if isinstance(f, ast.Name):
51+
return f.id
52+
if isinstance(f, ast.Attribute):
53+
return f.attr
54+
return ""
55+
56+
57+
def _calls_in(path: pathlib.Path):
58+
tree = ast.parse(path.read_text(encoding="utf-8"))
59+
for node in ast.walk(tree):
60+
if isinstance(node, ast.Call):
61+
yield node
62+
63+
64+
def _aliases_for(path: pathlib.Path, target: str) -> set[str]:
65+
"""Local names bound to ``target`` in this module.
66+
67+
Every tool imports it lazily and renames it
68+
(``from ..retrieval.verdict import build_verdict as _build_verdict``), so a
69+
plain name match finds nothing and the scan passes vacuously. That is the
70+
exact failure `test_the_guard_is_not_vacuous` exists to catch.
71+
"""
72+
names = {target}
73+
tree = ast.parse(path.read_text(encoding="utf-8"))
74+
for node in ast.walk(tree):
75+
if isinstance(node, ast.ImportFrom):
76+
for a in node.names:
77+
if a.name == target:
78+
names.add(a.asname or a.name)
79+
return names
80+
81+
82+
def _verdict_calls(path: pathlib.Path):
83+
names = _aliases_for(path, "build_verdict")
84+
for call in _calls_in(path):
85+
if _call_name(call) in names:
86+
yield call
87+
88+
89+
def _has_kwarg(node: ast.Call, name: str) -> bool:
90+
return any(kw.arg == name for kw in node.keywords)
91+
92+
93+
def _tool_modules():
94+
return sorted(p for p in TOOLS.glob("*.py") if p.name != "__init__.py")
95+
96+
97+
def test_every_build_verdict_call_passes_index_changed():
98+
"""A tool that builds a verdict must say whether the index moved under it.
99+
100+
Without this, a zero-result scan reaches `absent` and the generic chokepoint
101+
mints a citable ref, with the 5th refusal rule structurally unable to fire.
102+
"""
103+
offenders = []
104+
for path in _tool_modules():
105+
for call in _verdict_calls(path):
106+
if not _has_kwarg(call, "index_changed"):
107+
offenders.append(f"{path.name}:{call.lineno}")
108+
assert not offenders, (
109+
"build_verdict() called without index_changed= at: "
110+
+ ", ".join(offenders)
111+
+ ". These can mint absence evidence over an index being rewritten. "
112+
"Pass index_changed=index_changed_since_load(index)."
113+
)
114+
115+
116+
def test_build_verdict_still_accepts_index_changed():
117+
"""Guard the guard: if the parameter is renamed, the test above goes vacuous."""
118+
import inspect
119+
120+
from jcodemunch_mcp.retrieval.verdict import build_verdict
121+
122+
assert "index_changed" in inspect.signature(build_verdict).parameters
123+
124+
125+
def test_the_guard_is_not_vacuous():
126+
"""At least one real build_verdict call must exist, or the scan proves nothing."""
127+
found = sum(1 for path in _tool_modules() for _ in _verdict_calls(path))
128+
assert found >= 3, f"expected the known verdict-emitting tools, found {found}"
129+
130+
131+
@pytest.mark.parametrize("wrapper", sorted(INDEX_AWARE_WRAPPERS))
132+
def test_index_aware_wrapper_gap_does_not_widen(wrapper):
133+
"""The wrappers take an index; each must either use it or be a tracked gap."""
134+
tree = ast.parse(VERDICT_MODULE.read_text(encoding="utf-8"))
135+
fn = next(
136+
(
137+
n
138+
for n in ast.walk(tree)
139+
if isinstance(n, ast.FunctionDef) and n.name == wrapper
140+
),
141+
None,
142+
)
143+
assert fn is not None, f"{wrapper} not found — update this guard"
144+
145+
wired = any(
146+
_has_kwarg(call, "index_changed")
147+
for call in ast.walk(fn)
148+
if isinstance(call, ast.Call)
149+
)
150+
if wrapper in KNOWN_UNWIRED_WRAPPERS:
151+
assert not wired, (
152+
f"{wrapper} now passes index_changed — remove it from "
153+
"KNOWN_UNWIRED_WRAPPERS so the ratchet tightens."
154+
)
155+
else:
156+
assert wired, (
157+
f"{wrapper} takes an index but never passes index_changed, so the "
158+
"tools using it can mint absence evidence over a rebuilding index."
159+
)
160+
161+
162+
def test_known_gap_set_is_a_subset_of_real_wrappers():
163+
"""A stale entry would silently exempt a wrapper that no longer exists."""
164+
assert KNOWN_UNWIRED_WRAPPERS <= INDEX_AWARE_WRAPPERS
165+
166+
167+
def test_wrapper_verdicts_really_can_reach_absent():
168+
"""Pins why the gap matters: these states feed note_absence's `absent` gate."""
169+
from jcodemunch_mcp.retrieval.verdict import (
170+
build_file_verdict,
171+
build_symbol_verdict,
172+
)
173+
174+
assert build_symbol_verdict(found_count=0, requested_id="a.py::f")["state"] == "absent"
175+
assert build_file_verdict(present=False, requested_path="a.py")["state"] == "absent"

whatsnew.json

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
{
2-
"current": "1.108.169",
2+
"current": "1.108.170",
33
"entries": [
4+
{
5+
"version": "1.108.170",
6+
"date": "2026-07-25",
7+
"title": "the file and symbol tools can see a rebuild too",
8+
"summary": "- **`get_file_content`, `get_file_outline` and `get_symbol_source` could mint absence evidence over an index being rewritten.** v1.108.168 added the 5th refusal rule to `build_verdict`, and v1.108.169 wired the last search tool into it \u2014 but the file and symbol tools do not use `build_verdict`. They go"
9+
},
410
{
511
"version": "1.108.169",
612
"date": "2026-07-25",
@@ -12,12 +18,6 @@
1218
"date": "2026-07-24",
1319
"title": "a rebuild underneath a scan cannot prove absence (5th refusal rule)",
1420
"summary": "- **Absence evidence could be minted over an index that was being rewritten.** v1.108.166 shipped four refusal rules for absence proofs \u2014 only `absent` proves absence; `low_confidence`/`degraded` do not; a stale index does not; a truncated index does not. None of them covered an index being **rewritten"
15-
},
16-
{
17-
"version": "1.108.167",
18-
"date": "2026-07-24",
19-
"title": "cue-anchored delivery ledger: measure what we hand over twice",
20-
"summary": "- **The session now measures how often it re-delivers a symbol it already bought.** jcm counted byte-identical repeat calls (`note_call_signature` \u2192 `yield.repeated_identical_calls`) and did nothing with the count, and that counter could not see the shape that actually costs: the **same symbol"
2121
}
2222
]
2323
}

0 commit comments

Comments
 (0)