fix(ast-parser): degrade gracefully on tree-sitter grammar DownloadError - #316
Merged
Conversation
mcp_server/core/ast_parser.py::_get_extractor_and_tree already treated a missing tree-sitter-language-pack (ImportError) as a handled degraded mode, but had no handling for a pack that imports fine and then fails to fetch a grammar over the network (DownloadError) -- an offline install, air-gapped environment, proxy, or upstream outage crashed codebase_analyze with an uncaught third-party exception instead of degrading (main-red, CI run 30592244731, 2026-07-31: Test (Python 3.10)). Catches DownloadError specifically around get_parser().parse() (not widened to the pack's broader Error base -- only DownloadError is evidenced), returns the same None degraded-mode signal parse_file_ast already reads to pick the regex fallback, and logs one actionable warning naming the language and reason on every occurrence (not suppressed after the first, since this runs once per file, not once per process like a singleton model load). Three regression tests in test_ast_parser_language_contract.py force the failure deterministically (monkeypatch on the pack's own get_parser, matching the file's existing ImportError-probe convention) and assert the log emission itself, not just crash-absence -- all three verified to fail against the pre-fix code. Makes the test suite hermetic rather than papering over the flake: ci.yml's test and test-sqlite jobs (the two that install the codebase extra) gain a Resolve-cache-dir + Cache + Prefetch step trio for tree-sitter grammars, same cache-then-retry-with-backoff shape as the existing HF-embedding/FlashRank steps, reading the language set from AST_SUPPORTED so it cannot drift from what ast_parser.py actually uses. Also fixes pyproject.toml's [tool.mutmut] also_copy list, discovered while mutation-testing this change: it was missing "scripts", and tests_py/conftest.py -- the root conftest every test collects through -- imports scripts.check_venv_lock_parity at module level, so every scoped mutation run in this repo (not just this one) failed collection before scoring a single mutant. No other unguarded get_parser/get_language call site exists in mcp_server/ (swept repo-wide). Co-Authored-By: Claude <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
mainis red: CI run 30592244731 (Test (Python 3.10), 2026-07-31) failed with anuncaught
tree_sitter_language_pack.DownloadError—tests_py/benchmarks/test_codebase_alteration.py(5 tests) and
tests_py/core/test_ast_extractors.py::test_decorated_function.tree-sitter-language-packis a declared dependency, but it resolves each grammar'sshared library lazily over the network at
get_parser()call time, not at installtime (measured: an already-cached language returns in <50ms with no network attempt;
an uncached one takes a real round trip against
https://github.com/xberg-io/tree-sitter-language-pack/releases).ast_parser.pyalready treated a missing pack (
ImportError) as a handled degraded mode (its owncomments say so), but had no handling for a pack that imports fine and then fails to
fetch a grammar — an offline install, air-gapped environment, proxy, or upstream
outage reached
get_parser(language).parse(content)uncaught, socodebase_analyzeraised a third-party exception type instead of degrading. Same defect class this repo
has already been bitten by twice (FlashRank silent-absence; MCP stdio response-loss,
#284): a degraded path existed and did not cover the failure that actually happens.
Root cause (Move 4)
codebase_analyzecrashed with an uncaughtDownloadErrorinstead ofdegrading, whenever a grammar's shared library couldn't be fetched over the network.
_get_extractor_and_treetreated only "pack not installed"as a handled degraded-mode boundary; it never accounted for the pack's own lazy
network-fetch-at-call-time design, so a network failure inside
get_parser().parse()propagated a third-party exception straight throughparse_file_astto the caller.DownloadErrorspecifically around the exactcall site, returning the same
Nonesentinelparse_file_astalready branches onfor the regex fallback — the postcondition ("never propagates a third-party
exception; always returns a valid degraded result") holds by construction, verified
by 3 regression tests that fail pre-fix / pass post-fix (including the log signal
itself), a scoped mutation run (0 surviving non-equivalent mutants on the changed
function), and a full 7017-test suite green with no regressions.
Reproduction (before the fix)
Uncaught, propagates straight out of
_get_extractor_and_tree/parse_file_ast.Changes
mcp_server/core/ast_parser.py—_get_extractor_and_treecatchesDownloadError(not the pack's broaderErrorbase — onlyDownloadErrorisevidenced) around
get_parser(language).parse(content), returnsNone(samedegraded-mode contract as the
ImportErrorbranch), and logs one actionablelogger.warningnaming the language and reason on every occurrence (notsuppressed after the first — this runs once per file, not once per process like a
singleton model load, so suppressing repeats would hide a mid-run outage).
tests_py/core/test_ast_parser_language_contract.py— 3 new regression tests,all verified to fail on the pre-fix code:
test_download_error_falls_back_instead_of_raising— the degraded-mode return.test_download_error_logs_language_and_reason— asserts the exact logmessage (both static halves + the dynamic language/reason substitutions), not
merely a substring — this is what a scoped mutation run demanded (see below).
test_download_error_degrades_through_parse_file_ast— the public entry pointcodebase_analyzeactually calls, end-to-end..github/workflows/ci.yml—testandtest-sqlitejobs (the two thatinstall the
codebaseextra) gain aResolve tree-sitter cache directory+Cache tree-sitter grammars+Prefetch tree-sitter grammarsstep trio, samecache-then-retry-with-backoff shape as the existing HF-embedding/FlashRank steps,
for the same reason: fetch every language in
AST_SUPPORTED(read from themodule, not hand-copied, so it cannot drift) once, with retries, before
pyteststarts — the suite's own pre-existing direct
get_parser(...)calls intest_ast_extractors.py/test_ast_parser_language_contract.pythen never touchthe network mid-run. Chose CI-side provisioning over touching those call sites
directly: the root cause was mid-test-run network non-determinism, not the test
assertions themselves, and this mirrors the exact established convention already
proven in this file for the identical class of problem.
pyproject.toml[tool.mutmut] also_copy— adds"scripts". Discoveredwhile mutation-testing this change:
tests_py/conftest.py(the root conftestevery test in the suite collects through) does
from scripts.check_venv_lock_parity import postgresql_extra_driftat modulelevel, and without this entry any scoped mutation run — not just this one —
fails collection with
ModuleNotFoundError: No module named 'scripts'beforescoring a single mutant. Small, additive, unblocks the tooling repo-wide.
CHANGELOG.md— entry under[Unreleased] / Fixed.No other unguarded
get_parser/get_languagecall site exists inmcp_server/(swept repo-wide with
grep -rn "get_parser(\|get_language(");ast_parser.pyline 99was the only production call).
Completion Ledger
DownloadErrorduringget_parser().parse()→ degradedNonetest_download_error_falls_back_instead_of_raising; fails pre-fix (reproduced)test_download_error_logs_language_and_reason— exact-string match, kills the 3 mutants a substring-only assertion left survivingparse_file_ast/codebase_analyze) never propagates the exceptiontest_download_error_degrades_through_parse_file_ast; fails pre-fixImportError(missing pack) degraded mode — pre-existing, unchangedtest_missing_pack_falls_back_instead_of_raising,test_is_available_is_false_when_the_pack_is_absent— still 16/16 green in the filetest_supported_language_with_an_extractor_parses, fulltest_ast_parser*/test_ast_extractors.pysuite (64 tests) greenget_parsercalls)test/test-sqlitejobs' new prefetch trio; local differential measurement: cached language <50ms/no network, uncached ~150ms/real fetch (elixir, outsideAST_SUPPORTED, confirmed absent fromdownloaded_languages()beforehand)scripts/mutation_check.shrun: 157 mutants generated forast_parser.pyagainst the file's full test selection; 0 surviving mutants in_get_extractor_and_tree(my changed function) after the message-assertion strengthening; 22 pre-existing survivors remain in_node_text/_extract_module_doc— untouched by this diff, already documented out-of-blast-radius by issue #269/PR #286 for this exact filepytest -q(offline env vars matching CI): 7012 passed, 5 skipped, 121 subtests passed, twice (before and after the final test-assertion strengthening)ruff check ./ruff format --check .— clean, repo-widepyright mcp_server/core/ast_parser.py— 0 errors (sanity-checked against a deliberately-wrong type to confirm it's really resolvingtree_sitter_language_pack, not silently no-op)check_doc_claims.py --test-count 7017/generate_repo_badges.py --check --test-count 7017— both clean (the tests badge is a floor, not exact-match, per issue #293 — no regeneration needed)actionlint .github/workflows/ci.yml(shellcheck-integrated) — 0 findings.bestpractices.jsonparsesStakes classification (objective)
High —
ast_parser.pyis imported by 7 other modules (>5 threshold) and has 2distinct
git log --format='%an'authors in the last 90 days; both criteriaindependently place it High per the standard's objective test. Full discipline
applied: reproduction before fix, root-cause fix (not a band-aid), regression tests
including signal-emission assertions, scoped mutation testing on the changed
function, full-suite verification, and CI-hermeticity fix rather than a test skip.
Working notes
Detached worktree at
Cortex-fix-grammar-fetch-degraded, branchfix-grammar-fetch-degraded, based onorigin/main@469e70c2(unchangedthroughout). Left open per instructions — not merging; the coordinator merges and
verifies main post-merge. Not touching
lint-finish-239(separate in-flight branchnoted as off-limits).
🤖 Generated with Claude Code