Found during the /oversee review of #2088's plan (PR #2612, round 22).
What
Neither engine's FUNCTION_SCOPE_NODE_TYPES list includes class_static_block:
src/extractors/javascript.ts:4634-4641
crates/codegraph-core/src/extractors/javascript.rs:4698-4705
functionScopeDeclaresVar (src/extractors/javascript.ts:4660-4672) walks every child of a function body looking for a hoisted var, skipping only children whose type is in FUNCTION_SCOPE_NODE_TYPES:
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (!child) continue;
if (FUNCTION_SCOPE_NODE_TYPES.has(child.type)) continue;
if (functionScopeDeclaresVar(child, name, depth + 1)) return true;
}
Since class_static_block isn't in that set, the walk descends straight through class_declaration → class_body → class_static_block → statement_block and finds a var declared there — even though a class static block's own statement list is collected in ECMA-262 via the same TopLevelVarDeclaredNames/TopLevelVarScopedDeclarations static-semantics operations used for a function body, not the plain VarDeclaredNames used for an ordinary nested block, making it a var-scope boundary in its own right rather than a transparent block; a var inside static { ... } is scoped to the block itself and never hoists to any enclosing function. Verified against the real grammar (tree-sitter-javascript@0.25.0's node-types.json: class_static_block carries its own body field, a statement_block) and against real Node:
function fnA() { return 1; }
function sink(x) { return x.alpha(); }
const T = { alpha: fnA };
function go() {
class C { static { var T = 1; } } // var is scoped to the STATIC BLOCK, not to go()
sink(T); // genuinely reads the module-level T
}
go();
go() returns 1 — sink(T) genuinely reaches the module-level T. But functionScopeDeclaresVar(go's body, 'T') returns true (it finds the static block's var T = 1 and, having no reason to stop at the static block's own boundary, attributes it to go), so introducesShadowedBinding(go, 'T') also returns true — go looks like it shadows T, even though it doesn't.
Control isolating the cause to the var path specifically: the identical shape with let T = 1 in the static block does not trigger this — functionScopeDeclaresVar only ever tests node.type === 'variable_declaration', never lexical_declaration, so it was never going to see a let here regardless of the class_static_block gap.
Reproduces identically across every function-shape container (function_declaration, arrow_function, method_definition, ...), with the static block held directly, inside a class expression, inside a class-field-held class expression, and nested two levels deep — the defect is in the walk itself, not in any one AST shape around it.
Why it matters
Direction: UNDER-escape — not recall-only, the opposite of #2643's direction. functionScopeDeclaresVar's only current consumer, introducesShadowedBinding's function-shape case, uses a spurious true here to prune a subtree from a reference walk (blockContainsIdentifierExcluding) that would otherwise find a genuine reference inside it. A subtree that's wrongly pruned hides a real reference outright — this is a false-positive shadow, not a missed one (contrast #2643, where a missed shadow only ever adds a spurious candidate to a conjunction, which can under-correlate but never hides an existing, already-reached reference). Concretely, this is exactly the shape of dead-code false positive this whole review has been hunting: a function that genuinely invokes something gets pruned from the search, a this whole search finds nothing result gets trusted, and a live symbol is reported dead.
#2088's own new escape-analysis design (allReferencesTracked, PR #2612 / WU-2) is not exposed to this in its shipped form: the plan's round-22 revision routes its own body-hoist check through a locally re-derived helper that additionally skips class_static_block, rather than through the shared, unmodified functionScopeDeclaresVar. The already-shipped consumer — introducesShadowedBinding, used today by the fallback-value-ref dead-code check predating this plan (PR #2432) — has no such workaround and remains exposed in production.
Why it wasn't fixed inline
Widening functionScopeDeclaresVar (or FUNCTION_SCOPE_NODE_TYPES) changes behavior for every consumer built on them — introducesShadowedBinding's full switch (for_statement, statement_block, switch_body, catch_clause cases too), and anything else that leans on FUNCTION_SCOPE_NODE_TYPES's current six-member definition. Every round of the #2088 review has declined to touch these two primitives directly for that reason, fixing locally in the new consumer's own re-derivation instead (mirroring the same discipline round 18 established for a different bug in the same function). Doing it here would need its own review of every existing consumer, not only the new one.
Suggested fix shape (not binding)
Add a class_static_block case to functionScopeDeclaresVar's own skip test (FUNCTION_SCOPE_NODE_TYPES.has(child.type) || child.type === 'class_static_block'), or add class_static_block to a new, dedicated boundary set if FUNCTION_SCOPE_NODE_TYPES's existing six members are relied upon elsewhere to mean "function-shaped, with name/parameters/body fields" (a class_static_block node has no such shape, only a body field). Re-verify introducesShadowedBinding's own behavior afterward, since a newly-detected non-shadow could in principle change something for its for_statement/statement_block/switch_body cases too, even though none of those currently descend through a class body in a way this gap would affect.
Mirror in crates/codegraph-core/src/extractors/javascript.rs (function_scope_declares_var, lines 4724-4745) — confirmed to carry the byte-identical gap, same six-member FUNCTION_SCOPE_NODE_TYPES list, same unbounded recursion into any non-listed child type.
Provenance note
Found during the round-22 revision of PR #2612 (docs/plans/issue-2088.md), verified against the real tree-sitter-javascript@0.25.0 grammar and eight executed Node repros exercising the trigger (base shape, five further function-shape containers, and the identical trick placed one hop into each of the plan's own rebinding-alias and for-of recursions), plus two further executed controls (a let-vs-var swap, and a genuine non-static-block var shadow) proving the shape does not over- or under-fire, before filing.
Found during the
/overseereview of #2088's plan (PR #2612, round 22).What
Neither engine's
FUNCTION_SCOPE_NODE_TYPESlist includesclass_static_block:src/extractors/javascript.ts:4634-4641crates/codegraph-core/src/extractors/javascript.rs:4698-4705functionScopeDeclaresVar(src/extractors/javascript.ts:4660-4672) walks every child of a function body looking for a hoistedvar, skipping only children whose type is inFUNCTION_SCOPE_NODE_TYPES:Since
class_static_blockisn't in that set, the walk descends straight throughclass_declaration→class_body→class_static_block→statement_blockand finds avardeclared there — even though a class static block's own statement list is collected in ECMA-262 via the sameTopLevelVarDeclaredNames/TopLevelVarScopedDeclarationsstatic-semantics operations used for a function body, not the plainVarDeclaredNamesused for an ordinary nested block, making it a var-scope boundary in its own right rather than a transparent block; avarinsidestatic { ... }is scoped to the block itself and never hoists to any enclosing function. Verified against the real grammar (tree-sitter-javascript@0.25.0'snode-types.json:class_static_blockcarries its ownbodyfield, astatement_block) and against real Node:go()returns1—sink(T)genuinely reaches the module-levelT. ButfunctionScopeDeclaresVar(go's body, 'T')returnstrue(it finds the static block'svar T = 1and, having no reason to stop at the static block's own boundary, attributes it togo), sointroducesShadowedBinding(go, 'T')also returnstrue—golooks like it shadowsT, even though it doesn't.Control isolating the cause to the
varpath specifically: the identical shape withlet T = 1in the static block does not trigger this —functionScopeDeclaresVaronly ever testsnode.type === 'variable_declaration', neverlexical_declaration, so it was never going to see alethere regardless of theclass_static_blockgap.Reproduces identically across every function-shape container (
function_declaration,arrow_function,method_definition, ...), with the static block held directly, inside a class expression, inside a class-field-held class expression, and nested two levels deep — the defect is in the walk itself, not in any one AST shape around it.Why it matters
Direction:
UNDER-escape— not recall-only, the opposite of #2643's direction.functionScopeDeclaresVar's only current consumer,introducesShadowedBinding's function-shape case, uses a spurioustruehere to prune a subtree from a reference walk (blockContainsIdentifierExcluding) that would otherwise find a genuine reference inside it. A subtree that's wrongly pruned hides a real reference outright — this is a false-positive shadow, not a missed one (contrast #2643, where a missed shadow only ever adds a spurious candidate to a conjunction, which can under-correlate but never hides an existing, already-reached reference). Concretely, this is exactly the shape of dead-code false positive this whole review has been hunting: a function that genuinely invokes something gets pruned from the search, athis whole search finds nothingresult gets trusted, and a live symbol is reported dead.#2088's own new escape-analysis design (allReferencesTracked, PR #2612 / WU-2) is not exposed to this in its shipped form: the plan's round-22 revision routes its own body-hoist check through a locally re-derived helper that additionally skipsclass_static_block, rather than through the shared, unmodifiedfunctionScopeDeclaresVar. The already-shipped consumer —introducesShadowedBinding, used today by the fallback-value-ref dead-code check predating this plan (PR #2432) — has no such workaround and remains exposed in production.Why it wasn't fixed inline
Widening
functionScopeDeclaresVar(orFUNCTION_SCOPE_NODE_TYPES) changes behavior for every consumer built on them —introducesShadowedBinding's full switch (for_statement,statement_block,switch_body,catch_clausecases too), and anything else that leans onFUNCTION_SCOPE_NODE_TYPES's current six-member definition. Every round of the #2088 review has declined to touch these two primitives directly for that reason, fixing locally in the new consumer's own re-derivation instead (mirroring the same discipline round 18 established for a different bug in the same function). Doing it here would need its own review of every existing consumer, not only the new one.Suggested fix shape (not binding)
Add a
class_static_blockcase tofunctionScopeDeclaresVar's own skip test (FUNCTION_SCOPE_NODE_TYPES.has(child.type) || child.type === 'class_static_block'), or addclass_static_blockto a new, dedicated boundary set ifFUNCTION_SCOPE_NODE_TYPES's existing six members are relied upon elsewhere to mean "function-shaped, with name/parameters/body fields" (aclass_static_blocknode has no such shape, only abodyfield). Re-verifyintroducesShadowedBinding's own behavior afterward, since a newly-detected non-shadow could in principle change something for itsfor_statement/statement_block/switch_bodycases too, even though none of those currently descend through a class body in a way this gap would affect.Mirror in
crates/codegraph-core/src/extractors/javascript.rs(function_scope_declares_var, lines 4724-4745) — confirmed to carry the byte-identical gap, same six-memberFUNCTION_SCOPE_NODE_TYPESlist, same unbounded recursion into any non-listed child type.Provenance note
Found during the round-22 revision of PR #2612 (docs/plans/issue-2088.md), verified against the real
tree-sitter-javascript@0.25.0grammar and eight executed Node repros exercising the trigger (base shape, five further function-shape containers, and the identical trick placed one hop into each of the plan's own rebinding-alias and for-of recursions), plus two further executed controls (alet-vs-varswap, and a genuine non-static-blockvarshadow) proving the shape does not over- or under-fire, before filing.