fix(ast): bound extractor traversal by heap, not the interpreter stack - #370
Merged
Conversation
Every full-tree AST walker in the extractor layer spent one Python frame per AST level and raised an uncaught RecursionError past an AST depth of ~1003 (default recursion limit 1000). Nothing in mcp_server/ catches it — ast_parser handles ImportError and DownloadError only — so a single deeply nested file failed the indexing run for the entire repository. Reachable in normal use: Cortex indexes third-party repos, where minified and generated sources nest far deeper than hand-written code. A sweep for the defect class found nine self-recursive walkers; seven descend the whole tree and are converted here: _walk_type, _walk_for_calls ast_extractors.py _walk_java, _walk_kotlin ast_extractors_jvm.py _walk_csharp ast_extractors_clike.py _walk_ruby, _walk_php ast_extractors_scripting.py _extract_swift_node ast_extractors_extra.py _extract_js_node is deliberately left recursive: it descends only through export_statement, so its depth is bounded by export nesting, not tree depth. Each walker now carries an explicit stack of (node, class_scope), pushing descendants reversed so they pop before the remaining siblings. That preserves depth-first pre-order, and with it the order of `defs` and the insertion order of extract_calls_per_function's dict. Two control-flow subtleties preserved and commented at the site: the recursive `return` sat inside `if name:`, so an unnamed type node falls through to the method check; and _extract_swift_node's if/elif/else means a _SWIFT_KIND_MAP node never reaches the catch-all descent. Verification: differential vs the HEAD implementations over 2229 real and synthetic sources (incl. 403 real Kotlin, 402 real Swift): 0 output differences, dict key order included depth contract: 7 cases fail pre-fix, 7 pass post-fix — each fixture verified to reach the recursive path, after a first draft proved vacuous for 8 of 9 cases pytest -k "ast or extractor or codebase": 1157 passed ruff check / format --check: clean · pyright mcp_server/: 0 diagnostics mutation, paired against HEAD: not-killed in the seven walkers 368 -> 241; the 241 remaining are inherited debt, filed as #369 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
cdeust
added a commit
that referenced
this pull request
Aug 6, 2026
Not-killed mutants in the seven walkers: 241 -> 6, all six documented equivalents. Killed across the five extractor modules: 571 -> 847. The Java, Kotlin, C#, Ruby, PHP and Swift extractors had almost no behavioural tests — test_ast_extractors.py covered Python, JS and Go only — so a wrong qualified name or a dropped nested type would have reached ingest_codebase, the wiki reference pages and unified_search with no failing test. The recursion fix in #370 had to lean on a purpose-built differential harness precisely because the suite could not have caught an ordering change. Each language asserts the exact ordered (name, kind) list, not membership: a membership assertion cannot tell a correct walker from one emitting the right names in the wrong scope. Every expectation was measured against the implementation and then read back against the source, rather than predicted. Three measured behaviours are surprising and are pinned as current behaviour so changing them takes a failing test rather than an accident: - Swift enum/struct report kind "class"; tree-sitter-swift parses all three as class_declaration, so two _SWIFT_KIND_MAP entries never match - Kotlin `object O` emits no type entry, but `object Named : Base()` does — the supertype is what makes the grammar emit a type_identifier - Ruby `def self.x` is dropped; it parses as singleton_method Also fixes a mutation gate that could report a false clean. On a five-file run scripts/mutation_check.sh printed "none — 0 surviving mutants" while mutmut's own progress line ended at 423 survived and mutmut results returned a 915-line listing. It now cross-checks the two independent counts and refuses to render any verdict when they disagree, instead of taking the friendlier number. Its cleanup trap wipes mutants/ and .mutmut-cache, so a disagreement is uninvestigable afterwards and has to be caught in the run. ast_extractors.py changes are comment-only: equivalence notes at the use site for the six remaining mutants. The decorated_definition arm of _walk_for_calls is one of them, and is deliberately kept — it mirrors _extract_python_children's dispatch, where the same branch is load-bearing (that function has no catch-all), and it is the seam for behaviour that was never filled in: calls inside a decorator produce no edge at all. Filed as #372 with the design question attached. Verified: pytest -k "ast or extractor or codebase" -> 1181 passed differential vs merged main, 1344 files -> 0 mismatches ruff check / format --check -> clean pyright mcp_server/ -> 0 errors, 0 warnings scoped mutation, seven walkers -> 6 survivors, all documented Refs #369, #372 Co-authored-by: Claude Opus 5 (1M context) <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.
Problem
Every full-tree AST walker in the extractor layer spent one Python frame per AST level. On a source nested deeper than the interpreter's recursion limit, they raised
RecursionError— and nothing catches it:ast_parser.pycatchesImportErrorandDownloadErroronly, so the error propagated out of the indexer and failed the run for the entire repository, not just the offending file.Reproduced before the fix:
This is reachable in normal operation. Cortex indexes third-party repositories, and minified or generated sources nest far deeper than hand-written code. It is not reachable from the current first-party corpus: I parsed 400 JS files in
cortex-vizand none hit the limit. So this is a latent robustness defect on untrusted input, not an active outage.Scope
The defect was not one function. A sweep for self-recursive walkers found nine, and seven of them descend the whole tree:
_walk_typeast_extractors.py_walk_for_callsast_extractors.py_walk_javaast_extractors_jvm.py_walk_kotlinast_extractors_jvm.py_walk_csharpast_extractors_clike.py_walk_rubyast_extractors_scripting.py_walk_phpast_extractors_scripting.py_extract_swift_nodeast_extractors_extra.py_extract_js_nodeast_extractors.py_extract_js_noderecurses only throughexport_statement, so its depth is bounded by export nesting rather than tree depth. Converting it would be churn without a defect behind it, so it is left alone deliberately rather than by omission.Fixing only
_walk_typewould have left Java, Kotlin, C#, Ruby, PHP, Swift and Python call-extraction with the identical crash in the same module family. That is the half-implementation §15 forbids.Approach
Each walker keeps an explicit
list[tuple[Node, str]]stack carrying the enclosing class scope, with descendants pushed reversed so they pop before the remaining siblings. That preserves depth-first pre-order, and with it the order ofdefsand the insertion order ofextract_calls_per_function's dict — which a naive breadth-first rewrite would silently change.Control flow is preserved exactly, including two subtleties:
returnsat insideif name:, so an unnamed type node fell through to the method check. The iterative form reproduces that with acontinueinsideif name:, and says so at the site._extract_swift_nodeisif/elif/else, so a_SWIFT_KIND_MAPnode never reaches the catch-all descent and an unnamed one descends nowhere. Preserved.Behaviour preservation
Unit tests are weak evidence for an order-preserving rewrite, so the primary evidence is a differential harness that runs the old implementations from
HEADand the new ones side by side over the same corpus and compares output, including dict key order.Files where the old code raised
RecursionErrorwere excluded from the comparison rather than counted as mismatches, since there is no old output to compare against.Tests
tests_py/core/test_ast_extractor_depth.pyasserts the depth contract for all six language entry points plusextract_calls_per_function.tests_py/core/test_ast_extractors.py::TestWalkTypecovers_walk_typedirectly.Each depth case asserts two properties, because either alone is insufficient:
sys.getrecursionlimit(), so a future parser change that flattens the tree cannot leave the test green while testing nothing;Property 2 is not theoretical. My first version of this test was vacuous for 8 of 9 cases: the language walkers
returnat a method node without entering its body, so nesting inside a method body never reached the recursive path. It was caught only by running the new test against the pre-fix code and seeing just one case fail. Every fixture in the committed version was verified to raiseRecursionErroragainst the old implementation before being accepted.Completion Ledger
pytest tests_py -k "ast or extractor or codebase"→ 1157 passed, 5968 deselected_SWIFT_KIND_MAPnode that never reaches the catch-all; empty children; no-match search —TestWalkType::test_returns_empty_when_nothing_matches,::test_includes_the_start_node_itselfRecursionErroris no longer reachable by depth. Asserted by 7 cases that fail on pre-fix code._-prefixed) except through unchanged public entry points. No signature changed.extract_calls_per_function,extract_{java,kotlin,csharp,ruby,php,swift}_definitions, and the_walk_typeimporters in four modules. All verified by the differential above, not by inspection.TestWalkTypecases + the differential harness.test_returns_empty_when_nothing_matches; the depth guard asserts the fixture is deeper than the limit, failing loudly if it stops being so.ruff check .→ All checks passed! ·ruff format --check .→ 1275 files already formatted ·pyright mcp_server/→ 0 errors, 0 warnings, 0 informationscore/still imports onlyshared/+ stdlib. Function sizes unchanged in order of magnitude and under the 40-line local cap.ruff formatto the touched files only.Mutation (§12)
Scoped run over the five changed files, against HEAD as a paired control (same tool, same source scope):
The rise in
survivedis reclassification, not regression: the new depth tests execute walker code no test previously reached, so mutants moved from "never run" into "run but not detected". The comparable metric is not-killed = survived + no tests, and it improves in every converted function:_walk_for_calls_walk_java_walk_kotlin_walk_csharp_walk_ruby_walk_php_extract_swift_node_walk_type241 mutants remain unpinned in these walkers. That is inherited debt — the Java, Kotlin, C#, Ruby, PHP and Swift extractors have almost no behavioural tests — reduced 34% by this PR and not introduced by it, so per §12.5 it is not treated as a blocker here. Filed as #369 with the reproduction command and acceptance criteria, per §14.3; not deferred as prose.
A tooling defect found while doing this, also in #369.
scripts/mutation_check.shprintednone — 0 surviving mutants 🎉on this exact run while its own progress counter ended at🙁 423, andmutmut resultsreturns a 915-line non-empty list. Its grep-based survivor detection produced a false green, and its cleanup trap deletesmutants/and.mutmut-cacheon exit, so the discrepancy is uninvestigable after the fact. Every mutation number quoted above comes from a directmutmut run+mutmut resultswith artifacts retained, not from the script's verdict. A mutation gate that can report a false clean is worse than no gate.