From 875c2a4761b4dd3ebf3df53a09436307f6768668 Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Sun, 20 Sep 2026 01:48:11 +0530 Subject: [PATCH] Scope JS let/const bindings to their own block, not the function _js_local_bound_names collected let/const declarator targets, and a for of/for in loop's own binding, into one function wide shadow set, but both are block scoped in JS, unlike var, which is function scoped through hoisting regardless of nesting depth. A reference to a same named module callable made outside the block that actually binds the name had its genuine indirect_call edge suppressed, whether the binding sat inside an if, try, or bare brace block, came from a for of/for in loop referenced after the loop ends, or was read from a closure that outlives the loop. The function wide set now only collects var, distinguished by checking whether a declarator's parent is a variable_declaration rather than a lexical_declaration, and a for in loop's own binding only when it uses var. Every let/const and for/for of/for in binding is instead folded into extra_locals for exactly its own block or loop subtree, the same mechanism already used for catch bindings, so a reference genuinely inside that block or loop still correctly shadows while one outside it resolves. A C style for loop's own let/const initializer gets the same treatment, since it is scoped to the whole loop the same way a for of binding is. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh --- graphify/extractors/engine.py | 92 ++++++++++++++++++++++++++++++----- 1 file changed, 80 insertions(+), 12 deletions(-) diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index 04cb471f7..1e93f2416 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -1360,11 +1360,25 @@ def _js_collect_pattern_idents(node, source: bytes, bound: set) -> None: _js_collect_pattern_idents(c, source, bound) def _js_local_bound_names(func_node, source: bytes) -> set[str]: - """Names bound locally inside a JS/TS function: parameters plus `const`/`let`/ - `var` declarator targets. Mirrors `_python_local_bound_names`: an argument that - is a parameter or local binding names a local value, not a same-named module - function, so it must not manufacture an indirect_call edge. Nested function and - class scopes are not descended into.""" + """Names bound locally inside a JS/TS function, into the FUNCTION-WIDE + scope: parameters plus `var` declarator targets. Mirrors + `_python_local_bound_names`: an argument that is a parameter or local + binding names a local value, not a same-named module function, so it + must not manufacture an indirect_call edge. Nested function and class + scopes are not descended into. + + `let`/`const` declarator targets and `for`/`for-in`/`for-of` loop + bindings are NOT collected here (#2822): unlike `var`, which is + function-scoped (hoisted) regardless of how deeply it is nested inside + an if/try/bare-`{}` block or a loop, `let`/`const` (and a `for`/`for-in` + loop's own binding, whatever keyword it uses) are scoped to exactly the + block or loop they are declared in. Collecting them into this + function-wide set suppressed a genuine reference to a same-named module + callable anywhere else in the function, even far outside the block that + actually owns the binding. `walk_calls` in this module folds each of + those into `extra_locals` for exactly its own block/loop subtree + instead, the same mechanism already used for `catch (e)` bindings. + """ bound: set[str] = set() params = func_node.child_by_field_name("parameters") if params is not None: @@ -1381,17 +1395,18 @@ def _js_local_bound_names(func_node, source: bytes) -> set[str]: for c in n.children: if c.type in _JS_SCOPE_BOUNDARY: continue # inner scope — its bindings are not this function's locals - if c.type == "variable_declarator": + if c.type == "variable_declarator" and n.type == "variable_declaration": + # n.type distinguishes `var` (variable_declaration) from + # `let`/`const` (lexical_declaration) — only var belongs in + # the function-wide set, see the docstring above (#2822). name = c.child_by_field_name("name") if name is not None: _js_collect_pattern_idents(name, source, bound) - elif c.type == "for_in_statement": - # `for (const entry of xs)` / `for (const {k} of xs)`: the loop + elif c.type == "for_in_statement" and any(gc.type == "var" for gc in c.children): + # `for (var entry of xs)`: var is still function-scoped/hoisted + # here too, unlike the let/const form (#2822) -- the loop # binding is the `left` pattern, NOT wrapped in a - # variable_declarator, so the branch above misses it and `entry` - # read as a by-name reference to any same-named module callable - # (#2606). C-style `for (let i = 0; ...)` uses a lexical_declaration - # with real declarators, already covered by the recursion below. + # variable_declarator, so the branch above misses it. left = c.child_by_field_name("left") if left is not None: _js_collect_pattern_idents(left, source, bound) @@ -1402,6 +1417,27 @@ def _js_local_bound_names(func_node, source: bytes) -> set[str]: walk(body) return bound + +def _js_direct_lexical_names(node, source: bytes) -> frozenset[str]: + """Names bound by `let`/`const` declarators that are DIRECT children of + node (not nested inside a further block, loop, function, or class). + + Used to scope a lexical binding to exactly the block or `for` loop it is + declared in (#2822), rather than the whole enclosing function: `node` is + a `statement_block` or a C-style `for_statement`, both of which have any + `lexical_declaration` they directly own as a plain child. + """ + names: set[str] = set() + for child in node.children: + if child.type != "lexical_declaration": + continue + for declarator in child.children: + if declarator.type == "variable_declarator": + name = declarator.child_by_field_name("name") + if name is not None: + _js_collect_pattern_idents(name, source, names) + return frozenset(names) + def _js_module_bound_names(root, source: bytes) -> set[str]: """Module-scope names rebound to NON-function data (`const X = {...}`, `let y = 5`). @@ -6620,6 +6656,38 @@ def _extract_generic( _js_collect_pattern_idents(param, source, caught) extra_locals = extra_locals | frozenset(caught) + # `let`/`const` are block-scoped, unlike `var` (function-scoped, + # hoisted) -- _js_local_bound_names only tracks var into the + # function-wide set now, so a name declared directly in a + # statement_block or a C-style for loop's own initializer must be + # folded into extra_locals for exactly that block/loop's subtree, or + # a same-named module callable referenced outside it is wrongly + # treated as shadowed everywhere in the function (#2822). + if ( + config.ts_module in ("tree_sitter_javascript", "tree_sitter_typescript") + and node.type in ("statement_block", "for_statement") + ): + block_locals = _js_direct_lexical_names(node, source) + if block_locals: + extra_locals = extra_locals | block_locals + + # `for (const entry of xs)` / `for (const {k} of xs)`: the loop + # binding is the `left` pattern, not wrapped in a variable_declarator, + # and is scoped to the whole loop (condition and body), never the + # enclosing function (#2822) -- folding it here regardless of + # var/let/const is harmless for the var case, already covered by the + # function-wide set. + if ( + config.ts_module in ("tree_sitter_javascript", "tree_sitter_typescript") + and node.type == "for_in_statement" + ): + left = node.child_by_field_name("left") + if left is not None: + loop_locals: set[str] = set() + _js_collect_pattern_idents(left, source, loop_locals) + if loop_locals: + extra_locals = extra_locals | frozenset(loop_locals) + for child in node.children: walk_calls(child, caller_nid, receiver_types, extra_locals)