Skip to content

Commit c1c891b

Browse files
jgravelleclaude
andcommitted
release: v1.108.193 - the size cap can be raised, and a withheld file cannot prove absence
Both reported by @dkiaulakis, measured on 1.108.188. Every line he cited verified before the fix: 5 for 5. The size cap was the one limit of three with no way to move it. Its neighbours each had a resolver reading config; it had none, appeared in none of the 79 JCODEMUNCH_* variables, was not among index_folder()'s 11 parameters, and was passed from line 1490 as a hardcoded constant. New security.get_max_file_size(), same shape as its siblings. The default stays at 500KB - the ask was an escape hatch, not a higher default. The finding underneath the ask is the larger half. His sentence: "Size is the one exclusion reason where the file is real, current, and wanted." Worse than reported. too_large is refused during discovery, so the file never reaches files_accepted, and the 1.108.176 completeness reconciliation balances perfectly because the refused file is outside both numbers. Coverage reported complete: true over a corpus missing a real, current, wanted source file. The contract was answering a question about the walk while presenting the answer as being about the tree. Exclusions now split: excluded (the corpus being defined - binary, gitignore, wrong_extension, never candidates, must still permit absence) versus withheld (real source refused by our own limits - too_large, file_limit, unreadable). Withheld makes complete false and refuses absence, with a refusal that names the cause and the remedy rather than the generic partial message. Existing indexes will start reporting complete: false after a re-index if they withheld anything. That is the contract reporting something true it was previously hiding. test_watcher_memory_cache.py corrected: it set a low cap by patching DEFAULT_MAX_FILE_SIZE, which the resolver no longer consults, so the patch silently did nothing and the fast path indexed an oversize file. It now sets the config value. The failure proved the fast path honours the new route end to end. Tests: 6204 passed, 7 skipped (excl. the known 12 local-ONNX env failures). New tests/test_v1_108_193.py (15). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 11be8c1 commit c1c891b

10 files changed

Lines changed: 340 additions & 12 deletions

File tree

CHANGELOG.md

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

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

5+
## [1.108.193] - 2026-07-27 - the size cap can be raised, and a withheld file cannot prove absence
6+
7+
Both reported by @dkiaulakis, measured on 1.108.188. The ask was small. The
8+
finding underneath it was not.
9+
10+
### Added
11+
12+
- **`JCODEMUNCH_MAX_FILE_SIZE` / `max_file_size`.** The per-file size cap was
13+
the one limit of three with no way to move it. Its neighbours
14+
`DEFAULT_MAX_INDEX_FILES` and `DEFAULT_MAX_FOLDER_FILES` each had a resolver
15+
reading config; the size cap had none, appeared in none of the 79
16+
`JCODEMUNCH_*` variables, was not among `index_folder()`'s 11 parameters, and
17+
was passed from `index_folder.py:1490` as a hardcoded constant. All five
18+
points verified before the fix.
19+
20+
New `security.get_max_file_size()`, same shape as its siblings.
21+
⚠ **The default is unchanged at 500KB.** The ask was an escape hatch, not a
22+
higher default, and 500KB still protects the common case from a parse worth
23+
less than it costs.
24+
25+
### Fixed
26+
27+
- ⚠ **A file refused for size no longer lets the corpus call itself complete.**
28+
His sentence, which is the actual finding: *"Size is the one exclusion reason
29+
where the file is real, current, and wanted."*
30+
31+
Worse than reported. `too_large` is refused during **discovery**, so the file
32+
never reaches `files_accepted`, and the 1.108.176 completeness reconciliation
33+
balances perfectly because it compares what the walk accepted against what got
34+
indexed — the refused file is outside **both** numbers. Coverage reported
35+
`complete: true` over a corpus missing a real, current, wanted source file.
36+
The contract was answering a question about the walk while presenting the
37+
answer as being about the tree.
38+
39+
Exclusions now split in two:
40+
41+
- **excluded** — the corpus being defined. A `.png` is `binary`, a vendored
42+
tree is `gitignore`, a lockfile is `wrong_extension`. Never candidates. A
43+
search over this can still prove absence, and **must**, or no real
44+
repository could prove anything.
45+
- **withheld** — real source, current, wanted, refused by one of *our* limits:
46+
`too_large`, `file_limit`, `unreadable`. Any of these makes `complete` false,
47+
and a zero-result over such a corpus cannot prove absence.
48+
49+
The refusal names the cause and the remedy, separately from the generic
50+
`partial` message, because they send a reader to different places: "the index
51+
did not cover the whole tree" sends them to inspect their repo, while naming
52+
`too_large` sends them to a setting.
53+
54+
⚠ **Existing indexes will start reporting `complete: false` after a re-index
55+
if they withheld anything, and affected absence claims degrade.** That is the
56+
contract reporting something true it was previously hiding, not a regression.
57+
58+
### Tests
59+
60+
- New `tests/test_v1_108_193.py` (15), including the boundary that keeps the
61+
signal useful: structural exclusions alone must still permit absence.
62+
- ⚠ `tests/test_watcher_memory_cache.py` corrected. It set a low cap by patching
63+
`DEFAULT_MAX_FILE_SIZE`, which the resolver no longer consults, so the patch
64+
silently did nothing and the watcher fast path indexed an oversize file. It
65+
now sets the config value, which exercises the override a user actually has —
66+
a stronger test than before, and the failure is what proved the fast path
67+
honours the new route end to end.
68+
69+
No tool-count, INDEX_VERSION or schema change.
70+
571
## [1.108.192] - 2026-07-27 - #377 P3: one immutable snapshot, start to finish
672

