3030 * rule catches. Without this skip, the two rules race each
3131 * other and the autofix re-introduces the bug.
3232 *
33- * Both rules register the same visitors (`VariableDeclarator`,
34- * `FunctionDeclaration`, `FunctionExpression`,
35- * `ArrowFunctionExpression`) and share the resulting per-file map
36- * via the helpers in this module.
33+ * # Scope handling
34+ *
35+ * Bindings are resolved by walking the AST `parent` chain from the
36+ * USE site upward, stopping at the nearest scope-creating node that
37+ * declares the name. A scope-creating node is any of:
38+ *
39+ * - `Program` (module / file scope)
40+ * - `BlockStatement` (function body, if/for/while body, bare block)
41+ * - `ForStatement` / `ForOfStatement` / `ForInStatement` (the head
42+ * binding `let i = 0` is scoped to the loop, not the surrounding
43+ * block)
44+ * - any `Function*` node (parameters are scoped to that function)
45+ * - `CatchClause` (the caught-error binding)
46+ *
47+ * This is the JS `let`/`const` block-scoping model. The fleet's code
48+ * uses `const` / `let` exclusively (no `var`), so we don't need to
49+ * model `var`'s function-scope hoisting separately.
50+ *
51+ * Earlier revisions of this module used a single flat `Map<name,
52+ * Kind>` populated by visitor side-effect. That model conflated
53+ * bindings across scopes — a function-local `const closure = new
54+ * Map()` propagated the `map` classification to every other
55+ * binding in the file named `closure`, including unrelated arrays
56+ * in the parent scope. The scope-walk path fixes that at the cost
57+ * of a per-lookup walk; rule lookups happen on `ForStatement` and
58+ * `MemberExpression` which are relatively rare, so the overhead is
59+ * bounded.
3760 */
3861
3962import type { AstNode } from './rule-types.mts'
@@ -57,6 +80,31 @@ export const FLAGGED_KINDS: ReadonlySet<Kind> = new Set([
5780 'iterable' ,
5881] )
5982
83+ const SCOPE_NODE_TYPES = new Set ( [
84+ 'Program' ,
85+ 'BlockStatement' ,
86+ 'ForStatement' ,
87+ 'ForOfStatement' ,
88+ 'ForInStatement' ,
89+ 'FunctionDeclaration' ,
90+ 'FunctionExpression' ,
91+ 'ArrowFunctionExpression' ,
92+ 'TSDeclareFunction' ,
93+ 'CatchClause' ,
94+ // Class body has its own lexical environment for method `this` etc.,
95+ // but doesn't host `let`/`const` declarations at the body level (only
96+ // method definitions). Including it doesn't hurt.
97+ 'ClassDeclaration' ,
98+ 'ClassExpression' ,
99+ ] )
100+
101+ const FUNCTION_NODE_TYPES = new Set ( [
102+ 'FunctionDeclaration' ,
103+ 'FunctionExpression' ,
104+ 'ArrowFunctionExpression' ,
105+ 'TSDeclareFunction' ,
106+ ] )
107+
60108/**
61109 * Classify a TS type-annotation AST node (the `: T` part of a
62110 * binding). Returns the kind, or `'unknown'` if the annotation is
@@ -146,67 +194,207 @@ export function classifyInit(init: AstNode | undefined): Kind {
146194}
147195
148196/**
149- * Wire the per-file kind-tracking visitors into a rule's visitor
150- * map. Returns the kinds Map and a record of visitor handlers the
151- * caller should merge into its own visitor return. Use:
197+ * Classify a single VariableDeclarator AST node. Type annotation
198+ * wins over inferred init kind (explicit > implicit).
199+ */
200+ function classifyVariableDeclarator ( declarator : AstNode ) : Kind {
201+ if ( ! declarator || ! declarator . id || declarator . id . type !== 'Identifier' ) {
202+ return 'unknown'
203+ }
204+ const annotated = classifyTypeAnnotation ( declarator . id . typeAnnotation )
205+ if ( annotated !== 'unknown' ) {
206+ return annotated
207+ }
208+ return classifyInit ( declarator . init )
209+ }
210+
211+ /**
212+ * Find a binding for `name` declared *directly* in the given scope
213+ * node (does not recurse into nested scopes). Returns the classified
214+ * Kind, or undefined if no such binding exists in this scope.
152215 *
153- * const { kinds, visitors } = trackKinds()
154- * return {
155- * ...visitors,
156- * ForStatement(node) { … kinds.get(name) … },
157- * }
216+ * Each scope-node type stores its declarations differently:
217+ *
218+ * - `Program` / `BlockStatement`: scan `body` for top-level
219+ * `VariableDeclaration` and `FunctionDeclaration` nodes.
220+ * - `Function*`: check the function's `params` for an Identifier
221+ * param named `name`. The body BlockStatement is a separate
222+ * scope (visited on the way up).
223+ * - `ForStatement`: check the `init` (a VariableDeclaration whose
224+ * declarators are scoped to the loop).
225+ * - `ForOfStatement` / `ForInStatement`: check the `left` (a
226+ * VariableDeclaration declaring the loop var, scoped to the loop).
227+ * - `CatchClause`: check the `param` Identifier.
158228 */
159- export function trackKinds ( ) : {
160- kinds : Map < string , Kind >
161- visitors : Record < string , ( node : AstNode ) => void >
162- } {
163- const kinds = new Map < string , Kind > ( )
229+ function findInScope ( scope : AstNode , name : string ) : Kind | undefined {
230+ if ( ! scope ) {
231+ return undefined
232+ }
164233
165- function record ( name : string | undefined , kind : Kind ) : void {
166- if ( ! name || kind === 'unknown' ) {
167- return
234+ // Function parameter scope.
235+ if ( FUNCTION_NODE_TYPES . has ( scope . type ) ) {
236+ const params : AstNode [ ] | undefined = scope . params
237+ if ( params ) {
238+ for ( let i = 0 , { length } = params ; i < length ; i += 1 ) {
239+ const p = params [ i ]
240+ if ( p && p . type === 'Identifier' && ( p . name as string ) === name ) {
241+ return classifyTypeAnnotation ( p . typeAnnotation )
242+ }
243+ }
168244 }
169- kinds . set ( name , kind )
245+ return undefined
170246 }
171247
172- function recordParams ( params : AstNode [ ] | undefined ) : void {
173- if ( ! params ) {
174- return
248+ // Catch clause: single Identifier param.
249+ if ( scope . type === 'CatchClause' ) {
250+ const p = scope . param
251+ if ( p && p . type === 'Identifier' && ( p . name as string ) === name ) {
252+ return classifyTypeAnnotation ( p . typeAnnotation )
175253 }
176- for ( let i = 0 , { length } = params ; i < length ; i += 1 ) {
177- const p = params [ i ]
178- if ( ! p || p . type !== 'Identifier' ) {
179- continue
254+ return undefined
255+ }
256+
257+ // for (let X = …; …; …) — declaration is in scope.init.
258+ if ( scope . type === 'ForStatement' ) {
259+ const init : AstNode | undefined = scope . init
260+ if ( init && init . type === 'VariableDeclaration' ) {
261+ const k = findInVariableDeclaration ( init , name )
262+ if ( k !== undefined ) {
263+ return k
180264 }
181- const name = p . name as string
182- record ( name , classifyTypeAnnotation ( p . typeAnnotation ) )
183265 }
266+ return undefined
184267 }
185268
186- return {
187- kinds,
188- visitors : {
189- VariableDeclarator ( node : AstNode ) {
190- if ( ! node . id || node . id . type !== 'Identifier' ) {
191- return
269+ // for (const X of …) / for (const X in …) — declaration is in scope.left.
270+ if (
271+ scope . type === 'ForOfStatement' ||
272+ scope . type === 'ForInStatement'
273+ ) {
274+ const left : AstNode | undefined = scope . left
275+ if ( left && left . type === 'VariableDeclaration' ) {
276+ const k = findInVariableDeclaration ( left , name )
277+ if ( k !== undefined ) {
278+ return k
279+ }
280+ }
281+ return undefined
282+ }
283+
284+ // Program or BlockStatement: scan body for declarations.
285+ if ( scope . type === 'Program' || scope . type === 'BlockStatement' ) {
286+ const body : AstNode [ ] | undefined = scope . body
287+ if ( ! body ) {
288+ return undefined
289+ }
290+ for ( let i = 0 , { length } = body ; i < length ; i += 1 ) {
291+ const stmt = body [ i ]
292+ if ( ! stmt ) {
293+ continue
294+ }
295+ if ( stmt . type === 'VariableDeclaration' ) {
296+ const k = findInVariableDeclaration ( stmt , name )
297+ if ( k !== undefined ) {
298+ return k
192299 }
193- const name = node . id . name as string
194- const annotated = classifyTypeAnnotation ( node . id . typeAnnotation )
195- if ( annotated !== 'unknown' ) {
196- record ( name , annotated )
197- return
300+ } else if (
301+ stmt . type === 'ExportNamedDeclaration' &&
302+ stmt . declaration &&
303+ stmt . declaration . type === 'VariableDeclaration'
304+ ) {
305+ const k = findInVariableDeclaration ( stmt . declaration , name )
306+ if ( k !== undefined ) {
307+ return k
198308 }
199- record ( name , classifyInit ( node . init ) )
200- } ,
201- FunctionDeclaration ( node : AstNode ) {
202- recordParams ( node . params )
203- } ,
204- FunctionExpression ( node : AstNode ) {
205- recordParams ( node . params )
206- } ,
207- ArrowFunctionExpression ( node : AstNode ) {
208- recordParams ( node . params )
209- } ,
210- } ,
309+ }
310+ }
311+ return undefined
312+ }
313+
314+ return undefined
315+ }
316+
317+ /**
318+ * Scan a VariableDeclaration node's declarators for one whose id is
319+ * `Identifier(name)`. Returns the classified Kind if found, else
320+ * undefined.
321+ */
322+ function findInVariableDeclaration (
323+ decl : AstNode ,
324+ name : string ,
325+ ) : Kind | undefined {
326+ const decls : AstNode [ ] | undefined = decl . declarations
327+ if ( ! decls ) {
328+ return undefined
329+ }
330+ for ( let i = 0 , { length } = decls ; i < length ; i += 1 ) {
331+ const d = decls [ i ]
332+ if (
333+ d &&
334+ d . id &&
335+ d . id . type === 'Identifier' &&
336+ ( d . id . name as string ) === name
337+ ) {
338+ return classifyVariableDeclarator ( d )
339+ }
211340 }
341+ return undefined
342+ }
343+
344+ /**
345+ * Resolve `name` as seen from the use-site `useNode`. Walks the
346+ * AST parent chain, checking each scope-creating ancestor for a
347+ * direct declaration of `name`. Returns the nearest enclosing
348+ * scope's classification, or `'unknown'` if no declaration is
349+ * found.
350+ *
351+ * The walk stops on the first declaring scope (JS lookup
352+ * semantics): a function-local `const closure = new Map()` shadows
353+ * an outer `const closure = await fn()` even if the inner is
354+ * declared "later" in source order, because they live in
355+ * different scopes and the use-site picks the nearest declaring
356+ * scope on its parent chain.
357+ */
358+ export function resolveKind ( useNode : AstNode , name : string ) : Kind {
359+ let cur : AstNode | undefined = useNode
360+ while ( cur ) {
361+ if ( SCOPE_NODE_TYPES . has ( cur . type ) ) {
362+ const k = findInScope ( cur , name )
363+ if ( k !== undefined ) {
364+ return k
365+ }
366+ }
367+ cur = cur . parent
368+ }
369+ return 'unknown'
370+ }
371+
372+ /**
373+ * Wire the scope-aware kind resolver into a rule. Returns
374+ * `resolveKind(useNode, name)` for the rule to call from its
375+ * use-site visitors (e.g. ForStatement / MemberExpression).
376+ *
377+ * Unlike the older `trackKinds()` API, this returns no visitors:
378+ * the resolver walks the AST on-demand instead of building a
379+ * pre-populated map. The trade-off is one parent-chain walk per
380+ * lookup vs. an O(file-size) population pass at create() time.
381+ * Lookups are scoped to rule call sites (ForStatement,
382+ * MemberExpression with a Set/Map LHS), so the per-lookup cost
383+ * is bounded.
384+ *
385+ * Usage:
386+ *
387+ * const resolveKind = createKindResolver()
388+ * return {
389+ * ForStatement(node) {
390+ * const kind = resolveKind(node, 'someName')
391+ * …
392+ * },
393+ * }
394+ */
395+ export function createKindResolver ( ) : (
396+ useNode : AstNode ,
397+ name : string ,
398+ ) => Kind {
399+ return resolveKind
212400}
0 commit comments