@ontrails/source 1.0.0-beta.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/scopes.ts ADDED
@@ -0,0 +1,517 @@
1
+ /** Shared lexical-scope helpers for AST analysis. */
2
+
3
+ import { ScopeTracker } from 'oxc-walker';
4
+ import type { ScopeTrackerNode } from 'oxc-walker';
5
+
6
+ import type { AstNode, AstScopeContext, AstScopeDeclaration } from './nodes.js';
7
+ import { identifierName } from './literals.js';
8
+ import { walkChildren, walkWithOxcFacade } from './walk.js';
9
+ import type { WalkFn } from './walk.js';
10
+
11
+ const toScopeDeclaration = (
12
+ declaration: ScopeTrackerNode | null
13
+ ): AstScopeDeclaration | null => {
14
+ if (!declaration) {
15
+ return null;
16
+ }
17
+
18
+ return {
19
+ end: declaration.end,
20
+ node: declaration.node as unknown as AstNode,
21
+ start: declaration.start,
22
+ type: declaration.type,
23
+ };
24
+ };
25
+
26
+ export const walkWithScopeContext = (
27
+ node: unknown,
28
+ visit: (node: AstNode, context: AstScopeContext) => void
29
+ ): void => {
30
+ const scopeTracker = new ScopeTracker();
31
+
32
+ walkWithOxcFacade(
33
+ node,
34
+ (candidate, context) => {
35
+ visit(candidate, {
36
+ ...context,
37
+ currentScope: scopeTracker.getCurrentScope(),
38
+ getDeclaration: (name) =>
39
+ toScopeDeclaration(scopeTracker.getDeclaration(name)),
40
+ isCurrentScopeUnder: (scope) => scopeTracker.isCurrentScopeUnder(scope),
41
+ isDeclared: (name) => scopeTracker.isDeclared(name),
42
+ });
43
+ },
44
+ scopeTracker
45
+ );
46
+ };
47
+
48
+ const NESTED_SCOPE_TYPES = new Set([
49
+ 'ArrowFunctionExpression',
50
+ 'FunctionExpression',
51
+ 'FunctionDeclaration',
52
+ ]);
53
+
54
+ const walkScopeInner: WalkFn = (node, visit) => {
55
+ if (!node || typeof node !== 'object') {
56
+ return;
57
+ }
58
+ const n = node as AstNode;
59
+ if (n.type) {
60
+ visit(n);
61
+ if (NESTED_SCOPE_TYPES.has(n.type)) {
62
+ return;
63
+ }
64
+ }
65
+ walkChildren(n, visit, walkScopeInner);
66
+ };
67
+
68
+ /**
69
+ * Walk an AST node tree without descending into nested function scopes.
70
+ * The root node is always traversed; only inner function boundaries are skipped.
71
+ * Useful for resource-access analysis where inner functions may shadow
72
+ * the trail context parameter name.
73
+ */
74
+ export const walkScope: WalkFn = (node, visit) => {
75
+ if (!node || typeof node !== 'object') {
76
+ return;
77
+ }
78
+ const n = node as AstNode;
79
+ if (n.type) {
80
+ visit(n);
81
+ }
82
+ walkChildren(n, visit, walkScopeInner);
83
+ };
84
+
85
+ type PatternExpander = (node: AstNode) => readonly AstNode[];
86
+
87
+ const expandAssignmentPattern: PatternExpander = (node) => {
88
+ const { left } = node as unknown as { left?: AstNode };
89
+ return left ? [left] : [];
90
+ };
91
+
92
+ const expandRestElement: PatternExpander = (node) => {
93
+ const { argument } = node as unknown as { argument?: AstNode };
94
+ return argument ? [argument] : [];
95
+ };
96
+
97
+ const expandArrayPattern: PatternExpander = (node) => {
98
+ const elements =
99
+ (node as unknown as { elements?: readonly (AstNode | null)[] }).elements ??
100
+ [];
101
+ return elements.filter((e): e is AstNode => e !== null);
102
+ };
103
+
104
+ const expandObjectPatternProperty = (prop: AstNode): AstNode | null => {
105
+ if (prop.type === 'RestElement') {
106
+ return prop;
107
+ }
108
+ const { value } = prop as unknown as { value?: AstNode };
109
+ return value ?? null;
110
+ };
111
+
112
+ const expandObjectPattern: PatternExpander = (node) => {
113
+ const properties =
114
+ (node as unknown as { properties?: readonly AstNode[] }).properties ?? [];
115
+ return properties
116
+ .map(expandObjectPatternProperty)
117
+ .filter((n): n is AstNode => n !== null);
118
+ };
119
+
120
+ const PATTERN_EXPANDERS: Record<string, PatternExpander> = {
121
+ ArrayPattern: expandArrayPattern,
122
+ AssignmentPattern: expandAssignmentPattern,
123
+ ObjectPattern: expandObjectPattern,
124
+ RestElement: expandRestElement,
125
+ };
126
+
127
+ const processPatternNode = (
128
+ node: AstNode,
129
+ into: Set<string>,
130
+ stack: AstNode[]
131
+ ): void => {
132
+ if (node.type === 'Identifier') {
133
+ const { name } = node as unknown as { name?: string };
134
+ if (name) {
135
+ into.add(name);
136
+ }
137
+ return;
138
+ }
139
+ const expand = PATTERN_EXPANDERS[node.type];
140
+ if (expand) {
141
+ stack.push(...expand(node));
142
+ }
143
+ };
144
+
145
+ const addPatternBindingNames = (
146
+ pattern: AstNode | undefined,
147
+ into: Set<string>
148
+ ): void => {
149
+ if (!pattern) {
150
+ return;
151
+ }
152
+ const stack: AstNode[] = [pattern];
153
+ while (stack.length > 0) {
154
+ const node = stack.pop();
155
+ if (node) {
156
+ processPatternNode(node, into, stack);
157
+ }
158
+ }
159
+ };
160
+
161
+ const addVarDeclarationBindingNames = (
162
+ decl: AstNode,
163
+ into: Set<string>
164
+ ): void => {
165
+ const declarations =
166
+ (decl as unknown as { declarations?: readonly AstNode[] }).declarations ??
167
+ [];
168
+ for (const d of declarations) {
169
+ addPatternBindingNames((d as unknown as { id?: AstNode }).id, into);
170
+ }
171
+ };
172
+
173
+ const addFunctionOrClassBindingName = (
174
+ node: AstNode,
175
+ into: Set<string>
176
+ ): void => {
177
+ const { id } = node as unknown as { id?: AstNode };
178
+ const name = identifierName(id);
179
+ if (name) {
180
+ into.add(name);
181
+ }
182
+ };
183
+
184
+ const addBlockStatementBindings = (stmt: AstNode, into: Set<string>): void => {
185
+ if (stmt.type === 'VariableDeclaration') {
186
+ addVarDeclarationBindingNames(stmt, into);
187
+ return;
188
+ }
189
+ if (
190
+ stmt.type === 'FunctionDeclaration' ||
191
+ stmt.type === 'ClassDeclaration' ||
192
+ stmt.type === 'TSEnumDeclaration' ||
193
+ stmt.type === 'TSModuleDeclaration'
194
+ ) {
195
+ addFunctionOrClassBindingName(stmt, into);
196
+ }
197
+ };
198
+
199
+ const collectTopLevelStatementBindings = (
200
+ stmt: AstNode,
201
+ into: Set<string>
202
+ ): void => {
203
+ if (
204
+ stmt.type === 'ExportNamedDeclaration' ||
205
+ stmt.type === 'ExportDefaultDeclaration'
206
+ ) {
207
+ const { declaration } = stmt as unknown as { declaration?: AstNode };
208
+ if (declaration) {
209
+ collectTopLevelStatementBindings(declaration, into);
210
+ }
211
+ return;
212
+ }
213
+ addBlockStatementBindings(stmt, into);
214
+ };
215
+
216
+ const FUNCTION_BOUNDARY_TYPES = new Set([
217
+ 'ArrowFunctionExpression',
218
+ 'FunctionDeclaration',
219
+ 'FunctionExpression',
220
+ 'StaticBlock',
221
+ ]);
222
+
223
+ export const forEachAstChild = (
224
+ node: AstNode,
225
+ visit: (child: AstNode) => void
226
+ ): void => {
227
+ for (const val of Object.values(node)) {
228
+ if (Array.isArray(val)) {
229
+ for (const item of val) {
230
+ if (item && typeof item === 'object' && (item as AstNode).type) {
231
+ visit(item as AstNode);
232
+ }
233
+ }
234
+ } else if (val && typeof val === 'object' && (val as AstNode).type) {
235
+ visit(val as AstNode);
236
+ }
237
+ }
238
+ };
239
+
240
+ const recordHoistedBinding = (
241
+ node: AstNode,
242
+ into: Set<string>,
243
+ inNestedBlock: boolean
244
+ ): void => {
245
+ if (node.type === 'VariableDeclaration') {
246
+ const { kind } = node as unknown as { kind?: string };
247
+ if (kind === 'var') {
248
+ addVarDeclarationBindingNames(node, into);
249
+ }
250
+ return;
251
+ }
252
+ // In strict/module code, function/class/enum/module declarations inside a
253
+ // nested block (`if { function foo() {} }`, `switch` case, etc.) are
254
+ // block-scoped. Only hoist them to the enclosing function frame when they
255
+ // sit directly in the function body, not inside a further block.
256
+ if (inNestedBlock) {
257
+ return;
258
+ }
259
+ if (
260
+ node.type === 'FunctionDeclaration' ||
261
+ node.type === 'ClassDeclaration' ||
262
+ node.type === 'TSEnumDeclaration' ||
263
+ node.type === 'TSModuleDeclaration'
264
+ ) {
265
+ addFunctionOrClassBindingName(node, into);
266
+ }
267
+ };
268
+
269
+ const NESTED_BLOCK_BOUNDARY_TYPES = new Set([
270
+ 'BlockStatement',
271
+ 'ForStatement',
272
+ 'ForInStatement',
273
+ 'ForOfStatement',
274
+ 'SwitchStatement',
275
+ 'CatchClause',
276
+ ]);
277
+
278
+ const visitForHoisted = (
279
+ node: AstNode,
280
+ isRoot: boolean,
281
+ into: Set<string>,
282
+ inNestedBlock: boolean
283
+ ): void => {
284
+ if (!isRoot && FUNCTION_BOUNDARY_TYPES.has(node.type)) {
285
+ return;
286
+ }
287
+ recordHoistedBinding(node, into, inNestedBlock);
288
+ const childInNestedBlock =
289
+ inNestedBlock || (!isRoot && NESTED_BLOCK_BOUNDARY_TYPES.has(node.type));
290
+ forEachAstChild(node, (child) => {
291
+ visitForHoisted(child, false, into, childInNestedBlock);
292
+ });
293
+ };
294
+
295
+ /**
296
+ * Collect `var` declarations and `function` declarations hoisted to the
297
+ * nearest function scope from anywhere inside `root`, without composing a
298
+ * nested function or static-block boundary.
299
+ */
300
+ const collectHoistedVarAndFunctionBindings = (
301
+ root: AstNode,
302
+ into: Set<string>
303
+ ): void => {
304
+ visitForHoisted(root, true, into, false);
305
+ };
306
+
307
+ type FrameCollector = (node: AstNode, into: Set<string>) => void;
308
+
309
+ const collectProgramFrame: FrameCollector = (node, into) => {
310
+ const body = (node as unknown as { body?: readonly AstNode[] }).body ?? [];
311
+ for (const stmt of body) {
312
+ collectTopLevelStatementBindings(stmt, into);
313
+ }
314
+ };
315
+
316
+ const collectFunctionFrame: FrameCollector = (node, into) => {
317
+ const params =
318
+ (node as unknown as { params?: readonly AstNode[] }).params ?? [];
319
+ for (const param of params) {
320
+ addPatternBindingNames(param, into);
321
+ }
322
+ // Hoisted vars and function declarations inside the body live in the
323
+ // function's var-environment. A `var ns = ...;` inside an `if` still
324
+ // shadows a module-level `ns` for the whole function.
325
+ const { body } = node as unknown as { body?: AstNode };
326
+ if (body) {
327
+ collectHoistedVarAndFunctionBindings(body, into);
328
+ }
329
+ };
330
+
331
+ const collectBlockFrame: FrameCollector = (node, into) => {
332
+ const body = (node as unknown as { body?: readonly AstNode[] }).body ?? [];
333
+ for (const stmt of body) {
334
+ addBlockStatementBindings(stmt, into);
335
+ }
336
+ };
337
+
338
+ const collectForStatementFrame: FrameCollector = (node, into) => {
339
+ const { init } = node as unknown as { init?: AstNode };
340
+ if (init && init.type === 'VariableDeclaration') {
341
+ addVarDeclarationBindingNames(init, into);
342
+ }
343
+ };
344
+
345
+ const collectForInOfFrame: FrameCollector = (node, into) => {
346
+ const { left } = node as unknown as { left?: AstNode };
347
+ if (left && left.type === 'VariableDeclaration') {
348
+ addVarDeclarationBindingNames(left, into);
349
+ }
350
+ };
351
+
352
+ const collectSwitchStatementFrame: FrameCollector = (node, into) => {
353
+ // `switch` shares one scope across every case. A binding in one case
354
+ // shadows the namespace across sibling cases (fall-through or otherwise).
355
+ const cases = (node as unknown as { cases?: readonly AstNode[] }).cases ?? [];
356
+ for (const c of cases) {
357
+ const consequent =
358
+ (c as unknown as { consequent?: readonly AstNode[] }).consequent ?? [];
359
+ for (const stmt of consequent) {
360
+ addBlockStatementBindings(stmt, into);
361
+ }
362
+ }
363
+ };
364
+
365
+ const collectCatchClauseFrame: FrameCollector = (node, into) => {
366
+ const { param } = node as unknown as { param?: AstNode };
367
+ addPatternBindingNames(param, into);
368
+ };
369
+
370
+ const collectClassExpressionFrame: FrameCollector = (node, into) => {
371
+ // A named `class expr` (`const C = class foo { ... }`) binds its own name
372
+ // inside its body only. ClassDeclaration names are hoisted into the
373
+ // enclosing block/program frame instead, so only class *expression* names
374
+ // need their own frame here.
375
+ addFunctionOrClassBindingName(node, into);
376
+ };
377
+
378
+ export const SCOPE_FRAME_COLLECTORS: Record<string, FrameCollector> = {
379
+ ArrowFunctionExpression: collectFunctionFrame,
380
+ BlockStatement: collectBlockFrame,
381
+ CatchClause: collectCatchClauseFrame,
382
+ ClassExpression: collectClassExpressionFrame,
383
+ ForInStatement: collectForInOfFrame,
384
+ ForOfStatement: collectForInOfFrame,
385
+ ForStatement: collectForStatementFrame,
386
+ // oxc-parser emits `FunctionBody` for `function` expression bodies; without
387
+ // this entry, a `const ns = ...` at the top of a function-expression body
388
+ // would not push a scope frame, and a module-level namespace import with
389
+ // the same name would be incorrectly recognized inside.
390
+ FunctionBody: collectBlockFrame,
391
+ FunctionDeclaration: collectFunctionFrame,
392
+ FunctionExpression: collectFunctionFrame,
393
+ Program: collectProgramFrame,
394
+ StaticBlock: collectBlockFrame,
395
+ SwitchStatement: collectSwitchStatementFrame,
396
+ };
397
+
398
+ /**
399
+ * Collect the identifier bindings introduced *directly* by a scope frame
400
+ * node. Scope frames correspond to JS lexical scopes (function bodies, blocks,
401
+ * catch clauses, for-statements, switch statements, module/script roots).
402
+ */
403
+ export const collectScopeFrameBindings = (
404
+ node: AstNode
405
+ ): ReadonlySet<string> => {
406
+ const names = new Set<string>();
407
+ const collector = SCOPE_FRAME_COLLECTORS[node.type];
408
+ if (collector) {
409
+ collector(node, names);
410
+ }
411
+ return names;
412
+ };
413
+
414
+ export type ScopeAwareVisitor = (
415
+ node: AstNode,
416
+ scopes: readonly ReadonlySet<string>[]
417
+ ) => void;
418
+
419
+ export interface ScopeWalkOptions {
420
+ readonly initialScopes?: readonly ReadonlySet<string>[];
421
+ readonly stopAtNestedFunctions?: boolean;
422
+ }
423
+
424
+ const asAstNode = (node: unknown): AstNode | null => {
425
+ if (!node || typeof node !== 'object') {
426
+ return null;
427
+ }
428
+ const astNode = node as AstNode;
429
+ return astNode.type ? astNode : null;
430
+ };
431
+
432
+ /**
433
+ * Walk an AST subtree while threading lexical scope bindings through each
434
+ * visit. Callers can seed outer scopes and optionally stop at nested function
435
+ * boundaries when only the current implementation body should be analyzed.
436
+ */
437
+ export const walkWithScopes = (
438
+ node: unknown,
439
+ visit: ScopeAwareVisitor,
440
+ options: ScopeWalkOptions = {}
441
+ ): void => {
442
+ const root = asAstNode(node);
443
+ if (!root) {
444
+ return;
445
+ }
446
+
447
+ const stack = [...(options.initialScopes ?? [])];
448
+
449
+ const walkNode = (current: AstNode, isRoot: boolean): void => {
450
+ if (
451
+ !isRoot &&
452
+ options.stopAtNestedFunctions &&
453
+ FUNCTION_BOUNDARY_TYPES.has(current.type)
454
+ ) {
455
+ return;
456
+ }
457
+
458
+ const isScope = current.type in SCOPE_FRAME_COLLECTORS;
459
+ if (isScope) {
460
+ stack.unshift(collectScopeFrameBindings(current));
461
+ }
462
+
463
+ try {
464
+ visit(current, stack);
465
+ forEachAstChild(current, (child) => {
466
+ walkNode(child, false);
467
+ });
468
+ } finally {
469
+ if (isScope) {
470
+ stack.shift();
471
+ }
472
+ }
473
+ };
474
+
475
+ walkNode(root, true);
476
+ };
477
+
478
+ export const isShadowed = (
479
+ receiverName: string,
480
+ scopeStack: readonly ReadonlySet<string>[]
481
+ ): boolean => {
482
+ // The module-level Program frame is the last entry and contains the
483
+ // namespace imports themselves. A "shadow" must come from a frame *inside*
484
+ // that one — i.e. any frame except the outermost.
485
+ for (let i = 0; i < scopeStack.length - 1; i += 1) {
486
+ const frame = scopeStack[i];
487
+ if (frame?.has(receiverName)) {
488
+ return true;
489
+ }
490
+ }
491
+ return false;
492
+ };
493
+
494
+ /**
495
+ * Return `true` when `node` is a non-computed member access (`a.b` /
496
+ * `a?.b`) and `false` for anything else, including computed access
497
+ * (`a[b]`) or non-member nodes. Exported as the canonical predicate so
498
+ * rule modules do not re-implement the check.
499
+ *
500
+ * @remarks
501
+ * Declared near the top of the file so the scope walker can use it
502
+ * without hitting `no-use-before-define`. A few sibling helpers in this
503
+ * module still inline the same shape under different local names for
504
+ * historical reasons; prefer this export for new call sites.
505
+ */
506
+ export const isMemberAccessNonComputed = (node: AstNode): boolean => {
507
+ if (
508
+ node.type !== 'MemberExpression' &&
509
+ node.type !== 'StaticMemberExpression'
510
+ ) {
511
+ return false;
512
+ }
513
+ return (node as unknown as { computed?: boolean }).computed !== true;
514
+ };
515
+
516
+ export const isScopeFrameNode = (node: AstNode): boolean =>
517
+ node.type in SCOPE_FRAME_COLLECTORS;