773
The two edges @mightydanp pinned adversarially on #377 after reviewing

CLAUDE.md

Lines changed: 4 additions & 3 deletions
Large diffs are not rendered by default.

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.192"
3+
version = "1.108.193"
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/config.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
"JCODEMUNCH_USE_AI_SUMMARIES": "use_ai_summaries",
2424
"JCODEMUNCH_TRUSTED_FOLDERS": "trusted_folders",
2525
"JCODEMUNCH_TRUSTED_FOLDERS_WHITELIST_MODE": "trusted_folders_whitelist_mode",
26+
"JCODEMUNCH_MAX_FILE_SIZE": "max_file_size",
2627
"JCODEMUNCH_MAX_FOLDER_FILES": "max_folder_files",
2728
"JCODEMUNCH_MAX_INDEX_FILES": "max_index_files",
2829
"JCODEMUNCH_STALENESS_DAYS": "staleness_days",
@@ -325,6 +326,7 @@ def apply_adaptive_languages(source_root: str, detected: set[str]) -> bool:
325326
"use_ai_summaries": "auto",
326327
"trusted_folders": [],
327328
"trusted_folders_whitelist_mode": True,
329+
"max_file_size": 512000,
328330
"max_folder_files": 2000,
329331
"max_index_files": 10000,
330332
"staleness_days": 7,
@@ -480,6 +482,7 @@ def apply_adaptive_languages(source_root: str, detected: set[str]) -> bool:
480482
"use_ai_summaries": (bool, str),
481483
"trusted_folders": list,
482484
"trusted_folders_whitelist_mode": bool,
485+
"max_file_size": int,
483486
"max_folder_files": int,
484487
"max_index_files": int,
485488
"staleness_days": int,
@@ -1921,6 +1924,13 @@ def generate_template() -> str:
19211924
// true = only trust folders in trusted_folders list (default, secure).
19221925
// false = trust all folders EXCEPT those in trusted_folders (blocklist mode).
19231926
1927+
// "max_file_size": 512000,
1928+
// Per-file byte cap for indexing. A file larger than this is skipped with
1929+
// reason `too_large`, and a search over a corpus that skipped one cannot
1930+
// prove absence -- the file is real, current and wanted, it just never
1931+
// entered the index. The default is deliberately conservative; raise it if
1932+
// your repo has large legitimate source files, and re-index.
1933+
19241934
// "max_folder_files": 2000,
19251935
// Maximum number of files to index when indexing a local folder.
19261936
// Prevents accidental massive indexing jobs. Monorepos often exceed this;

