Skip to content

Commit 7ff14d0

Browse files
committed
chore(sync): cascade fleet template@f2ee346
Auto-applied by socket-wheelhouse sync-scaffolding into vscode-socket-security. 4 file(s) touched: - .config/oxlint-plugin/lib/iterable-kind.mts - .config/oxlint-plugin/rules/no-cached-for-on-iterable.mts - .config/oxlint-plugin/rules/prefer-cached-for-loop.mts - .config/oxlint-plugin/test/no-cached-for-on-iterable.test.mts
1 parent a22febc commit 7ff14d0

4 files changed

Lines changed: 320 additions & 70 deletions

File tree

.config/oxlint-plugin/lib/iterable-kind.mts

Lines changed: 241 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,33 @@
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

3962
import 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
}

.config/oxlint-plugin/rules/no-cached-for-on-iterable.mts

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,10 @@
7777
* diagnostic message names it explicitly.
7878
*/
7979

80-
import { FLAGGED_KINDS, trackKinds } from '../lib/iterable-kind.mts'
80+
import {
81+
FLAGGED_KINDS,
82+
createKindResolver,
83+
} from '../lib/iterable-kind.mts'
8184
import type { AstNode, RuleContext } from '../lib/rule-types.mts'
8285

8386
/**
@@ -140,19 +143,21 @@ const rule = {
140143
},
141144

142145
create(context: RuleContext) {
143-
// Per-file kind map + the visitors that populate it. Shared with
144-
// prefer-cached-for-loop via lib/iterable-kind.mts so both rules
145-
// agree on what "this binding is a Set/Map/Iterable" means.
146-
const { kinds, visitors } = trackKinds()
146+
// Scope-aware kind resolver. Shared with prefer-cached-for-loop
147+
// via lib/iterable-kind.mts so both rules agree on what "this
148+
// binding is a Set/Map/Iterable" means — including under
149+
// shadowing (a function-local `const closure = new Map()`
150+
// does NOT taint an outer-scope `const closure = await fn()`
151+
// array binding).
152+
const resolveKind = createKindResolver()
147153

148154
return {
149-
...visitors,
150155
ForStatement(node: AstNode) {
151156
const iterName = matchCachedForInit(node.init)
152157
if (!iterName) {
153158
return
154159
}
155-
const kind = kinds.get(iterName) ?? 'unknown'
160+
const kind = resolveKind(node, iterName)
156161
if (!FLAGGED_KINDS.has(kind)) {
157162
return
158163
}
@@ -170,7 +175,7 @@ const rule = {
170175
return
171176
}
172177
const name = node.object.name as string
173-
const kind = kinds.get(name) ?? 'unknown'
178+
const kind = resolveKind(node, name)
174179
if (!FLAGGED_KINDS.has(kind)) {
175180
return
176181
}

0 commit comments

Comments
 (0)