fix(extract): shadow JS/TS catch bindings from indirect_call args - #2517
fix(extract): shadow JS/TS catch bindings from indirect_call args#2517imagineers-tyler wants to merge 1 commit into
Conversation
There was a problem hiding this comment.
Graphify reviewed this change.
Worth a look — the grounded gate found no coupling regressions or blocking issues, but 1 advisory finding(s) below merit a look before merge.
Graphify review — findings
This PR extends _js_local_bound_names in the JS/TS extractor to also collect names bound by catch clauses (via the clause's parameter field), adding them to the set of locally-bound names used when deciding whether a call argument should produce an indirect_call edge. The stated intent is to prevent catch bindings (including destructured ones) from being treated as by-name references to same-named callables elsewhere in the corpus. It also adds a new test file covering catch bindings, destructured catch bindings, genuine references inside catch blocks, and catchless try/finally cases.
Worth a look
- Catch parameter suppresses by-name references outside its block —
graphify/extractors/engine.py:1176· Escalate · medium- agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
Analysis details — impact, health, verification
Impact & health
Graphify review
Impact — 580 functions depend on the 187 functions this change touches.
Health — grade A; 10 existing hotspot(s) in the area this change touches (pre-existing, not introduced here):
_extract_generic()— 18 callers, 20 callees (high)extract_xaml()— 19 callers, 17 callees (high)extract_objc()— 27 callers, 8 callees (high)extract_julia()— 16 callers, 7 callees (high)extract_vue()— 10 callers, 6 callees (high)walk()— 1 callers, 52 callees (high)extract_groovy()— 14 callers, 3 callees (high)extract_astro()— 6 callers, 5 callees (medium)- …and 2 more
Verification — 580 functions in the blast radius were not formally verified this run (proofs are advisory here).
Gate & verification
graphify gate
PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.
Advisory (not blocking):
- verification_scope: 521 function(s) in the blast radius were not formally verified this run
· 1 grounded finding(s) anchored inline below.
| elif c.type == "catch_clause": | ||
| # `catch (e)` binds through the clause's `parameter` field, never a | ||
| # variable_declarator, so the branch above misses it. A one-letter | ||
| # binding then read as a call argument in the handler body was taken | ||
| # for a by-name reference to a same-named callable elsewhere in the | ||
| # corpus — minified bundles supply one for nearly every letter. | ||
| param = c.child_by_field_name("parameter") | ||
| if param is not None: | ||
| _js_collect_pattern_idents(param, source, bound) | ||
| walk(c) |
There was a problem hiding this comment.
Catch parameter suppresses by-name references outside its block — agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
Graphify suggests a fix:
| elif c.type == "catch_clause": | |
| # `catch (e)` binds through the clause's `parameter` field, never a | |
| # variable_declarator, so the branch above misses it. A one-letter | |
| # binding then read as a call argument in the handler body was taken | |
| # for a by-name reference to a same-named callable elsewhere in the | |
| # corpus — minified bundles supply one for nearly every letter. | |
| param = c.child_by_field_name("parameter") | |
| if param is not None: | |
| _js_collect_pattern_idents(param, source, bound) | |
| walk(c) | |
| # NOTE: `catch (e)` is deliberately NOT handled here. A catch parameter | |
| # is scoped to its own catch block, so adding it to this function-wide | |
| # shadow set would suppress legitimate by-name references to a same-named | |
| # callable elsewhere in the function. The catch body is still walked below | |
| # so its var/let/const declarators are collected. | |
| walk(c) |
A `catch (e)` clause binds its name through the clause's own `parameter` field and is
never wrapped in a `variable_declarator`, so `_js_local_bound_names` never saw it. The
binding was therefore absent from the shadow set guarding indirect_call argument
resolution: passing it on as a plain call argument (`handlers.get(e)`) read as an
unresolved by-name reference and resolved against the corpus-wide label index,
fabricating an `indirect_call` edge (INFERRED, 0.8) to an unrelated same-named callable.
One-letter catch bindings make this land constantly against minified bundles, which
define a private function for nearly every letter:
// vendor.min.js
var Lib=function(){function k(a){return a}return{k:k}}();
// a.js
export function run(handlers) {
try { boom(); } catch (k) { handlers.get(k); }
}
emits `run -> k` on v8 @ 9f25a3a.
A catch binding is scoped to its clause, so the fix folds it into the per-subtree
`extra_locals` channel inside `walk_calls` — the same shape as the untracked-closure
fold added in Graphify-Labs#2241 — rather than into the function-wide `_js_local_bound_names` set.
Shadowing therefore applies inside the handler only; a same-named module callable
referenced after the try/catch still resolves. `_js_local_bound_names` is untouched,
so the `variable_declarator` path keeps its existing behaviour.
`_js_collect_pattern_idents` handles the pattern forms, so `catch ({ cause })` is
covered. ES2019 `catch { }` has no `parameter` field and is guarded.
Tests pin all three directions — the fabricated in-handler edge is gone, a genuine
by-name reference inside a catch block still emits, and a same-named callable
referenced outside the block is not suppressed. Measured on this file:
v8 @ 9f25a3a 2 failed, 3 passed
function-wide variant (earlier draft) 1 failed, 4 passed
this change 5 passed
699bd66 to
b441b77
Compare
|
The finding is correct, and it is now fixed rather than argued with. Pushed ReproducedThe earlier draft folded the catch parameter into function k(x){ return x; }
export function run(pool) {
try { boom(); } catch (k) { log(k); }
pool.submit(k); // ← should resolve to the module-level k
}Fixed by moving to the per-subtree channel
if (
config.ts_module in ("tree_sitter_javascript", "tree_sitter_typescript")
and node.type == "catch_clause"
):
param = node.child_by_field_name("parameter") # absent for ES2019 `catch {}`
if param is not None:
caught: set[str] = set()
_js_collect_pattern_idents(param, source, caught)
extra_locals = extra_locals | frozenset(caught)
A new test pins the exact case the review flagged,
Regression run: 227 passed, 1 skipped across On the inline suggestionNot taking it, for the record: as written the suggestion block spans the whole added hunk, so applying it removes the fix entirely and leaves a comment asserting the omission was deliberate. Its text also states that a catch parameter is block-scoped "unlike" what the function collects — but function k(x){ return x; }
export function run(pool) {
if (1) { const k = 1; void k; }
pool.submit(k); // suppressed on v8 today, no change from this PR
}That is a real pre-existing issue and the same |
There was a problem hiding this comment.
Graphify reviewed this change.
Looks safe to merge — no coupling regressions and no blocking issues, checked against the code graph (not a self-assessment).
Graphify review — findings
This PR addresses a case where a JS/TS catch clause binding could produce a fabricated indirect_call edge, since catch bindings are declared through the clause's own parameter field rather than a variable_declarator and thus weren't captured by the existing local-name collection. In engine.py, it adds a branch in the call-walking logic that, for JS/TS catch_clause nodes, collects the caught pattern's identifiers and folds them into the per-subtree extra_locals shadow set. It also adds a new test file covering simple and destructured catch bindings, scoping behavior outside the block, genuine references, and the ES2019 optional (parameter-less) catch form, and adds a changelog entry.
No blocking issues surfaced. 1 lower-confidence candidate did not survive cross-model review.
Analysis details — impact, health, verification
Impact & health
Graphify review
Impact — 762 functions depend on the 369 functions this change touches.
Health — this change adds coupling hotspots:
- worse:
walk_calls()— 1 callers, 14 callees
Verification — 762 functions in the blast radius were not formally verified this run (proofs are advisory here).
Gate & verification
graphify gate
PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.
Advisory (not blocking):
- verification_scope: 703 function(s) in the blast radius were not formally verified this run
· 1 more finding(s) on lines outside this diff (see the check run).
Summary
A
catch (e)clause binds its name through the clause's ownparameterfield and is never wrapped in avariable_declarator, so_js_local_bound_namesnever sees it. The binding is therefore absent from the shadow set guardingindirect_callargument resolution: passing it on as a plain call argument (handlers.get(e)) reads as an unresolved by-name reference, falls through to the corpus-wide label index, and fabricates anindirect_calledge (INFERRED, 0.8) to an unrelated same-named callable.One-letter catch bindings make this land constantly against minified bundles, which define a private function for nearly every letter.
Reproduction
On
v8@9f25a3a:Change — scoped to the clause, not the function
A catch binding is scoped to its own block. Folding it into the function-wide
_js_local_bound_namesset would fix the fabricated edge but suppress a legitimate by-name reference to a same-named callable elsewhere in the same function. So the fix uses the per-subtreeextra_localschannel inwalk_callsinstead — the same shape as the untracked-closure fold added in #2241:_js_local_bound_namesis untouched, so thevariable_declaratorpath keeps its existing behaviour. All six shadow-set consumers already readlocal_bound_names.get(caller_nid, frozenset()) | extra_locals, so nothing else needed changing.Notes:
_js_local_bound_namescannot be reused on a catch node — it readschild_by_field_name("parameters")(plural), whilecatch_clauseexposesparameter(singular)._js_collect_pattern_identscovers the pattern forms, socatch ({ cause })works. ES2019catch { }has noparameterfield and is guarded.Tests
New
tests/test_indirect_call_catch_binding_shadow.py, modelled ontest_indirect_call_nested_closure_shadow.py:test_catch_binding_emits_no_indirect_calltest_catch_binding_destructured_emits_no_indirect_callcatch ({ cause })shadows tootest_catch_binding_does_not_shadow_outside_its_blocktest_catch_genuine_reference_still_emits_indirect_calltest_optional_catch_binding_unaffectedcatch { }— theparam is not NoneguardMeasured on this test file, swapping only
graphify/extractors/engine.py:v8@9f25a3adoes_not_shadow_outside_its_blockRegression run on this branch:
test_indirect_call_catch_binding_shadow.py+test_indirect_call_nested_closure_shadow.py+test_indirect_dispatch*.py+test_extract.py+test_cross_language_call_resolution.py+test_node_id_canonical.py→ 227 passed, 1 skipped.Scope
Does not touch
_js_local_bound_names, so it does not overlap #1985 (for_in_statement), which edits that function. The two changes are in different functions and do not conflict.