src/jcodemunch_mcp/handoff.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,20 @@ def absence_refusal(record: Optional[dict]) -> Optional[str]:
145145
"the index did not cover the whole tree at scan time, so the target "
146146
"may sit in a file that never entered the corpus"
147147
)
148+
_withheld = (record.get("coverage") or {}).get("withheld") or {}
149+
if _withheld:
150+
# v1.108.193. Named BEFORE the generic state rule, and separately from
151+
# `partial`, because the cause is ours and the remedy is a setting.
152+
# `partial` says the corpus has a gap; this says WE made it, by refusing
153+
# a file that is real, current and wanted. A reader who is told "the
154+
# index did not cover the whole tree" goes looking at their repo; a
155+
# reader told a file was too large goes and raises the limit.
156+
_detail = ", ".join(f"{n} {reason}" for reason, n in sorted(_withheld.items()))
157+
return (
158+
f"the index withheld files that belong to this corpus ({_detail}), so "
159+
"the target may sit in a real, current file that our own limits kept "
160+
"out; raise the relevant limit and re-index before claiming absence"
161+
)
148162
_tree = record.get("working_tree") or {}
149163
if _tree.get("files_not_in_index"):
150164
# Named before the generic state rule (#377 item 5). Work OUTSIDE the

src/jcodemunch_mcp/retrieval/verdict.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,13 @@ def index_coverage_meta(index) -> Optional[dict]:
9595
out["dropped_after_discovery"] = cov["dropped_after_discovery"]
9696
if cov.get("unaccounted"):
9797
out["unaccounted"] = cov["unaccounted"]
98+
# v1.108.193: surfaced separately from `excluded` because it answers a
99+
# different question. `excluded` says what this corpus is not about; a
100+
# binary or a gitignored tree was never a candidate. `withheld` says a real,
101+
# current, wanted source file was refused by one of OUR limits, which is the
102+
# one exclusion reason a reader must not read as "the file is not there".
103+
if cov.get("withheld"):
104+
out["withheld"] = cov["withheld"]
98105
return out
99106

100107

src/jcodemunch_mcp/security.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,42 @@ def get_extra_ignore_patterns(
357357
DEFAULT_MAX_FOLDER_FILES = 2_000
358358
MAX_FOLDER_FILES_ENV_VAR = "JCODEMUNCH_MAX_FOLDER_FILES"
359359

360+
# The size cap was the only one of these three with no way to move it
361+
# (reported by @dkiaulakis, v1.108.193). Its two neighbours each had a
362+
# resolver reading config; this one was passed as a hardcoded constant from
363+
# index_folder.py and appeared in none of the 79 JCODEMUNCH_* variables.
364+
#
365+
# The default is NOT raised. 500KB stays, because it protects the common case
366+
# from a parse that costs more than the file is worth. What changes is that a
367+
# caller who knows their own corpus can move it, the same way they can already
368+
# move both file-count limits three lines up.
369+
MAX_FILE_SIZE_ENV_VAR = "JCODEMUNCH_MAX_FILE_SIZE"
370+
371+
372+
def get_max_file_size(max_size: Optional[int] = None) -> int:
373+
"""Resolve the per-file byte cap from arg or config.
374+
375+
Parity with ``get_max_index_files`` / ``get_max_folder_files``, which is the
376+
whole point: the size cap was the one limit of the three that could not be
377+
moved by any route (v1.108.193).
378+
379+
Args:
380+
max_size: Explicit override. Must be a positive integer when provided.
381+
382+
Returns:
383+
Positive byte limit. Falls back to the default if config is unset or
384+
invalid, matching the siblings rather than failing the index.
385+
"""
386+
if max_size is not None:
387+
if max_size <= 0:
388+
raise ValueError("max_size must be a positive integer")
389+
return max_size
390+
391+
value = _config.get("max_file_size", DEFAULT_MAX_FILE_SIZE)
392+
if isinstance(value, int) and value > 0:
393+
return value
394+
return DEFAULT_MAX_FILE_SIZE
395+
360396

361397
def get_max_index_files(max_files: Optional[int] = None) -> int:
362398
"""Resolve the maximum indexed file count from arg or config.

src/jcodemunch_mcp/tools/index_folder.py

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
is_secret_file,
3030
is_binary_file,
3131
DEFAULT_MAX_FILE_SIZE,
32+
get_max_file_size,
3233
get_max_folder_files,
3334
get_extra_ignore_patterns,
3435
get_skip_directories,
@@ -87,6 +88,25 @@ def _attach_cap_report(result: dict, cap: Optional[dict]) -> None:
8788
f"JCODEMUNCH_MAX_FOLDER_FILES) and re-index, or narrow the path."
8889
)
8990

91+
#: Skip reasons where the file is REAL, CURRENT and WANTED, and we refused it
92+
#: anyway (v1.108.193, reported by @dkiaulakis). Every other reason describes a
93+
#: file that was never a candidate for this corpus: a `.png` is `binary`, a
94+
#: vendored tree is `gitignore`, a `.lock` is `wrong_extension`. Excluding
95+
#: those is the corpus being defined, and a search over it can still prove
96+
#: absence.
97+
#:
98+
#: These are different in kind. The file is source, it is current, an agent
99+
#: asked about it by name, and it is missing because of OUR limit rather than
100+
#: the caller's intent. A zero-result over a corpus that withheld one cannot
101+
#: prove absence: "I never learned that file" and "that file does not exist"
102+
#: are exactly the two things this whole contract exists to keep apart.
103+
WITHHELD_SKIP_REASONS = frozenset({
104+
"too_large", # over max_file_size, which until v1.108.193 could not be raised
105+
"file_limit", # over max_folder_files / max_index_files
106+
"unreadable", # a permission or IO failure, NOT a statement about the file
107+
})
108+
109+
90110
def _coverage_report(
91111
skip_counts: dict, files_indexed: int, no_symbols_count: int,
92112
files_accepted: Optional[int] = None,
@@ -148,7 +168,16 @@ def _coverage_report(
148168
unaccounted = int(files_accepted) - int(files_indexed) - sum(drops.values())
149169
if unaccounted > 0:
150170
report["unaccounted"] = unaccounted
151-
report["complete"] = (int(files_indexed) == int(files_accepted)) and not drops
171+
# v1.108.193: withheld files never reach `files_accepted` (they are refused
172+
# during discovery), so the reconciliation below balances perfectly while a
173+
# real, wanted source file sits outside the corpus. Counting them keeps
174+
# `complete` honest about the tree rather than only about the walk.
175+
withheld = {k: v for k, v in skips.items() if k in WITHHELD_SKIP_REASONS}
176+
if withheld:
177+
report["withheld"] = withheld
178+
report["complete"] = (
179+
int(files_indexed) == int(files_accepted) and not drops and not withheld
180+
)
152181
return report
153182

154183

@@ -1487,7 +1516,7 @@ def _run_deferred_summarize(
14871516
_fast_filter_cfg = _build_index_filters(
14881517
root=folder_path.resolve(),
14891518
follow_symlinks=follow_symlinks,
1490-
max_size=DEFAULT_MAX_FILE_SIZE,
1519+
max_size=get_max_file_size(),
14911520
extra_spec=_fast_extra_spec,
14921521
forced_paths=set(),
14931522
skip_dirs_regex=_build_skip_dirs_regex(),

0 commit comments

Comments
 (0)