@ontrails/warden 1.0.0-beta.39 → 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.
Files changed (134) hide show
  1. package/CHANGELOG.md +125 -0
  2. package/README.md +5 -5
  3. package/package.json +10 -12
  4. package/src/cli.ts +49 -44
  5. package/src/command.ts +2 -2
  6. package/src/drift.ts +4 -3
  7. package/src/index.ts +3 -2
  8. package/src/project-context.ts +3 -3
  9. package/src/resolve.ts +2 -2
  10. package/src/rules/captured-kernel.ts +375 -0
  11. package/src/rules/circular-refs.ts +12 -16
  12. package/src/rules/cli-command-route-coherence.ts +1 -1
  13. package/src/rules/composes-declarations.ts +15 -13
  14. package/src/rules/context-no-surface-types.ts +4 -3
  15. package/src/rules/dead-internal-trail.ts +3 -3
  16. package/src/rules/dead-public-trail.ts +3 -3
  17. package/src/rules/draft-file-marking.ts +4 -9
  18. package/src/rules/draft-visible-debt.ts +3 -8
  19. package/src/rules/duplicate-public-contract.ts +50 -5
  20. package/src/rules/{contour-exists.ts → entity-exists.ts} +67 -64
  21. package/src/rules/entity-ids.ts +15 -0
  22. package/src/rules/error-mapping-completeness.ts +2 -2
  23. package/src/rules/example-valid.ts +84 -84
  24. package/src/rules/fires-declarations.ts +27 -22
  25. package/src/rules/governed-symbol-residue.ts +339 -32
  26. package/src/rules/implementation-returns-result.ts +8 -8
  27. package/src/rules/incomplete-accessor-for-standard-op.ts +15 -15
  28. package/src/rules/incomplete-crud.ts +42 -40
  29. package/src/rules/index.ts +9 -6
  30. package/src/rules/intent-propagation.ts +3 -5
  31. package/src/rules/layer-field-name-drift.ts +2 -2
  32. package/src/rules/library-projection-coherence.ts +2 -2
  33. package/src/rules/metadata.ts +37 -15
  34. package/src/rules/missing-reconcile.ts +3 -4
  35. package/src/rules/missing-visibility.ts +2 -1
  36. package/src/rules/no-destructured-compose.ts +14 -12
  37. package/src/rules/no-direct-implementation-call.ts +12 -7
  38. package/src/rules/no-legacy-cli-alias-export.ts +2 -2
  39. package/src/rules/no-native-error-result.ts +2 -2
  40. package/src/rules/no-redundant-result-error-wrap.ts +10 -10
  41. package/src/rules/no-sync-result-assumption.ts +84 -78
  42. package/src/rules/no-throw-in-detour-recover.ts +2 -2
  43. package/src/rules/no-throw-in-implementation.ts +13 -7
  44. package/src/rules/no-top-level-surface.ts +2 -2
  45. package/src/rules/on-references-exist.ts +3 -3
  46. package/src/rules/orphaned-signal.ts +4 -5
  47. package/src/rules/owner-projection-parity.ts +2 -2
  48. package/src/rules/public-export-example-coverage.ts +10 -4
  49. package/src/rules/public-internal-deep-imports.ts +2 -3
  50. package/src/rules/read-intent-fires.ts +4 -3
  51. package/src/rules/reference-exists.ts +20 -21
  52. package/src/rules/registry-names.ts +6 -4
  53. package/src/rules/resolved-import-boundary.ts +1 -1
  54. package/src/rules/resource-declarations.ts +21 -17
  55. package/src/rules/resource-exists.ts +4 -2
  56. package/src/rules/resource-id-grammar.ts +2 -2
  57. package/src/rules/resource-mock-coverage.ts +2 -2
  58. package/src/rules/retired-vocabulary.ts +242 -5
  59. package/src/rules/source/composition.ts +165 -0
  60. package/src/rules/source/drafts.ts +164 -0
  61. package/src/rules/source/entities.ts +618 -0
  62. package/src/rules/source/pragmas.ts +45 -0
  63. package/src/rules/source/resources.ts +64 -0
  64. package/src/rules/source/signals.ts +397 -0
  65. package/src/rules/source/stores.ts +310 -0
  66. package/src/rules/static-resource-accessor-preference.ts +15 -11
  67. package/src/rules/surface-overlay-coherence.ts +1 -1
  68. package/src/rules/surface-trailhead-coherence.ts +2 -2
  69. package/src/rules/trail-fork-coaching.ts +27 -18
  70. package/src/rules/trail-versioning-source.ts +16 -12
  71. package/src/rules/trail-versioning-topo.ts +2 -2
  72. package/src/rules/trailhead-override-divergence.ts +2 -2
  73. package/src/rules/types.ts +7 -7
  74. package/src/rules/unmaterialized-activation-source.ts +1 -0
  75. package/src/rules/unreachable-detour-shadowing.ts +2 -2
  76. package/src/rules/valid-describe-refs.ts +2 -2
  77. package/src/rules/warden-export-symmetry.ts +3 -3
  78. package/src/rules/warden-rules-use-ast.ts +12 -17
  79. package/src/trails/activation-orphan.trail.ts +5 -5
  80. package/src/trails/captured-kernel.trail.ts +108 -0
  81. package/src/trails/circular-refs.trail.ts +6 -6
  82. package/src/trails/cli-command-route-coherence.trail.ts +2 -2
  83. package/src/trails/composes-declarations.trail.ts +1 -1
  84. package/src/trails/context-no-surface-types.trail.ts +1 -1
  85. package/src/trails/dead-internal-trail.trail.ts +2 -2
  86. package/src/trails/dead-public-trail.trail.ts +1 -1
  87. package/src/trails/deprecation-without-guidance.trail.ts +1 -1
  88. package/src/trails/duplicate-public-contract.trail.ts +3 -3
  89. package/src/trails/entity-exists.trail.ts +21 -0
  90. package/src/trails/example-valid.trail.ts +3 -3
  91. package/src/trails/fires-declarations.trail.ts +1 -1
  92. package/src/trails/{fork-without-preserved-blaze.trail.ts → fork-without-preserved-implementation.trail.ts} +4 -4
  93. package/src/trails/implementation-returns-result.trail.ts +1 -1
  94. package/src/trails/incomplete-accessor-for-standard-op.trail.ts +4 -4
  95. package/src/trails/index.ts +3 -2
  96. package/src/trails/intent-propagation.trail.ts +2 -2
  97. package/src/trails/library-projection-coherence.trail.ts +2 -2
  98. package/src/trails/marker-schema-unsupported.trail.ts +1 -1
  99. package/src/trails/missing-visibility.trail.ts +1 -1
  100. package/src/trails/no-destructured-compose.trail.ts +5 -5
  101. package/src/trails/no-direct-implementation-call.trail.ts +1 -1
  102. package/src/trails/no-redundant-result-error-wrap.trail.ts +2 -2
  103. package/src/trails/no-sync-result-assumption.trail.ts +2 -2
  104. package/src/trails/no-throw-in-detour-recover.trail.ts +1 -1
  105. package/src/trails/no-throw-in-implementation.trail.ts +1 -1
  106. package/src/trails/on-references-exist.trail.ts +1 -1
  107. package/src/trails/pending-force.trail.ts +1 -1
  108. package/src/trails/permit-governance.trail.ts +2 -2
  109. package/src/trails/prefer-schema-inference.trail.ts +1 -1
  110. package/src/trails/public-output-schema.trail.ts +2 -2
  111. package/src/trails/public-union-output-discriminants.trail.ts +1 -1
  112. package/src/trails/read-intent-fires.trail.ts +1 -1
  113. package/src/trails/reference-exists.trail.ts +5 -5
  114. package/src/trails/resource-declarations.trail.ts +1 -1
  115. package/src/trails/resource-exists.trail.ts +1 -1
  116. package/src/trails/run.ts +4 -6
  117. package/src/trails/scheduled-destroy-intent.trail.ts +2 -2
  118. package/src/trails/schema.ts +9 -9
  119. package/src/trails/signal-graph-coaching.trail.ts +2 -2
  120. package/src/trails/static-resource-accessor-preference.trail.ts +1 -1
  121. package/src/trails/surface-overlay-coherence.trail.ts +1 -1
  122. package/src/trails/trail-fork-coaching.trail.ts +1 -1
  123. package/src/trails/unmaterialized-activation-source.trail.ts +5 -5
  124. package/src/trails/valid-detour-contract.trail.ts +1 -1
  125. package/src/trails/version-gap.trail.ts +1 -1
  126. package/src/trails/version-pinned-compose.trail.ts +1 -1
  127. package/src/trails/version-without-examples.trail.ts +1 -1
  128. package/src/trails/warden-rules-use-ast.trail.ts +2 -2
  129. package/src/trails/webhook-route-collision.trail.ts +2 -2
  130. package/src/trails/wrap-rule.ts +17 -17
  131. package/src/ast.ts +0 -137
  132. package/src/rules/ast.ts +0 -4143
  133. package/src/rules/contour-ids.ts +0 -15
  134. package/src/trails/contour-exists.trail.ts +0 -21
package/src/rules/ast.ts DELETED
@@ -1,4143 +0,0 @@
1
- /**
2
- * Shared AST utilities for warden rules.
3
- *
4
- * Uses oxc-parser for native-speed TypeScript parsing. Provides a lightweight
5
- * walker and helpers for finding blaze bodies.
6
- */
7
-
8
- import { existsSync, readFileSync } from 'node:fs';
9
- import { basename, dirname, join, resolve } from 'node:path';
10
- import { fileURLToPath } from 'node:url';
11
-
12
- import { DRAFT_ID_PREFIX, intentValues } from '@ontrails/core';
13
- import type { Intent } from '@ontrails/core';
14
- import { parseSync } from 'oxc-parser';
15
- import { ScopeTracker, walk as walkWithOxc } from 'oxc-walker';
16
- import type {
17
- ScopeTrackerNode,
18
- WalkerCallbackContext,
19
- WalkOptions,
20
- } from 'oxc-walker';
21
-
22
- // ---------------------------------------------------------------------------
23
- // Types (minimal, avoiding full @oxc-project/types dep)
24
- // ---------------------------------------------------------------------------
25
-
26
- export interface AstNode {
27
- readonly type: string;
28
- readonly start: number;
29
- readonly end: number;
30
- readonly parent?: AstNode | null;
31
- readonly key?: unknown;
32
- readonly value?: unknown;
33
- readonly body?: AstNode | readonly AstNode[];
34
- readonly [key: string]: unknown;
35
- }
36
-
37
- export interface ProgramNode extends AstNode {
38
- readonly type: 'Program';
39
- readonly body?: readonly AstNode[];
40
- }
41
-
42
- export interface IdentifierNode extends AstNode {
43
- readonly type: 'Identifier';
44
- readonly name?: string;
45
- }
46
-
47
- export interface StringLiteralNode extends AstNode {
48
- readonly type: 'Literal' | 'StringLiteral';
49
- readonly value?: unknown;
50
- }
51
-
52
- export interface CallExpressionNode extends AstNode {
53
- readonly type: 'CallExpression' | 'NewExpression';
54
- readonly arguments?: readonly AstNode[];
55
- readonly callee?: AstNode;
56
- }
57
-
58
- export interface MemberExpressionNode extends AstNode {
59
- readonly type: 'MemberExpression' | 'StaticMemberExpression';
60
- readonly computed?: boolean;
61
- readonly object?: AstNode;
62
- readonly property?: AstNode;
63
- }
64
-
65
- export interface ImportDeclarationNode extends AstNode {
66
- readonly type: 'ImportDeclaration';
67
- readonly source?: AstNode;
68
- readonly specifiers?: readonly AstNode[];
69
- }
70
-
71
- export interface ImportSpecifierNode extends AstNode {
72
- readonly type:
73
- | 'ImportDefaultSpecifier'
74
- | 'ImportNamespaceSpecifier'
75
- | 'ImportSpecifier';
76
- readonly imported?: AstNode;
77
- readonly local?: AstNode;
78
- }
79
-
80
- export interface ExportDeclarationNode extends AstNode {
81
- readonly type:
82
- | 'ExportAllDeclaration'
83
- | 'ExportDefaultDeclaration'
84
- | 'ExportNamedDeclaration';
85
- readonly declaration?: AstNode;
86
- readonly source?: AstNode;
87
- readonly specifiers?: readonly AstNode[];
88
- }
89
-
90
- export interface ExportSpecifierNode extends AstNode {
91
- readonly type: 'ExportSpecifier';
92
- readonly exportKind?: string;
93
- readonly exported?: AstNode;
94
- readonly local?: AstNode;
95
- }
96
-
97
- export interface VariableDeclarationNode extends AstNode {
98
- readonly type: 'VariableDeclaration';
99
- readonly declarations?: readonly AstNode[];
100
- readonly kind?: string;
101
- }
102
-
103
- export interface VariableDeclaratorNode extends AstNode {
104
- readonly type: 'VariableDeclarator';
105
- readonly id?: AstNode;
106
- readonly init?: AstNode;
107
- }
108
-
109
- export interface DeclarationWithIdNode extends AstNode {
110
- readonly type:
111
- | 'ClassDeclaration'
112
- | 'EnumDeclaration'
113
- | 'FunctionDeclaration'
114
- | 'InterfaceDeclaration'
115
- | 'TSInterfaceDeclaration'
116
- | 'TSEnumDeclaration'
117
- | 'TSTypeAliasDeclaration';
118
- readonly id?: AstNode;
119
- }
120
-
121
- export interface ClassMemberNode extends AstNode {
122
- readonly type: 'MethodDefinition' | 'PropertyDefinition';
123
- readonly computed?: boolean;
124
- readonly key?: AstNode;
125
- readonly value?: AstNode;
126
- }
127
-
128
- export interface ArrayExpressionNode extends AstNode {
129
- readonly type: 'ArrayExpression';
130
- readonly elements?: readonly (AstNode | null)[];
131
- }
132
-
133
- export interface ObjectExpressionNode extends AstNode {
134
- readonly type: 'ObjectExpression' | 'ObjectPattern';
135
- readonly properties?: readonly AstNode[];
136
- }
137
-
138
- export interface PropertyNode extends AstNode {
139
- readonly type: 'Property';
140
- readonly computed?: boolean;
141
- readonly key?: AstNode;
142
- readonly value?: AstNode;
143
- }
144
-
145
- export interface RestElementNode extends AstNode {
146
- readonly type: 'RestElement';
147
- readonly argument?: AstNode;
148
- }
149
-
150
- export interface AssignmentPatternNode extends AstNode {
151
- readonly type: 'AssignmentPattern';
152
- readonly left?: AstNode;
153
- readonly right?: AstNode;
154
- }
155
-
156
- export interface ExpressionStatementNode extends AstNode {
157
- readonly type: 'ExpressionStatement';
158
- readonly expression?: AstNode;
159
- }
160
-
161
- export interface UnaryExpressionNode extends AstNode {
162
- readonly type: 'AwaitExpression' | 'ChainExpression' | 'UnaryExpression';
163
- readonly argument?: AstNode;
164
- }
165
-
166
- export interface BinaryExpressionNode extends AstNode {
167
- readonly type:
168
- | 'AssignmentExpression'
169
- | 'BinaryExpression'
170
- | 'ConditionalExpression'
171
- | 'LogicalExpression';
172
- readonly alternate?: AstNode;
173
- readonly consequent?: AstNode;
174
- readonly left?: AstNode;
175
- readonly operator?: string;
176
- readonly right?: AstNode;
177
- readonly test?: AstNode;
178
- }
179
-
180
- export interface FunctionLikeNode extends AstNode {
181
- readonly type:
182
- | 'ArrowFunctionExpression'
183
- | 'FunctionDeclaration'
184
- | 'FunctionExpression';
185
- readonly body?: AstNode;
186
- readonly params?: readonly AstNode[];
187
- }
188
-
189
- export interface BlockStatementNode extends AstNode {
190
- readonly type: 'BlockStatement' | 'StaticBlock';
191
- readonly body?: readonly AstNode[];
192
- }
193
-
194
- export interface ReturnStatementNode extends AstNode {
195
- readonly type: 'ReturnStatement';
196
- readonly argument?: AstNode;
197
- }
198
-
199
- export type CuratedAstNode =
200
- | ArrayExpressionNode
201
- | AssignmentPatternNode
202
- | BinaryExpressionNode
203
- | BlockStatementNode
204
- | CallExpressionNode
205
- | ClassMemberNode
206
- | DeclarationWithIdNode
207
- | ExportDeclarationNode
208
- | ExportSpecifierNode
209
- | ExpressionStatementNode
210
- | FunctionLikeNode
211
- | IdentifierNode
212
- | ImportDeclarationNode
213
- | ImportSpecifierNode
214
- | MemberExpressionNode
215
- | ObjectExpressionNode
216
- | ProgramNode
217
- | PropertyNode
218
- | RestElementNode
219
- | ReturnStatementNode
220
- | StringLiteralNode
221
- | UnaryExpressionNode
222
- | VariableDeclarationNode
223
- | VariableDeclaratorNode;
224
-
225
- export interface AstParentContext {
226
- readonly index: number | null;
227
- readonly key: string | number | symbol | null | undefined;
228
- readonly parent: AstNode | null;
229
- }
230
-
231
- export interface AstScopeDeclaration {
232
- readonly end: number;
233
- readonly node: AstNode;
234
- readonly start: number;
235
- readonly type: string;
236
- }
237
-
238
- export interface AstScopeContext extends AstParentContext {
239
- readonly currentScope: string;
240
- readonly getDeclaration: (name: string) => AstScopeDeclaration | null;
241
- readonly isDeclared: (name: string) => boolean;
242
- readonly isCurrentScopeUnder: (scope: string) => boolean;
243
- }
244
-
245
- export interface SourceEdit {
246
- readonly end: number;
247
- readonly replacement: string;
248
- readonly start: number;
249
- }
250
-
251
- export interface SourceLocation {
252
- readonly column: number;
253
- readonly line: number;
254
- }
255
-
256
- export interface AstParseDiagnosticLabel {
257
- readonly end: number;
258
- readonly message: string | null;
259
- readonly start: number;
260
- }
261
-
262
- export interface AstParseDiagnostic {
263
- readonly helpMessage: string | null;
264
- readonly labels: readonly AstParseDiagnosticLabel[];
265
- readonly message: string;
266
- readonly severity: string;
267
- }
268
-
269
- export interface AstParseResult {
270
- readonly ast: AstNode | null;
271
- readonly diagnostics: readonly AstParseDiagnostic[];
272
- }
273
-
274
- export interface AstFieldProjection {
275
- readonly alternate?: AstNode;
276
- readonly argument?: AstNode;
277
- readonly arguments?: readonly AstNode[];
278
- readonly body?: AstNode | readonly AstNode[];
279
- readonly callee?: AstNode;
280
- readonly cases?: readonly AstNode[];
281
- readonly computed?: boolean;
282
- readonly consequent?: AstNode;
283
- readonly declaration?: AstNode;
284
- readonly declarations?: readonly AstNode[];
285
- readonly discriminant?: AstNode;
286
- readonly elements?: readonly (AstNode | null)[];
287
- readonly exportKind?: string;
288
- readonly exported?: AstNode;
289
- readonly expression?: AstNode;
290
- readonly id?: AstNode;
291
- readonly imported?: AstNode;
292
- readonly init?: AstNode;
293
- readonly key?: AstNode;
294
- readonly kind?: string;
295
- readonly left?: AstNode;
296
- readonly local?: AstNode;
297
- readonly name?: string;
298
- readonly object?: AstNode;
299
- readonly operator?: string;
300
- readonly param?: AstNode;
301
- readonly params?: readonly AstNode[];
302
- readonly properties?: readonly AstNode[];
303
- readonly property?: AstNode;
304
- readonly returnType?: AstNode;
305
- readonly right?: AstNode;
306
- readonly source?: AstNode;
307
- readonly specifiers?: readonly AstNode[];
308
- readonly superClass?: AstNode;
309
- readonly test?: AstNode;
310
- readonly typeAnnotation?: AstNode;
311
- readonly value?: unknown;
312
- }
313
-
314
- export const isAstNode = (value: unknown): value is AstNode =>
315
- Boolean(value && typeof value === 'object' && (value as AstNode).type);
316
-
317
- const isNodeType = <TNode extends CuratedAstNode>(
318
- node: AstNode | null | undefined,
319
- types: readonly string[]
320
- ): node is TNode =>
321
- node !== null && node !== undefined && types.includes(node.type);
322
-
323
- export const isProgram = (
324
- node: AstNode | null | undefined
325
- ): node is ProgramNode => isNodeType<ProgramNode>(node, ['Program']);
326
-
327
- export const isIdentifier = (
328
- node: AstNode | null | undefined
329
- ): node is IdentifierNode => isNodeType<IdentifierNode>(node, ['Identifier']);
330
-
331
- export const isCallExpression = (
332
- node: AstNode | null | undefined
333
- ): node is CallExpressionNode =>
334
- isNodeType<CallExpressionNode>(node, ['CallExpression', 'NewExpression']);
335
-
336
- export const isMemberExpression = (
337
- node: AstNode | null | undefined
338
- ): node is MemberExpressionNode =>
339
- isNodeType<MemberExpressionNode>(node, [
340
- 'MemberExpression',
341
- 'StaticMemberExpression',
342
- ]);
343
-
344
- export const isImportDeclaration = (
345
- node: AstNode | null | undefined
346
- ): node is ImportDeclarationNode =>
347
- isNodeType<ImportDeclarationNode>(node, ['ImportDeclaration']);
348
-
349
- export const isImportSpecifier = (
350
- node: AstNode | null | undefined
351
- ): node is ImportSpecifierNode =>
352
- isNodeType<ImportSpecifierNode>(node, [
353
- 'ImportDefaultSpecifier',
354
- 'ImportNamespaceSpecifier',
355
- 'ImportSpecifier',
356
- ]);
357
-
358
- export const isExportDeclaration = (
359
- node: AstNode | null | undefined
360
- ): node is ExportDeclarationNode =>
361
- isNodeType<ExportDeclarationNode>(node, [
362
- 'ExportAllDeclaration',
363
- 'ExportDefaultDeclaration',
364
- 'ExportNamedDeclaration',
365
- ]);
366
-
367
- export const isExportNamedDeclaration = (
368
- node: AstNode | null | undefined
369
- ): node is ExportDeclarationNode & {
370
- readonly type: 'ExportNamedDeclaration';
371
- } =>
372
- isNodeType<
373
- ExportDeclarationNode & { readonly type: 'ExportNamedDeclaration' }
374
- >(node, ['ExportNamedDeclaration']);
375
-
376
- export const isExportDefaultDeclaration = (
377
- node: AstNode | null | undefined
378
- ): node is ExportDeclarationNode & {
379
- readonly type: 'ExportDefaultDeclaration';
380
- } =>
381
- isNodeType<
382
- ExportDeclarationNode & { readonly type: 'ExportDefaultDeclaration' }
383
- >(node, ['ExportDefaultDeclaration']);
384
-
385
- export const isExportAllDeclaration = (
386
- node: AstNode | null | undefined
387
- ): node is ExportDeclarationNode & { readonly type: 'ExportAllDeclaration' } =>
388
- isNodeType<ExportDeclarationNode & { readonly type: 'ExportAllDeclaration' }>(
389
- node,
390
- ['ExportAllDeclaration']
391
- );
392
-
393
- export const isExportSpecifier = (
394
- node: AstNode | null | undefined
395
- ): node is ExportSpecifierNode =>
396
- isNodeType<ExportSpecifierNode>(node, ['ExportSpecifier']);
397
-
398
- export const isVariableDeclaration = (
399
- node: AstNode | null | undefined
400
- ): node is VariableDeclarationNode =>
401
- isNodeType<VariableDeclarationNode>(node, ['VariableDeclaration']);
402
-
403
- export const isVariableDeclarator = (
404
- node: AstNode | null | undefined
405
- ): node is VariableDeclaratorNode =>
406
- isNodeType<VariableDeclaratorNode>(node, ['VariableDeclarator']);
407
-
408
- export const isDeclarationWithId = (
409
- node: AstNode | null | undefined
410
- ): node is DeclarationWithIdNode =>
411
- isNodeType<DeclarationWithIdNode>(node, [
412
- 'ClassDeclaration',
413
- 'EnumDeclaration',
414
- 'FunctionDeclaration',
415
- 'InterfaceDeclaration',
416
- 'TSInterfaceDeclaration',
417
- 'TSEnumDeclaration',
418
- 'TSTypeAliasDeclaration',
419
- ]);
420
-
421
- export const isClassMember = (
422
- node: AstNode | null | undefined
423
- ): node is ClassMemberNode =>
424
- isNodeType<ClassMemberNode>(node, ['MethodDefinition', 'PropertyDefinition']);
425
-
426
- export const isArrayExpression = (
427
- node: AstNode | null | undefined
428
- ): node is ArrayExpressionNode =>
429
- isNodeType<ArrayExpressionNode>(node, ['ArrayExpression']);
430
-
431
- export const isObjectExpression = (
432
- node: AstNode | null | undefined
433
- ): node is ObjectExpressionNode =>
434
- isNodeType<ObjectExpressionNode>(node, ['ObjectExpression', 'ObjectPattern']);
435
-
436
- export const isProperty = (
437
- node: AstNode | null | undefined
438
- ): node is PropertyNode => isNodeType<PropertyNode>(node, ['Property']);
439
-
440
- export const isRestElement = (
441
- node: AstNode | null | undefined
442
- ): node is RestElementNode =>
443
- isNodeType<RestElementNode>(node, ['RestElement']);
444
-
445
- export const isAssignmentPattern = (
446
- node: AstNode | null | undefined
447
- ): node is AssignmentPatternNode =>
448
- isNodeType<AssignmentPatternNode>(node, ['AssignmentPattern']);
449
-
450
- export const isExpressionStatement = (
451
- node: AstNode | null | undefined
452
- ): node is ExpressionStatementNode =>
453
- isNodeType<ExpressionStatementNode>(node, ['ExpressionStatement']);
454
-
455
- export const isUnaryExpression = (
456
- node: AstNode | null | undefined
457
- ): node is UnaryExpressionNode =>
458
- isNodeType<UnaryExpressionNode>(node, [
459
- 'AwaitExpression',
460
- 'ChainExpression',
461
- 'UnaryExpression',
462
- ]);
463
-
464
- export const isBinaryExpression = (
465
- node: AstNode | null | undefined
466
- ): node is BinaryExpressionNode =>
467
- isNodeType<BinaryExpressionNode>(node, [
468
- 'AssignmentExpression',
469
- 'BinaryExpression',
470
- 'ConditionalExpression',
471
- 'LogicalExpression',
472
- ]);
473
-
474
- export const isFunctionLike = (
475
- node: AstNode | null | undefined
476
- ): node is FunctionLikeNode =>
477
- isNodeType<FunctionLikeNode>(node, [
478
- 'ArrowFunctionExpression',
479
- 'FunctionDeclaration',
480
- 'FunctionExpression',
481
- ]);
482
-
483
- export const isBlockStatement = (
484
- node: AstNode | null | undefined
485
- ): node is BlockStatementNode =>
486
- isNodeType<BlockStatementNode>(node, ['BlockStatement', 'StaticBlock']);
487
-
488
- export const isReturnStatement = (
489
- node: AstNode | null | undefined
490
- ): node is ReturnStatementNode =>
491
- isNodeType<ReturnStatementNode>(node, ['ReturnStatement']);
492
-
493
- const projectAstFields = (
494
- node: AstNode | null | undefined
495
- ): AstFieldProjection | null => (node ? (node as AstFieldProjection) : null);
496
-
497
- export const getNodeAlternate = (
498
- node: AstNode | null | undefined
499
- ): AstNode | undefined => projectAstFields(node)?.alternate;
500
-
501
- export const getNodeArgument = (
502
- node: AstNode | null | undefined
503
- ): AstNode | undefined => projectAstFields(node)?.argument;
504
-
505
- export const getNodeArguments = (
506
- node: AstNode | null | undefined
507
- ): readonly AstNode[] => (isCallExpression(node) ? (node.arguments ?? []) : []);
508
-
509
- export const getNodeBody = (
510
- node: AstNode | null | undefined
511
- ): AstNode | readonly AstNode[] | undefined => projectAstFields(node)?.body;
512
-
513
- export const getNodeBodyNode = (
514
- node: AstNode | null | undefined
515
- ): AstNode | undefined => {
516
- const body = getNodeBody(node);
517
- return isAstNode(body) ? body : undefined;
518
- };
519
-
520
- export const getNodeBodyStatements = (
521
- node: AstNode | null | undefined
522
- ): readonly AstNode[] => {
523
- const body = getNodeBody(node);
524
- return Array.isArray(body) ? body : [];
525
- };
526
-
527
- export const getNodeCallee = (
528
- node: AstNode | null | undefined
529
- ): AstNode | undefined => (isCallExpression(node) ? node.callee : undefined);
530
-
531
- export const getNodeCases = (
532
- node: AstNode | null | undefined
533
- ): readonly AstNode[] => projectAstFields(node)?.cases ?? [];
534
-
535
- export const getNodeComputed = (
536
- node: AstNode | null | undefined
537
- ): boolean | undefined => projectAstFields(node)?.computed;
538
-
539
- export const getNodeConsequent = (
540
- node: AstNode | null | undefined
541
- ): AstNode | undefined => projectAstFields(node)?.consequent;
542
-
543
- export const getNodeDeclaration = (
544
- node: AstNode | null | undefined
545
- ): AstNode | undefined => projectAstFields(node)?.declaration;
546
-
547
- export const getNodeDeclarations = (
548
- node: AstNode | null | undefined
549
- ): readonly AstNode[] =>
550
- isVariableDeclaration(node) ? (node.declarations ?? []) : [];
551
-
552
- export const getNodeDiscriminant = (
553
- node: AstNode | null | undefined
554
- ): AstNode | undefined => projectAstFields(node)?.discriminant;
555
-
556
- export const getNodeElements = (
557
- node: AstNode | null | undefined
558
- ): readonly (AstNode | null)[] => projectAstFields(node)?.elements ?? [];
559
-
560
- export const getNodeExported = (
561
- node: AstNode | null | undefined
562
- ): AstNode | undefined => projectAstFields(node)?.exported;
563
-
564
- export const getNodeExportKind = (
565
- node: AstNode | null | undefined
566
- ): string | undefined => projectAstFields(node)?.exportKind;
567
-
568
- export const getNodeExpression = (
569
- node: AstNode | null | undefined
570
- ): AstNode | undefined => projectAstFields(node)?.expression;
571
-
572
- export const getNodeId = (
573
- node: AstNode | null | undefined
574
- ): AstNode | undefined => projectAstFields(node)?.id;
575
-
576
- export const getNodeImported = (
577
- node: AstNode | null | undefined
578
- ): AstNode | undefined => projectAstFields(node)?.imported;
579
-
580
- export const getNodeInit = (
581
- node: AstNode | null | undefined
582
- ): AstNode | undefined => projectAstFields(node)?.init;
583
-
584
- export const getNodeKey = (
585
- node: AstNode | null | undefined
586
- ): AstNode | undefined => projectAstFields(node)?.key;
587
-
588
- export const getNodeKind = (
589
- node: AstNode | null | undefined
590
- ): string | undefined => projectAstFields(node)?.kind;
591
-
592
- export const getNodeLeft = (
593
- node: AstNode | null | undefined
594
- ): AstNode | undefined => projectAstFields(node)?.left;
595
-
596
- export const getNodeLocal = (
597
- node: AstNode | null | undefined
598
- ): AstNode | undefined => projectAstFields(node)?.local;
599
-
600
- export const getNodeName = (
601
- node: AstNode | null | undefined
602
- ): string | undefined => projectAstFields(node)?.name;
603
-
604
- export const getNodeObject = (
605
- node: AstNode | null | undefined
606
- ): AstNode | undefined => (isMemberExpression(node) ? node.object : undefined);
607
-
608
- export const getNodeOperator = (
609
- node: AstNode | null | undefined
610
- ): string | undefined => projectAstFields(node)?.operator;
611
-
612
- export const getNodeParam = (
613
- node: AstNode | null | undefined
614
- ): AstNode | undefined => projectAstFields(node)?.param;
615
-
616
- export const getNodeParams = (
617
- node: AstNode | null | undefined
618
- ): readonly AstNode[] => (isFunctionLike(node) ? (node.params ?? []) : []);
619
-
620
- export const getNodeProperties = (
621
- node: AstNode | null | undefined
622
- ): readonly AstNode[] =>
623
- isObjectExpression(node) ? (node.properties ?? []) : [];
624
-
625
- export const getNodeProperty = (
626
- node: AstNode | null | undefined
627
- ): AstNode | undefined =>
628
- isMemberExpression(node) ? node.property : undefined;
629
-
630
- export const getNodeReturnType = (
631
- node: AstNode | null | undefined
632
- ): AstNode | undefined => projectAstFields(node)?.returnType;
633
-
634
- export const getNodeRight = (
635
- node: AstNode | null | undefined
636
- ): AstNode | undefined => projectAstFields(node)?.right;
637
-
638
- export const getNodeSource = (
639
- node: AstNode | null | undefined
640
- ): AstNode | undefined => projectAstFields(node)?.source;
641
-
642
- export const getNodeSpecifiers = (
643
- node: AstNode | null | undefined
644
- ): readonly AstNode[] => projectAstFields(node)?.specifiers ?? [];
645
-
646
- export const getNodeSuperClass = (
647
- node: AstNode | null | undefined
648
- ): AstNode | undefined => projectAstFields(node)?.superClass;
649
-
650
- export const getNodeTest = (
651
- node: AstNode | null | undefined
652
- ): AstNode | undefined => projectAstFields(node)?.test;
653
-
654
- export const getNodeTypeAnnotation = (
655
- node: AstNode | null | undefined
656
- ): AstNode | undefined => projectAstFields(node)?.typeAnnotation;
657
-
658
- export const getNodeValue = (node: AstNode | null | undefined): unknown =>
659
- projectAstFields(node)?.value;
660
-
661
- export const getNodeValueNode = (
662
- node: AstNode | null | undefined
663
- ): AstNode | undefined => {
664
- const value = getNodeValue(node);
665
- return isAstNode(value) ? value : undefined;
666
- };
667
-
668
- // ---------------------------------------------------------------------------
669
- // Parser
670
- // ---------------------------------------------------------------------------
671
-
672
- /** Parse TypeScript source into an AST. Returns null on parse failure. */
673
- export const parse = (filePath: string, sourceCode: string): AstNode | null => {
674
- try {
675
- const result = parseSync(filePath, sourceCode, { sourceType: 'module' });
676
- return result.program as unknown as AstNode;
677
- } catch {
678
- return null;
679
- }
680
- };
681
-
682
- /**
683
- * Parse TypeScript source and surface parser diagnostics. OXC can recover a
684
- * partial program for malformed input, so rewrite tooling should use this
685
- * helper when applying edits would be unsafe after syntax errors.
686
- */
687
- export const parseWithDiagnostics = (
688
- filePath: string,
689
- sourceCode: string
690
- ): AstParseResult => {
691
- try {
692
- const result = parseSync(filePath, sourceCode, { sourceType: 'module' });
693
- return {
694
- ast: result.program as unknown as AstNode,
695
- diagnostics: result.errors.map((error) => ({
696
- helpMessage: error.helpMessage,
697
- labels: error.labels.map((label) => ({
698
- end: label.end,
699
- message: label.message,
700
- start: label.start,
701
- })),
702
- message: error.message,
703
- severity: error.severity,
704
- })),
705
- };
706
- } catch (error) {
707
- return {
708
- ast: null,
709
- diagnostics: [
710
- {
711
- helpMessage: null,
712
- labels: [],
713
- message:
714
- error instanceof Error ? error.message : 'Unable to parse source.',
715
- severity: 'Error',
716
- },
717
- ],
718
- };
719
- }
720
- };
721
-
722
- // ---------------------------------------------------------------------------
723
- // Walker
724
- // ---------------------------------------------------------------------------
725
-
726
- type WalkFn = (node: unknown, visit: (node: AstNode) => void) => void;
727
-
728
- const walkChildren = (
729
- node: AstNode,
730
- visit: (node: AstNode) => void,
731
- recurse: WalkFn
732
- ): void => {
733
- for (const val of Object.values(node)) {
734
- if (Array.isArray(val)) {
735
- for (const item of val) {
736
- recurse(item, visit);
737
- }
738
- } else if (val && typeof val === 'object' && (val as AstNode).type) {
739
- recurse(val, visit);
740
- }
741
- }
742
- };
743
-
744
- /** Walk an AST node tree, calling `visit` on every node. */
745
- export const walk: WalkFn = (node, visit) => {
746
- if (!node || typeof node !== 'object') {
747
- return;
748
- }
749
- const n = node as AstNode;
750
- if (n.type) {
751
- visit(n);
752
- }
753
- walkChildren(n, visit, walk);
754
- };
755
-
756
- const toAstParentContext = (
757
- parent: unknown,
758
- ctx: WalkerCallbackContext
759
- ): AstParentContext => ({
760
- index: ctx.index,
761
- key: ctx.key,
762
- parent: isAstNode(parent) ? parent : null,
763
- });
764
-
765
- const toScopeDeclaration = (
766
- declaration: ScopeTrackerNode | null
767
- ): AstScopeDeclaration | null => {
768
- if (!declaration) {
769
- return null;
770
- }
771
-
772
- return {
773
- end: declaration.end,
774
- node: declaration.node as unknown as AstNode,
775
- start: declaration.start,
776
- type: declaration.type,
777
- };
778
- };
779
-
780
- const walkWithOxcFacade = (
781
- node: unknown,
782
- enter: (node: AstNode, context: AstParentContext) => void,
783
- scopeTracker?: ScopeTracker
784
- ): void => {
785
- if (!isAstNode(node)) {
786
- return;
787
- }
788
-
789
- const options: Partial<WalkOptions> = {
790
- enter(candidate, parent, ctx) {
791
- if (!isAstNode(candidate)) {
792
- return;
793
- }
794
- enter(candidate, toAstParentContext(parent, ctx));
795
- },
796
- };
797
-
798
- if (scopeTracker) {
799
- options.scopeTracker = scopeTracker;
800
- }
801
-
802
- walkWithOxc(node as never, options);
803
- };
804
-
805
- /**
806
- * Walk an AST node tree with parent, key, and index context for each visited
807
- * node. This is the supported Warden facade over `oxc-walker` for rules and
808
- * regrades that need structural context.
809
- */
810
- export const walkWithParents = (
811
- node: unknown,
812
- visit: (node: AstNode, context: AstParentContext) => void
813
- ): void => {
814
- walkWithOxcFacade(node, visit);
815
- };
816
-
817
- /**
818
- * Walk an AST node tree with parent context and a scope query facade. The
819
- * concrete `oxc-walker` tracker stays behind this helper so rule authors can
820
- * ask Warden-shaped questions without depending on walker internals.
821
- */
822
- export const walkWithScopeContext = (
823
- node: unknown,
824
- visit: (node: AstNode, context: AstScopeContext) => void
825
- ): void => {
826
- const scopeTracker = new ScopeTracker();
827
-
828
- walkWithOxcFacade(
829
- node,
830
- (candidate, context) => {
831
- visit(candidate, {
832
- ...context,
833
- currentScope: scopeTracker.getCurrentScope(),
834
- getDeclaration: (name) =>
835
- toScopeDeclaration(scopeTracker.getDeclaration(name)),
836
- isCurrentScopeUnder: (scope) => scopeTracker.isCurrentScopeUnder(scope),
837
- isDeclared: (name) => scopeTracker.isDeclared(name),
838
- });
839
- },
840
- scopeTracker
841
- );
842
- };
843
-
844
- const NESTED_SCOPE_TYPES = new Set([
845
- 'ArrowFunctionExpression',
846
- 'FunctionExpression',
847
- 'FunctionDeclaration',
848
- ]);
849
-
850
- const walkScopeInner: WalkFn = (node, visit) => {
851
- if (!node || typeof node !== 'object') {
852
- return;
853
- }
854
- const n = node as AstNode;
855
- if (n.type) {
856
- visit(n);
857
- if (NESTED_SCOPE_TYPES.has(n.type)) {
858
- return;
859
- }
860
- }
861
- walkChildren(n, visit, walkScopeInner);
862
- };
863
-
864
- /**
865
- * Walk an AST node tree without descending into nested function scopes.
866
- * The root node is always traversed; only inner function boundaries are skipped.
867
- * Useful for resource-access analysis where inner functions may shadow
868
- * the trail context parameter name.
869
- */
870
- export const walkScope: WalkFn = (node, visit) => {
871
- if (!node || typeof node !== 'object') {
872
- return;
873
- }
874
- const n = node as AstNode;
875
- if (n.type) {
876
- visit(n);
877
- }
878
- walkChildren(n, visit, walkScopeInner);
879
- };
880
-
881
- // ---------------------------------------------------------------------------
882
- // Helpers
883
- // ---------------------------------------------------------------------------
884
-
885
- /** Find the byte offset's line number (1-based) in source code. */
886
- export const offsetToLine = (sourceCode: string, offset: number): number => {
887
- let line = 1;
888
- for (let i = 0; i < offset && i < sourceCode.length; i += 1) {
889
- if (sourceCode[i] === '\n') {
890
- line += 1;
891
- }
892
- }
893
- return line;
894
- };
895
-
896
- /** Find the byte offset's line and column (1-based) in source code. */
897
- export const offsetToLineColumn = (
898
- sourceCode: string,
899
- offset: number
900
- ): SourceLocation => {
901
- let line = 1;
902
- let column = 1;
903
- const limit = Math.min(Math.max(offset, 0), sourceCode.length);
904
-
905
- for (let i = 0; i < limit; i += 1) {
906
- if (sourceCode[i] === '\n') {
907
- line += 1;
908
- column = 1;
909
- } else {
910
- column += 1;
911
- }
912
- }
913
-
914
- return { column, line };
915
- };
916
-
917
- export const createSourceEdit = (
918
- start: number,
919
- end: number,
920
- replacement: string
921
- ): SourceEdit => ({ end, replacement, start });
922
-
923
- export const validateSourceEdits = (
924
- edits: readonly SourceEdit[],
925
- sourceLength?: number
926
- ): readonly SourceEdit[] => {
927
- const ordered = [...edits].toSorted(
928
- (left, right) => left.start - right.start
929
- );
930
- for (let i = 0; i < ordered.length; i += 1) {
931
- const edit = ordered[i];
932
- if (!edit) {
933
- continue;
934
- }
935
- if (
936
- !Number.isSafeInteger(edit.start) ||
937
- !Number.isSafeInteger(edit.end) ||
938
- edit.start < 0 ||
939
- edit.end < edit.start ||
940
- (sourceLength !== undefined && edit.end > sourceLength)
941
- ) {
942
- throw new Error(`Invalid source edit range ${edit.start}-${edit.end}.`);
943
- }
944
-
945
- const previous = ordered[i - 1];
946
- if (previous && edit.start < previous.end) {
947
- throw new Error(
948
- `Overlapping source edits ${previous.start}-${previous.end} and ${edit.start}-${edit.end}.`
949
- );
950
- }
951
- }
952
-
953
- return ordered;
954
- };
955
-
956
- export const applySourceEdits = (
957
- sourceCode: string,
958
- edits: readonly SourceEdit[]
959
- ): string => {
960
- validateSourceEdits(edits, sourceCode.length);
961
-
962
- return [...edits]
963
- .toSorted((left, right) => right.start - left.start)
964
- .reduce(
965
- (output, edit) =>
966
- output.slice(0, edit.start) + edit.replacement + output.slice(edit.end),
967
- sourceCode
968
- );
969
- };
970
-
971
- /** Get the name of an Identifier node, or null. */
972
- export const identifierName = (node: AstNode | undefined): string | null => {
973
- if (node?.type !== 'Identifier') {
974
- return null;
975
- }
976
- return (node as unknown as { name?: string }).name ?? null;
977
- };
978
-
979
- /** Check if a node is a string literal. */
980
- export const isStringLiteral = (
981
- node: AstNode | undefined
982
- ): node is StringLiteralNode => {
983
- if (!node) {
984
- return false;
985
- }
986
- if (node.type === 'StringLiteral') {
987
- return true;
988
- }
989
- if (node.type === 'Literal') {
990
- return typeof (node as unknown as { value?: unknown }).value === 'string';
991
- }
992
- return false;
993
- };
994
-
995
- /** Extract the string value from a string literal node. */
996
- export const getStringValue = (node: AstNode): string | null => {
997
- const val = (node as unknown as { value?: unknown }).value;
998
- return typeof val === 'string' ? val : null;
999
- };
1000
-
1001
- /**
1002
- * Best-effort resolution of `const NAME = 'value'` declarations via regex.
1003
- *
1004
- * Returns the string value if a simple `const <name> = '...'` or `"..."` is
1005
- * found in the source. Returns null for anything more complex. Shared between
1006
- * warden rules that need to resolve identifier references to signal / trail
1007
- * IDs at lint time.
1008
- */
1009
- export const deriveConstString = (
1010
- name: string,
1011
- sourceCode: string
1012
- ): string | null => {
1013
- const pattern = new RegExp(
1014
- `const\\s+${name}\\s*=\\s*(?:'([^']*)'|"([^"]*)")`
1015
- );
1016
- const match = pattern.exec(sourceCode);
1017
- if (!match) {
1018
- return null;
1019
- }
1020
- return match[1] ?? match[2] ?? null;
1021
- };
1022
-
1023
- /** Extract a string literal value, or null when the node is not a string. */
1024
- export const extractStringLiteral = (
1025
- node: AstNode | undefined
1026
- ): string | null =>
1027
- node && isStringLiteral(node) ? getStringValue(node) : null;
1028
-
1029
- /**
1030
- * Extract the cooked value from a `TemplateLiteral` with no interpolations
1031
- * (e.g. `` `entity.fallback` ``). Template literals with `${...}` expressions
1032
- * cannot be resolved at lint time and return null.
1033
- *
1034
- * Shared helper used by rules that accept both string literals and simple
1035
- * backtick-literal IDs (e.g. `valid-describe-refs`).
1036
- */
1037
- const getSingleQuasi = (node: AstNode): AstNode | null => {
1038
- const expressions =
1039
- (node['expressions'] as readonly AstNode[] | undefined) ?? [];
1040
- if (expressions.length > 0) {
1041
- return null;
1042
- }
1043
- const quasis = (node['quasis'] as readonly AstNode[] | undefined) ?? [];
1044
- return quasis.length === 1 ? (quasis[0] ?? null) : null;
1045
- };
1046
-
1047
- export const extractPlainTemplateLiteral = (
1048
- node: AstNode | undefined
1049
- ): string | null => {
1050
- if (!node || node.type !== 'TemplateLiteral') {
1051
- return null;
1052
- }
1053
- const quasi = getSingleQuasi(node);
1054
- if (!quasi) {
1055
- return null;
1056
- }
1057
- const cooked = (quasi as unknown as { value?: { cooked?: unknown } }).value
1058
- ?.cooked;
1059
- return typeof cooked === 'string' ? cooked : null;
1060
- };
1061
-
1062
- /**
1063
- * Extract a string value from either a string literal or a plain template
1064
- * literal (no `${...}` expressions). Returns null for anything else.
1065
- */
1066
- export const extractStringOrTemplateLiteral = (
1067
- node: AstNode | undefined
1068
- ): string | null =>
1069
- extractStringLiteral(node) ?? extractPlainTemplateLiteral(node);
1070
-
1071
- export interface StringLiteralMatch {
1072
- readonly end: number;
1073
- readonly node: AstNode;
1074
- readonly start: number;
1075
- readonly value: string;
1076
- }
1077
-
1078
- /**
1079
- * Names of framework constants whose value is a draft-marker prefix literal.
1080
- *
1081
- * String literals that initialize a `const` declaration with one of these
1082
- * names are treated as the framework's own draft-marker declarations, not as
1083
- * draft-id usage. This list is intentionally small and explicit — adding a
1084
- * new framework draft-prefix constant requires updating this set.
1085
- */
1086
- export const FRAMEWORK_DRAFT_PREFIX_CONSTANT_NAMES: ReadonlySet<string> =
1087
- new Set(['DRAFT_ID_PREFIX', 'DRAFT_FILE_PREFIX']);
1088
-
1089
- /**
1090
- * Exact string literal value allowed for framework draft-prefix constant
1091
- * declarations. Tightens the exemption so a future framework file cannot
1092
- * redeclare `DRAFT_ID_PREFIX = '_draft.something-else'` and accidentally
1093
- * suppress its own draft-id diagnostic.
1094
- */
1095
- const FRAMEWORK_DRAFT_PREFIX_LITERAL = DRAFT_ID_PREFIX;
1096
-
1097
- interface PackageJsonWithName {
1098
- readonly name: string;
1099
- }
1100
-
1101
- const FRAMEWORK_DRAFT_PREFIX_PACKAGES: ReadonlySet<string> = new Set([
1102
- '@ontrails/core',
1103
- '@ontrails/warden',
1104
- ]);
1105
-
1106
- const isPackageJsonWithName = (value: unknown): value is PackageJsonWithName =>
1107
- typeof value === 'object' &&
1108
- value !== null &&
1109
- typeof (value as { name?: unknown }).name === 'string';
1110
-
1111
- const readPackageJsonName = (packageJsonPath: string): string | null => {
1112
- try {
1113
- const parsed = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
1114
- return isPackageJsonWithName(parsed) ? parsed.name : null;
1115
- } catch {
1116
- return null;
1117
- }
1118
- };
1119
-
1120
- const frameworkDraftPackageRoot = (filePath: string): string | null => {
1121
- const resolvedPath = resolve(filePath);
1122
- if (basename(resolvedPath) !== 'draft.ts') {
1123
- return null;
1124
- }
1125
-
1126
- const sourceDir = dirname(resolvedPath);
1127
- if (basename(sourceDir) !== 'src') {
1128
- return null;
1129
- }
1130
-
1131
- const packageRoot = dirname(sourceDir);
1132
- if (!existsSync(join(packageRoot, 'package.json'))) {
1133
- return null;
1134
- }
1135
-
1136
- return packageRoot;
1137
- };
1138
-
1139
- /** Fallback exemption when framework files are consumed from a different install path. */
1140
- const isFrameworkDraftPrefixSourceFile = (filePath: string): boolean => {
1141
- const root = frameworkDraftPackageRoot(filePath);
1142
- if (!root) {
1143
- return false;
1144
- }
1145
- const packageName = readPackageJsonName(join(root, 'package.json'));
1146
- return (
1147
- packageName !== null && FRAMEWORK_DRAFT_PREFIX_PACKAGES.has(packageName)
1148
- );
1149
- };
1150
-
1151
- /**
1152
- * Absolute paths of the two framework files allowed to declare the
1153
- * draft-prefix constants. Anchored against the rule module's own URL so the
1154
- * exemption is scoped to this package's real on-disk location — a consumer
1155
- * repository that happens to declare `const DRAFT_ID_PREFIX = '_draft.leak'`
1156
- * anywhere else cannot hide a genuine leak by matching the identifier name.
1157
- *
1158
- * The two framework files are:
1159
- * - `packages/core/src/draft.ts` (defines `DRAFT_ID_PREFIX`)
1160
- * - `packages/warden/src/draft.ts` (defines `DRAFT_FILE_PREFIX`)
1161
- */
1162
- const FRAMEWORK_DRAFT_CONSTANT_FILES: ReadonlySet<string> = new Set([
1163
- resolve(
1164
- fileURLToPath(new URL('../../../core/src/draft.ts', import.meta.url))
1165
- ),
1166
- resolve(fileURLToPath(new URL('../draft.ts', import.meta.url))),
1167
- ]);
1168
-
1169
- /**
1170
- * Collect the source offsets of string literals that initialize a framework
1171
- * draft-prefix constant declaration (e.g. `export const DRAFT_ID_PREFIX =
1172
- * '_draft.'`). Used by draft-awareness rules to skip their own marker
1173
- * constants.
1174
- *
1175
- * Exemption is gated on all three of:
1176
- * 1. The file is one of the two known framework draft files, or its package
1177
- * root `package.json` name is `@ontrails/core` or `@ontrails/warden`.
1178
- * 2. The declaration name is `DRAFT_ID_PREFIX` or `DRAFT_FILE_PREFIX`.
1179
- * 3. The string literal value is exactly `'_draft.'`.
1180
- *
1181
- * A consumer file that reuses one of these identifier names cannot hide a
1182
- * `_draft.*` leak — the path gate rejects it outright.
1183
- */
1184
- export const collectFrameworkDraftPrefixConstantOffsets = (
1185
- ast: AstNode,
1186
- filePath: string
1187
- ): ReadonlySet<number> => {
1188
- const offsets = new Set<number>();
1189
-
1190
- const resolvedPath = resolve(filePath);
1191
- if (
1192
- !FRAMEWORK_DRAFT_CONSTANT_FILES.has(resolvedPath) &&
1193
- !isFrameworkDraftPrefixSourceFile(resolvedPath)
1194
- ) {
1195
- return offsets;
1196
- }
1197
-
1198
- walk(ast, (node) => {
1199
- if (node.type !== 'VariableDeclarator') {
1200
- return;
1201
- }
1202
-
1203
- const { id, init } = node as unknown as {
1204
- readonly id?: AstNode;
1205
- readonly init?: AstNode;
1206
- };
1207
- const name = identifierName(id);
1208
- if (
1209
- !name ||
1210
- !FRAMEWORK_DRAFT_PREFIX_CONSTANT_NAMES.has(name) ||
1211
- !init ||
1212
- !isStringLiteral(init)
1213
- ) {
1214
- return;
1215
- }
1216
-
1217
- if (getStringValue(init) !== FRAMEWORK_DRAFT_PREFIX_LITERAL) {
1218
- return;
1219
- }
1220
-
1221
- offsets.add(init.start);
1222
- });
1223
-
1224
- return offsets;
1225
- };
1226
-
1227
- const WARDEN_IGNORE_NEXT_LINE_PRAGMAS = new Set([
1228
- '// warden-ignore-next-line',
1229
- '<!-- warden-ignore-next-line -->',
1230
- ]);
1231
-
1232
- /**
1233
- * Split source code into lines for pragma lookups. Callers should split once
1234
- * per `check` invocation and thread the result through to
1235
- * {@link hasIgnoreCommentOnLine} so we avoid re-splitting the full source on
1236
- * every match in files with many draft-like string literals.
1237
- */
1238
- export const splitSourceLines = (sourceCode: string): readonly string[] =>
1239
- sourceCode.split('\n');
1240
-
1241
- /**
1242
- * Check whether the line immediately preceding `line` contains a
1243
- * `warden-ignore-next-line` pragma (leading/trailing whitespace tolerated).
1244
- * Pragma scope is strictly one line — an intervening blank line breaks it.
1245
- *
1246
- * Takes a pre-split `lines` array so callers can split the source once per
1247
- * invocation instead of re-splitting for every literal they check.
1248
- *
1249
- * @example
1250
- * ```ts
1251
- * // warden-ignore-next-line
1252
- * const x = '_draft.intentional'; // suppressed
1253
- * ```
1254
- */
1255
- export const hasIgnoreCommentOnLine = (
1256
- lines: readonly string[],
1257
- line: number
1258
- ): boolean => {
1259
- if (line <= 1) {
1260
- return false;
1261
- }
1262
-
1263
- const previous = lines[line - 2];
1264
- if (previous === undefined) {
1265
- return false;
1266
- }
1267
-
1268
- return WARDEN_IGNORE_NEXT_LINE_PRAGMAS.has(previous.trim());
1269
- };
1270
-
1271
- export const findStringLiterals = (
1272
- ast: AstNode,
1273
- predicate?: (value: string, node: AstNode) => boolean
1274
- ): StringLiteralMatch[] => {
1275
- const matches: StringLiteralMatch[] = [];
1276
-
1277
- walk(ast, (node) => {
1278
- if (!isStringLiteral(node)) {
1279
- return;
1280
- }
1281
-
1282
- const value = getStringValue(node);
1283
- if (value === null) {
1284
- return;
1285
- }
1286
-
1287
- if (predicate && !predicate(value, node)) {
1288
- return;
1289
- }
1290
-
1291
- matches.push({
1292
- end: node.end,
1293
- node,
1294
- start: node.start,
1295
- value,
1296
- });
1297
- });
1298
-
1299
- return matches;
1300
- };
1301
-
1302
- /** Extract the first string argument from a CallExpression. */
1303
- export const extractFirstStringArg = (node: AstNode): string | null => {
1304
- if (node.type !== 'CallExpression') {
1305
- return null;
1306
- }
1307
-
1308
- const args = node['arguments'] as readonly AstNode[] | undefined;
1309
- const [firstArg] = args ?? [];
1310
- return extractStringLiteral(firstArg);
1311
- };
1312
-
1313
- const isResourceCall = (node: AstNode | undefined): boolean =>
1314
- !!node &&
1315
- node.type === 'CallExpression' &&
1316
- identifierName((node as unknown as { callee?: AstNode }).callee) ===
1317
- 'resource';
1318
-
1319
- const extractBindingName = (node: AstNode | undefined): string | null => {
1320
- if (!node) {
1321
- return null;
1322
- }
1323
- if (node.type === 'Identifier') {
1324
- return identifierName(node);
1325
- }
1326
- if (node.type === 'AssignmentPattern') {
1327
- return identifierName((node as unknown as { left?: AstNode }).left);
1328
- }
1329
- return null;
1330
- };
1331
-
1332
- /** Collect `const foo = resource('id', ...)` bindings from a parsed file. */
1333
- export const collectNamedResourceIds = (
1334
- ast: AstNode
1335
- ): ReadonlyMap<string, string> => {
1336
- const ids = new Map<string, string>();
1337
-
1338
- walk(ast, (node) => {
1339
- if (node.type !== 'VariableDeclarator') {
1340
- return;
1341
- }
1342
-
1343
- const { id, init } = node as unknown as {
1344
- readonly id?: AstNode;
1345
- readonly init?: AstNode;
1346
- };
1347
- if (!isResourceCall(init)) {
1348
- return;
1349
- }
1350
-
1351
- const name = extractBindingName(id);
1352
- const resourceId = init ? extractFirstStringArg(init) : null;
1353
- if (name && resourceId) {
1354
- ids.set(name, resourceId);
1355
- }
1356
- });
1357
-
1358
- return ids;
1359
- };
1360
-
1361
- /** Collect all inline `resource('id', ...)` definition IDs from a parsed file. */
1362
- export const collectResourceDefinitionIds = (
1363
- ast: AstNode
1364
- ): ReadonlySet<string> => {
1365
- const ids = new Set<string>();
1366
-
1367
- walk(ast, (node) => {
1368
- if (!isResourceCall(node)) {
1369
- return;
1370
- }
1371
-
1372
- const id = extractFirstStringArg(node);
1373
- if (id) {
1374
- ids.add(id);
1375
- }
1376
- });
1377
-
1378
- return ids;
1379
- };
1380
-
1381
- // ---------------------------------------------------------------------------
1382
- // Config property extraction helpers
1383
- // ---------------------------------------------------------------------------
1384
-
1385
- /**
1386
- * Extract the identifying name of a `Property` key, supporting both
1387
- * identifier keys (`{ foo: 1 }`) and string-literal keys
1388
- * (`{ "foo": 1 }`). Computed keys are intentionally not resolved — a
1389
- * computed expression could evaluate to anything and we only want to
1390
- * match keys that are statically equivalent to a plain identifier.
1391
- */
1392
- const staticPropertyKeyName = (key: AstNode): string | null => {
1393
- if (key.type === 'Identifier') {
1394
- return (key as unknown as { name?: string }).name ?? null;
1395
- }
1396
- return isStringLiteral(key) ? getStringValue(key) : null;
1397
- };
1398
-
1399
- const propertyKeyName = (prop: AstNode): string | null => {
1400
- if (prop.type !== 'Property') {
1401
- return null;
1402
- }
1403
- const { computed } = prop as unknown as { computed?: boolean };
1404
- if (computed) {
1405
- return null;
1406
- }
1407
- const key = prop.key as AstNode | undefined;
1408
- return key ? staticPropertyKeyName(key) : null;
1409
- };
1410
-
1411
- /** Find a Property node by key name inside an ObjectExpression config. */
1412
- export const findConfigProperty = (
1413
- config: AstNode,
1414
- propertyName: string
1415
- ): AstNode | null => {
1416
- if (config.type !== 'ObjectExpression') {
1417
- return null;
1418
- }
1419
- const properties = config['properties'] as readonly AstNode[] | undefined;
1420
- if (!properties) {
1421
- return null;
1422
- }
1423
- for (const prop of properties) {
1424
- if (propertyKeyName(prop) === propertyName) {
1425
- return prop;
1426
- }
1427
- }
1428
- return null;
1429
- };
1430
-
1431
- // ---------------------------------------------------------------------------
1432
- // Trail definition extraction
1433
- // ---------------------------------------------------------------------------
1434
-
1435
- export interface TrailDefinition {
1436
- /** Trail ID string, e.g. "entity.show" */
1437
- readonly id: string;
1438
- /** "trail" or "signal" */
1439
- readonly kind: string;
1440
- /** The config object argument (second arg to trail() call) */
1441
- readonly config: AstNode;
1442
- /** Start offset of the call expression */
1443
- readonly start: number;
1444
- }
1445
-
1446
- /**
1447
- * Find all `trail("id", { ... })`, `trail({ id: "x", ... })`, and
1448
- * `signal("id", { ... })` call sites.
1449
- *
1450
- * Returns the trail ID, kind, and config object node for each definition.
1451
- */
1452
- const TRAIL_CALLEE_NAMES = new Set(['signal', 'trail']);
1453
-
1454
- /**
1455
- * Source prefix for the Trails framework package whose namespace imports are
1456
- * recognized as carriers of `trail()` / `signal()` / `contour()` primitives.
1457
- *
1458
- * A namespaced callee like `core.trail(...)` is only treated as a framework
1459
- * call when the receiver identifier resolves to an `import * as core from
1460
- * '@ontrails/...'` in the same file. An unrelated `analytics.trail(...)`
1461
- * whose `analytics` comes from a different module (or no import at all)
1462
- * is ignored.
1463
- */
1464
- const FRAMEWORK_NAMESPACE_SOURCE_PREFIX = '@ontrails/';
1465
-
1466
- const isFrameworkNamespaceSource = (value: unknown): boolean =>
1467
- typeof value === 'string' &&
1468
- value.startsWith(FRAMEWORK_NAMESPACE_SOURCE_PREFIX);
1469
-
1470
- /**
1471
- * Collect local binding names introduced by `import * as <name> from
1472
- * '@ontrails/...'` declarations. Used to gate namespaced framework-primitive
1473
- * calls so an unrelated `analytics.trail(...)` doesn't match.
1474
- */
1475
- const getImportSourceValue = (node: AstNode): unknown => {
1476
- const sourceNode = (node as unknown as { source?: AstNode }).source;
1477
- return sourceNode
1478
- ? (sourceNode as unknown as { value?: unknown }).value
1479
- : undefined;
1480
- };
1481
-
1482
- const addNamespaceImportBindings = (
1483
- node: AstNode,
1484
- names: Set<string>
1485
- ): void => {
1486
- const specifiers =
1487
- (node['specifiers'] as readonly AstNode[] | undefined) ?? [];
1488
- for (const spec of specifiers) {
1489
- if (spec.type !== 'ImportNamespaceSpecifier') {
1490
- continue;
1491
- }
1492
- const { local } = spec as unknown as { local?: AstNode };
1493
- const localName = identifierName(local);
1494
- if (localName) {
1495
- names.add(localName);
1496
- }
1497
- }
1498
- };
1499
-
1500
- const TOP_LEVEL_NAMED_DECL_TYPES = new Set([
1501
- 'ClassDeclaration',
1502
- 'FunctionDeclaration',
1503
- 'TSEnumDeclaration',
1504
- 'TSModuleDeclaration',
1505
- ]);
1506
-
1507
- const removeVarDeclarationShadowedNames = (
1508
- stmt: AstNode,
1509
- names: Set<string>
1510
- ): void => {
1511
- const declarations =
1512
- (stmt as unknown as { declarations?: readonly AstNode[] }).declarations ??
1513
- [];
1514
- for (const d of declarations) {
1515
- const { id } = d as unknown as { id?: AstNode };
1516
- const n = identifierName(id);
1517
- if (n) {
1518
- names.delete(n);
1519
- }
1520
- }
1521
- };
1522
-
1523
- const removeNamedDeclShadowedName = (
1524
- stmt: AstNode,
1525
- names: Set<string>
1526
- ): void => {
1527
- const { id } = stmt as unknown as { id?: AstNode };
1528
- const n = identifierName(id);
1529
- if (n) {
1530
- names.delete(n);
1531
- }
1532
- };
1533
-
1534
- const removeTopLevelShadowedNames = (
1535
- stmt: AstNode,
1536
- names: Set<string>
1537
- ): void => {
1538
- if (
1539
- stmt.type === 'ExportNamedDeclaration' ||
1540
- stmt.type === 'ExportDefaultDeclaration'
1541
- ) {
1542
- const { declaration } = stmt as unknown as { declaration?: AstNode };
1543
- if (declaration) {
1544
- removeTopLevelShadowedNames(declaration, names);
1545
- }
1546
- return;
1547
- }
1548
- if (stmt.type === 'VariableDeclaration') {
1549
- removeVarDeclarationShadowedNames(stmt, names);
1550
- return;
1551
- }
1552
- if (TOP_LEVEL_NAMED_DECL_TYPES.has(stmt.type)) {
1553
- removeNamedDeclShadowedName(stmt, names);
1554
- }
1555
- };
1556
-
1557
- const collectFrameworkNamespaceBindings = (
1558
- ast: AstNode
1559
- ): ReadonlySet<string> => {
1560
- const names = new Set<string>();
1561
- walk(ast, (node) => {
1562
- if (node.type !== 'ImportDeclaration') {
1563
- return;
1564
- }
1565
- if (!isFrameworkNamespaceSource(getImportSourceValue(node))) {
1566
- return;
1567
- }
1568
- addNamespaceImportBindings(node, names);
1569
- });
1570
- if (names.size === 0) {
1571
- return names;
1572
- }
1573
- // A same-named top-level declaration (class / enum / namespace / var /
1574
- // function / lexical binding) shadows the namespace import at module scope.
1575
- // The scope walker treats Program as the outermost frame and skips it when
1576
- // testing for inner shadows, so we have to strip these collisions here.
1577
- if (ast.type === 'Program') {
1578
- const body = (ast as unknown as { body?: readonly AstNode[] }).body ?? [];
1579
- for (const stmt of body) {
1580
- removeTopLevelShadowedNames(stmt, names);
1581
- }
1582
- }
1583
- return names;
1584
- };
1585
-
1586
- // ---------------------------------------------------------------------------
1587
- // Scope-aware framework-namespace resolution
1588
- // ---------------------------------------------------------------------------
1589
- //
1590
- // A module-level `import * as core from '@ontrails/core'` makes `core` a
1591
- // framework-namespace binding, but a function-local `const core = {...}` (or
1592
- // param, `let`, `var`, `function`, class, catch param) shadows the import for
1593
- // the duration of that scope. A name-only check is not enough to trust
1594
- // `core.trail(...)` — we have to walk scopes outward from each call site and
1595
- // verify the first declaration of the receiver IS the namespace import.
1596
- //
1597
- // {@link collectFrameworkNamespacedCallStarts} performs that walk once per
1598
- // AST and returns the set of `CallExpression` start offsets whose receiver is
1599
- // provably the framework binding. Downstream helpers gate on this set instead
1600
- // of the bare names, so a local shadow cannot sneak through.
1601
-
1602
- type PatternExpander = (node: AstNode) => readonly AstNode[];
1603
-
1604
- const expandAssignmentPattern: PatternExpander = (node) => {
1605
- const { left } = node as unknown as { left?: AstNode };
1606
- return left ? [left] : [];
1607
- };
1608
-
1609
- const expandRestElement: PatternExpander = (node) => {
1610
- const { argument } = node as unknown as { argument?: AstNode };
1611
- return argument ? [argument] : [];
1612
- };
1613
-
1614
- const expandArrayPattern: PatternExpander = (node) => {
1615
- const elements =
1616
- (node as unknown as { elements?: readonly (AstNode | null)[] }).elements ??
1617
- [];
1618
- return elements.filter((e): e is AstNode => e !== null);
1619
- };
1620
-
1621
- const expandObjectPatternProperty = (prop: AstNode): AstNode | null => {
1622
- if (prop.type === 'RestElement') {
1623
- return prop;
1624
- }
1625
- const { value } = prop as unknown as { value?: AstNode };
1626
- return value ?? null;
1627
- };
1628
-
1629
- const expandObjectPattern: PatternExpander = (node) => {
1630
- const properties =
1631
- (node as unknown as { properties?: readonly AstNode[] }).properties ?? [];
1632
- return properties
1633
- .map(expandObjectPatternProperty)
1634
- .filter((n): n is AstNode => n !== null);
1635
- };
1636
-
1637
- const PATTERN_EXPANDERS: Record<string, PatternExpander> = {
1638
- ArrayPattern: expandArrayPattern,
1639
- AssignmentPattern: expandAssignmentPattern,
1640
- ObjectPattern: expandObjectPattern,
1641
- RestElement: expandRestElement,
1642
- };
1643
-
1644
- const processPatternNode = (
1645
- node: AstNode,
1646
- into: Set<string>,
1647
- stack: AstNode[]
1648
- ): void => {
1649
- if (node.type === 'Identifier') {
1650
- const { name } = node as unknown as { name?: string };
1651
- if (name) {
1652
- into.add(name);
1653
- }
1654
- return;
1655
- }
1656
- const expand = PATTERN_EXPANDERS[node.type];
1657
- if (expand) {
1658
- stack.push(...expand(node));
1659
- }
1660
- };
1661
-
1662
- const addPatternBindingNames = (
1663
- pattern: AstNode | undefined,
1664
- into: Set<string>
1665
- ): void => {
1666
- if (!pattern) {
1667
- return;
1668
- }
1669
- const stack: AstNode[] = [pattern];
1670
- while (stack.length > 0) {
1671
- const node = stack.pop();
1672
- if (node) {
1673
- processPatternNode(node, into, stack);
1674
- }
1675
- }
1676
- };
1677
-
1678
- const addVarDeclarationBindingNames = (
1679
- decl: AstNode,
1680
- into: Set<string>
1681
- ): void => {
1682
- const declarations =
1683
- (decl as unknown as { declarations?: readonly AstNode[] }).declarations ??
1684
- [];
1685
- for (const d of declarations) {
1686
- addPatternBindingNames((d as unknown as { id?: AstNode }).id, into);
1687
- }
1688
- };
1689
-
1690
- const addFunctionOrClassBindingName = (
1691
- node: AstNode,
1692
- into: Set<string>
1693
- ): void => {
1694
- const { id } = node as unknown as { id?: AstNode };
1695
- const name = identifierName(id);
1696
- if (name) {
1697
- into.add(name);
1698
- }
1699
- };
1700
-
1701
- const addBlockStatementBindings = (stmt: AstNode, into: Set<string>): void => {
1702
- if (stmt.type === 'VariableDeclaration') {
1703
- addVarDeclarationBindingNames(stmt, into);
1704
- return;
1705
- }
1706
- if (
1707
- stmt.type === 'FunctionDeclaration' ||
1708
- stmt.type === 'ClassDeclaration' ||
1709
- stmt.type === 'TSEnumDeclaration' ||
1710
- stmt.type === 'TSModuleDeclaration'
1711
- ) {
1712
- addFunctionOrClassBindingName(stmt, into);
1713
- }
1714
- };
1715
-
1716
- const collectTopLevelStatementBindings = (
1717
- stmt: AstNode,
1718
- into: Set<string>
1719
- ): void => {
1720
- if (
1721
- stmt.type === 'ExportNamedDeclaration' ||
1722
- stmt.type === 'ExportDefaultDeclaration'
1723
- ) {
1724
- const { declaration } = stmt as unknown as { declaration?: AstNode };
1725
- if (declaration) {
1726
- collectTopLevelStatementBindings(declaration, into);
1727
- }
1728
- return;
1729
- }
1730
- addBlockStatementBindings(stmt, into);
1731
- };
1732
-
1733
- const FUNCTION_BOUNDARY_TYPES = new Set([
1734
- 'ArrowFunctionExpression',
1735
- 'FunctionDeclaration',
1736
- 'FunctionExpression',
1737
- 'StaticBlock',
1738
- ]);
1739
-
1740
- const forEachAstChild = (
1741
- node: AstNode,
1742
- visit: (child: AstNode) => void
1743
- ): void => {
1744
- for (const val of Object.values(node)) {
1745
- if (Array.isArray(val)) {
1746
- for (const item of val) {
1747
- if (item && typeof item === 'object' && (item as AstNode).type) {
1748
- visit(item as AstNode);
1749
- }
1750
- }
1751
- } else if (val && typeof val === 'object' && (val as AstNode).type) {
1752
- visit(val as AstNode);
1753
- }
1754
- }
1755
- };
1756
-
1757
- const recordHoistedBinding = (
1758
- node: AstNode,
1759
- into: Set<string>,
1760
- inNestedBlock: boolean
1761
- ): void => {
1762
- if (node.type === 'VariableDeclaration') {
1763
- const { kind } = node as unknown as { kind?: string };
1764
- if (kind === 'var') {
1765
- addVarDeclarationBindingNames(node, into);
1766
- }
1767
- return;
1768
- }
1769
- // In strict/module code, function/class/enum/module declarations inside a
1770
- // nested block (`if { function foo() {} }`, `switch` case, etc.) are
1771
- // block-scoped. Only hoist them to the enclosing function frame when they
1772
- // sit directly in the function body, not inside a further block.
1773
- if (inNestedBlock) {
1774
- return;
1775
- }
1776
- if (
1777
- node.type === 'FunctionDeclaration' ||
1778
- node.type === 'ClassDeclaration' ||
1779
- node.type === 'TSEnumDeclaration' ||
1780
- node.type === 'TSModuleDeclaration'
1781
- ) {
1782
- addFunctionOrClassBindingName(node, into);
1783
- }
1784
- };
1785
-
1786
- const NESTED_BLOCK_BOUNDARY_TYPES = new Set([
1787
- 'BlockStatement',
1788
- 'ForStatement',
1789
- 'ForInStatement',
1790
- 'ForOfStatement',
1791
- 'SwitchStatement',
1792
- 'CatchClause',
1793
- ]);
1794
-
1795
- const visitForHoisted = (
1796
- node: AstNode,
1797
- isRoot: boolean,
1798
- into: Set<string>,
1799
- inNestedBlock: boolean
1800
- ): void => {
1801
- if (!isRoot && FUNCTION_BOUNDARY_TYPES.has(node.type)) {
1802
- return;
1803
- }
1804
- recordHoistedBinding(node, into, inNestedBlock);
1805
- const childInNestedBlock =
1806
- inNestedBlock || (!isRoot && NESTED_BLOCK_BOUNDARY_TYPES.has(node.type));
1807
- forEachAstChild(node, (child) => {
1808
- visitForHoisted(child, false, into, childInNestedBlock);
1809
- });
1810
- };
1811
-
1812
- /**
1813
- * Collect `var` declarations and `function` declarations hoisted to the
1814
- * nearest function scope from anywhere inside `root`, without composing a
1815
- * nested function or static-block boundary.
1816
- */
1817
- const collectHoistedVarAndFunctionBindings = (
1818
- root: AstNode,
1819
- into: Set<string>
1820
- ): void => {
1821
- visitForHoisted(root, true, into, false);
1822
- };
1823
-
1824
- type FrameCollector = (node: AstNode, into: Set<string>) => void;
1825
-
1826
- const collectProgramFrame: FrameCollector = (node, into) => {
1827
- const body = (node as unknown as { body?: readonly AstNode[] }).body ?? [];
1828
- for (const stmt of body) {
1829
- collectTopLevelStatementBindings(stmt, into);
1830
- }
1831
- };
1832
-
1833
- const collectFunctionFrame: FrameCollector = (node, into) => {
1834
- const params =
1835
- (node as unknown as { params?: readonly AstNode[] }).params ?? [];
1836
- for (const param of params) {
1837
- addPatternBindingNames(param, into);
1838
- }
1839
- // Hoisted vars and function declarations inside the body live in the
1840
- // function's var-environment. A `var ns = ...;` inside an `if` still
1841
- // shadows a module-level `ns` for the whole function.
1842
- const { body } = node as unknown as { body?: AstNode };
1843
- if (body) {
1844
- collectHoistedVarAndFunctionBindings(body, into);
1845
- }
1846
- };
1847
-
1848
- const collectBlockFrame: FrameCollector = (node, into) => {
1849
- const body = (node as unknown as { body?: readonly AstNode[] }).body ?? [];
1850
- for (const stmt of body) {
1851
- addBlockStatementBindings(stmt, into);
1852
- }
1853
- };
1854
-
1855
- const collectForStatementFrame: FrameCollector = (node, into) => {
1856
- const { init } = node as unknown as { init?: AstNode };
1857
- if (init && init.type === 'VariableDeclaration') {
1858
- addVarDeclarationBindingNames(init, into);
1859
- }
1860
- };
1861
-
1862
- const collectForInOfFrame: FrameCollector = (node, into) => {
1863
- const { left } = node as unknown as { left?: AstNode };
1864
- if (left && left.type === 'VariableDeclaration') {
1865
- addVarDeclarationBindingNames(left, into);
1866
- }
1867
- };
1868
-
1869
- const collectSwitchStatementFrame: FrameCollector = (node, into) => {
1870
- // `switch` shares one scope across every case. A binding in one case
1871
- // shadows the namespace across sibling cases (fall-through or otherwise).
1872
- const cases = (node as unknown as { cases?: readonly AstNode[] }).cases ?? [];
1873
- for (const c of cases) {
1874
- const consequent =
1875
- (c as unknown as { consequent?: readonly AstNode[] }).consequent ?? [];
1876
- for (const stmt of consequent) {
1877
- addBlockStatementBindings(stmt, into);
1878
- }
1879
- }
1880
- };
1881
-
1882
- const collectCatchClauseFrame: FrameCollector = (node, into) => {
1883
- const { param } = node as unknown as { param?: AstNode };
1884
- addPatternBindingNames(param, into);
1885
- };
1886
-
1887
- const collectClassExpressionFrame: FrameCollector = (node, into) => {
1888
- // A named `class expr` (`const C = class foo { ... }`) binds its own name
1889
- // inside its body only. ClassDeclaration names are hoisted into the
1890
- // enclosing block/program frame instead, so only class *expression* names
1891
- // need their own frame here.
1892
- addFunctionOrClassBindingName(node, into);
1893
- };
1894
-
1895
- const SCOPE_FRAME_COLLECTORS: Record<string, FrameCollector> = {
1896
- ArrowFunctionExpression: collectFunctionFrame,
1897
- BlockStatement: collectBlockFrame,
1898
- CatchClause: collectCatchClauseFrame,
1899
- ClassExpression: collectClassExpressionFrame,
1900
- ForInStatement: collectForInOfFrame,
1901
- ForOfStatement: collectForInOfFrame,
1902
- ForStatement: collectForStatementFrame,
1903
- // oxc-parser emits `FunctionBody` for `function` expression bodies; without
1904
- // this entry, a `const ns = ...` at the top of a function-expression body
1905
- // would not push a scope frame, and a module-level namespace import with
1906
- // the same name would be incorrectly recognized inside.
1907
- FunctionBody: collectBlockFrame,
1908
- FunctionDeclaration: collectFunctionFrame,
1909
- FunctionExpression: collectFunctionFrame,
1910
- Program: collectProgramFrame,
1911
- StaticBlock: collectBlockFrame,
1912
- SwitchStatement: collectSwitchStatementFrame,
1913
- };
1914
-
1915
- /**
1916
- * Collect the identifier bindings introduced *directly* by a scope frame
1917
- * node. Scope frames correspond to JS lexical scopes (function bodies, blocks,
1918
- * catch clauses, for-statements, switch statements, module/script roots).
1919
- */
1920
- export const collectScopeFrameBindings = (
1921
- node: AstNode
1922
- ): ReadonlySet<string> => {
1923
- const names = new Set<string>();
1924
- const collector = SCOPE_FRAME_COLLECTORS[node.type];
1925
- if (collector) {
1926
- collector(node, names);
1927
- }
1928
- return names;
1929
- };
1930
-
1931
- export type ScopeAwareVisitor = (
1932
- node: AstNode,
1933
- scopes: readonly ReadonlySet<string>[]
1934
- ) => void;
1935
-
1936
- export interface ScopeWalkOptions {
1937
- readonly initialScopes?: readonly ReadonlySet<string>[];
1938
- readonly stopAtNestedFunctions?: boolean;
1939
- }
1940
-
1941
- const asAstNode = (node: unknown): AstNode | null => {
1942
- if (!node || typeof node !== 'object') {
1943
- return null;
1944
- }
1945
- const astNode = node as AstNode;
1946
- return astNode.type ? astNode : null;
1947
- };
1948
-
1949
- /**
1950
- * Walk an AST subtree while threading lexical scope bindings through each
1951
- * visit. Callers can seed outer scopes and optionally stop at nested function
1952
- * boundaries when only the current implementation body should be analyzed.
1953
- */
1954
- export const walkWithScopes = (
1955
- node: unknown,
1956
- visit: ScopeAwareVisitor,
1957
- options: ScopeWalkOptions = {}
1958
- ): void => {
1959
- const root = asAstNode(node);
1960
- if (!root) {
1961
- return;
1962
- }
1963
-
1964
- const stack = [...(options.initialScopes ?? [])];
1965
-
1966
- const walkNode = (current: AstNode, isRoot: boolean): void => {
1967
- if (
1968
- !isRoot &&
1969
- options.stopAtNestedFunctions &&
1970
- FUNCTION_BOUNDARY_TYPES.has(current.type)
1971
- ) {
1972
- return;
1973
- }
1974
-
1975
- const isScope = current.type in SCOPE_FRAME_COLLECTORS;
1976
- if (isScope) {
1977
- stack.unshift(collectScopeFrameBindings(current));
1978
- }
1979
-
1980
- try {
1981
- visit(current, stack);
1982
- forEachAstChild(current, (child) => {
1983
- walkNode(child, false);
1984
- });
1985
- } finally {
1986
- if (isScope) {
1987
- stack.shift();
1988
- }
1989
- }
1990
- };
1991
-
1992
- walkNode(root, true);
1993
- };
1994
-
1995
- export const isShadowed = (
1996
- receiverName: string,
1997
- scopeStack: readonly ReadonlySet<string>[]
1998
- ): boolean => {
1999
- // The module-level Program frame is the last entry and contains the
2000
- // namespace imports themselves. A "shadow" must come from a frame *inside*
2001
- // that one — i.e. any frame except the outermost.
2002
- for (let i = 0; i < scopeStack.length - 1; i += 1) {
2003
- const frame = scopeStack[i];
2004
- if (frame?.has(receiverName)) {
2005
- return true;
2006
- }
2007
- }
2008
- return false;
2009
- };
2010
-
2011
- /**
2012
- * Return `true` when `node` is a non-computed member access (`a.b` /
2013
- * `a?.b`) and `false` for anything else, including computed access
2014
- * (`a[b]`) or non-member nodes. Exported as the canonical predicate so
2015
- * rule modules do not re-implement the check.
2016
- *
2017
- * @remarks
2018
- * Declared near the top of the file so the scope walker can use it
2019
- * without hitting `no-use-before-define`. A few sibling helpers in this
2020
- * module still inline the same shape under different local names for
2021
- * historical reasons; prefer this export for new call sites.
2022
- */
2023
- export const isMemberAccessNonComputed = (node: AstNode): boolean => {
2024
- if (
2025
- node.type !== 'MemberExpression' &&
2026
- node.type !== 'StaticMemberExpression'
2027
- ) {
2028
- return false;
2029
- }
2030
- return (node as unknown as { computed?: boolean }).computed !== true;
2031
- };
2032
-
2033
- const resolveNamespacedMemberNames = (
2034
- callee: AstNode
2035
- ): { readonly receiver: string; readonly property: string } | null => {
2036
- if (!isMemberAccessNonComputed(callee)) {
2037
- return null;
2038
- }
2039
- const { object } = callee as unknown as { object?: AstNode };
2040
- const receiver = identifierName(object);
2041
- if (!receiver) {
2042
- return null;
2043
- }
2044
- const prop = (callee as unknown as { property?: AstNode }).property;
2045
- const property =
2046
- prop?.type === 'Identifier'
2047
- ? ((prop as unknown as { name?: string }).name ?? null)
2048
- : null;
2049
- return property ? { property, receiver } : null;
2050
- };
2051
-
2052
- const getFrameworkCallReceiver = (
2053
- node: AstNode,
2054
- frameworkNamespaces: ReadonlySet<string>
2055
- ): string | null => {
2056
- if (node.type !== 'CallExpression') {
2057
- return null;
2058
- }
2059
- const callee = node['callee'] as AstNode | undefined;
2060
- if (!callee) {
2061
- return null;
2062
- }
2063
- const names = resolveNamespacedMemberNames(callee);
2064
- if (!names || !frameworkNamespaces.has(names.receiver)) {
2065
- return null;
2066
- }
2067
- return names.receiver;
2068
- };
2069
-
2070
- /**
2071
- * Walk the AST with a scope stack and collect `CallExpression` start offsets
2072
- * whose callee is `<receiver>.<property>` where `<receiver>` is proven to
2073
- * resolve to a framework namespace import (i.e. not shadowed by any
2074
- * enclosing scope). Used to gate namespaced `core.trail(...)` /
2075
- * `core.signal(...)` / `core.contour(...)` resolution against local shadows.
2076
- */
2077
- const collectFrameworkNamespacedCallStarts = (
2078
- ast: AstNode,
2079
- frameworkNamespaces: ReadonlySet<string>
2080
- ): ReadonlySet<number> => {
2081
- const starts = new Set<number>();
2082
- if (frameworkNamespaces.size === 0) {
2083
- return starts;
2084
- }
2085
-
2086
- walkWithScopes(ast, (node, scopes) => {
2087
- const receiver = getFrameworkCallReceiver(node, frameworkNamespaces);
2088
- if (!receiver || isShadowed(receiver, scopes)) {
2089
- return;
2090
- }
2091
- starts.add(node.start);
2092
- });
2093
-
2094
- return starts;
2095
- };
2096
-
2097
- const matchTrailPrimitiveName = (
2098
- name: string | undefined | null
2099
- ): string | null => (name && TRAIL_CALLEE_NAMES.has(name) ? name : null);
2100
-
2101
- const getBareTrailCalleeName = (callee: AstNode): string | null => {
2102
- if (callee.type !== 'Identifier') {
2103
- return null;
2104
- }
2105
- return matchTrailPrimitiveName((callee as unknown as { name?: string }).name);
2106
- };
2107
-
2108
- /**
2109
- * Extract the `{ receiverName, propertyName }` of a non-computed member-call
2110
- * callee, or null for anything else. Computed access (`ns[trail]()`) is
2111
- * intentionally rejected: the bracketed expression may resolve to any runtime
2112
- * value, so we cannot prove the call targets a specific member.
2113
- */
2114
- const isNonComputedMemberAccess = (callee: AstNode): boolean => {
2115
- if (
2116
- callee.type !== 'MemberExpression' &&
2117
- callee.type !== 'StaticMemberExpression'
2118
- ) {
2119
- return false;
2120
- }
2121
- return (callee as unknown as { computed?: boolean }).computed !== true;
2122
- };
2123
-
2124
- const getNamespacedMemberNames = (
2125
- callee: AstNode
2126
- ): { readonly receiver: string; readonly property: string } | null => {
2127
- if (!isNonComputedMemberAccess(callee)) {
2128
- return null;
2129
- }
2130
- const { object } = callee as unknown as { object?: AstNode };
2131
- const receiver = identifierName(object);
2132
- if (!receiver) {
2133
- return null;
2134
- }
2135
- const prop = (callee as unknown as { property?: AstNode }).property;
2136
- const property =
2137
- prop?.type === 'Identifier'
2138
- ? ((prop as unknown as { name?: string }).name ?? null)
2139
- : null;
2140
- return property ? { property, receiver } : null;
2141
- };
2142
-
2143
- /**
2144
- * Resolution context for namespaced framework-primitive calls. Bundles the
2145
- * bare namespace-binding set with an optional set of proven-safe
2146
- * `CallExpression` start offsets from a scope-aware pre-pass. When the set of
2147
- * safe starts is present, a namespaced call only resolves if its start is in
2148
- * that set — so a function-local shadow of the namespace import does not
2149
- * leak through. When absent (e.g. from test helpers), the name-only gate is
2150
- * used as a backward-compatible fallback.
2151
- */
2152
- export interface FrameworkNamespaceContext {
2153
- readonly namespaces: ReadonlySet<string>;
2154
- readonly safeCallStarts?: ReadonlySet<number>;
2155
- }
2156
-
2157
- const asNamespaceContext = (
2158
- input: ReadonlySet<string> | FrameworkNamespaceContext | undefined
2159
- ): FrameworkNamespaceContext | undefined => {
2160
- if (!input) {
2161
- return undefined;
2162
- }
2163
- return input instanceof Set
2164
- ? { namespaces: input }
2165
- : (input as FrameworkNamespaceContext);
2166
- };
2167
-
2168
- const isNamespacedCallAllowed = (
2169
- callStart: number,
2170
- receiver: string,
2171
- ctx: FrameworkNamespaceContext
2172
- ): boolean => {
2173
- if (!ctx.namespaces.has(receiver)) {
2174
- return false;
2175
- }
2176
- // When `safeCallStarts` is present, it is the authoritative gate — it was
2177
- // built by a scope-aware pre-pass and already excludes shadowed receivers.
2178
- // Without it, fall back to the bare name check (used by unit-test hooks).
2179
- return ctx.safeCallStarts ? ctx.safeCallStarts.has(callStart) : true;
2180
- };
2181
-
2182
- /**
2183
- * Resolve a namespaced `ns.trail(...)` / `ns.signal(...)` callee to its
2184
- * primitive name. When a {@link FrameworkNamespaceContext} is provided, the
2185
- * receiver must be a framework namespace binding AND — when a
2186
- * `safeCallStarts` set is present — the call site must appear in that set,
2187
- * meaning the receiver is not shadowed by any enclosing scope.
2188
- *
2189
- * When `context` is `undefined`, this falls back to permissive matching
2190
- * (any `ns.trail(...)` shape resolves). Inline resolution paths that do
2191
- * not have the surrounding AST available (e.g. `composes: [core.trail(...)]`
2192
- * or `on: [core.signal(...)]`) rely on this fallback. Scope-aware call
2193
- * sites always pass a context, so this only affects inline contexts where
2194
- * a best-effort name match is the intended behavior.
2195
- */
2196
- const getNamespacedTrailCalleeName = (
2197
- callExpr: AstNode,
2198
- callee: AstNode,
2199
- context?: ReadonlySet<string> | FrameworkNamespaceContext
2200
- ): string | null => {
2201
- const names = getNamespacedMemberNames(callee);
2202
- if (!names) {
2203
- return null;
2204
- }
2205
- const ctx = asNamespaceContext(context);
2206
- if (ctx && !isNamespacedCallAllowed(callExpr.start, names.receiver, ctx)) {
2207
- return null;
2208
- }
2209
- return matchTrailPrimitiveName(names.property);
2210
- };
2211
-
2212
- /**
2213
- * Resolve the callee name of a trail/signal call expression.
2214
- *
2215
- * Matches both bare `trail(...)` / `signal(...)` identifiers and namespaced
2216
- * member-expression callees like `core.trail(...)` or `ns.signal(...)`, where
2217
- * the namespace must come from an `@ontrails/*` import and, when the scope
2218
- * pre-pass is wired in, be unshadowed at the call site.
2219
- */
2220
- const getTrailCalleeName = (
2221
- node: AstNode,
2222
- context?: ReadonlySet<string> | FrameworkNamespaceContext
2223
- ): string | null => {
2224
- if (node.type !== 'CallExpression') {
2225
- return null;
2226
- }
2227
- const callee = node['callee'] as AstNode | undefined;
2228
- if (!callee) {
2229
- return null;
2230
- }
2231
- return (
2232
- getBareTrailCalleeName(callee) ??
2233
- getNamespacedTrailCalleeName(node, callee, context)
2234
- );
2235
- };
2236
-
2237
- /**
2238
- * Test hook: exposes {@link getTrailCalleeName} for unit tests.
2239
- *
2240
- * Kept unexported from the module's public surface (no re-export from
2241
- * `index.ts`) so internal refactors stay free.
2242
- */
2243
- export const __getTrailCalleeNameForTest = getTrailCalleeName;
2244
-
2245
- /**
2246
- * Test hook: exposes {@link collectFrameworkNamespaceBindings} for unit tests.
2247
- *
2248
- * Not re-exported from `index.ts`; the double-underscore prefix marks it as an
2249
- * internal-only handle so consumer code cannot rely on it.
2250
- */
2251
- export const __collectFrameworkNamespaceBindingsForTest =
2252
- collectFrameworkNamespaceBindings;
2253
-
2254
- /** Extract args from a trail() call, handling both two-arg and single-object forms. */
2255
- const extractTrailArgs = (
2256
- node: AstNode
2257
- ): { idArg: AstNode | null; configArg: AstNode } | null => {
2258
- const args = node['arguments'] as readonly AstNode[] | undefined;
2259
- if (!args || args.length === 0) {
2260
- return null;
2261
- }
2262
-
2263
- const [firstArg, secondArg] = args;
2264
- if (!firstArg) {
2265
- return null;
2266
- }
2267
-
2268
- // Two-arg form: trail('id', { ... })
2269
- if (secondArg && firstArg.type !== 'ObjectExpression') {
2270
- return { configArg: secondArg, idArg: firstArg };
2271
- }
2272
-
2273
- // Single-object form: trail({ id: 'x', ... })
2274
- return firstArg.type === 'ObjectExpression'
2275
- ? { configArg: firstArg, idArg: null }
2276
- : null;
2277
- };
2278
-
2279
- /** Extract the string value from an `id` property inside a config ObjectExpression. */
2280
- const extractIdFromConfig = (config: AstNode): string | null => {
2281
- const idProp = findConfigProperty(config, 'id');
2282
- if (!idProp || !idProp.value) {
2283
- return null;
2284
- }
2285
- return extractStringOrTemplateLiteral(idProp.value as AstNode);
2286
- };
2287
-
2288
- const extractTrailId = (trailArgs: {
2289
- idArg: AstNode | null;
2290
- configArg: AstNode;
2291
- }): string | null => {
2292
- if (trailArgs.idArg) {
2293
- return extractStringOrTemplateLiteral(trailArgs.idArg);
2294
- }
2295
- return extractIdFromConfig(trailArgs.configArg);
2296
- };
2297
-
2298
- const extractTrailDefinition = (
2299
- node: AstNode,
2300
- context?: ReadonlySet<string> | FrameworkNamespaceContext
2301
- ): TrailDefinition | null => {
2302
- const calleeName = getTrailCalleeName(node, context);
2303
- if (!calleeName) {
2304
- return null;
2305
- }
2306
-
2307
- const trailArgs = extractTrailArgs(node);
2308
- if (!trailArgs) {
2309
- return null;
2310
- }
2311
-
2312
- const trailId = extractTrailId(trailArgs);
2313
- if (!trailId) {
2314
- return null;
2315
- }
2316
-
2317
- return {
2318
- config: trailArgs.configArg,
2319
- id: trailId,
2320
- kind: calleeName,
2321
- start: node.start,
2322
- };
2323
- };
2324
-
2325
- const buildFrameworkNamespaceContext = (
2326
- ast: AstNode
2327
- ): FrameworkNamespaceContext => {
2328
- const namespaces = collectFrameworkNamespaceBindings(ast);
2329
- return {
2330
- namespaces,
2331
- safeCallStarts: collectFrameworkNamespacedCallStarts(ast, namespaces),
2332
- };
2333
- };
2334
-
2335
- export const findTrailDefinitions = (ast: AstNode): TrailDefinition[] => {
2336
- const definitions: TrailDefinition[] = [];
2337
- const context = buildFrameworkNamespaceContext(ast);
2338
-
2339
- walk(ast, (node) => {
2340
- const def = extractTrailDefinition(node, context);
2341
- if (def) {
2342
- definitions.push(def);
2343
- }
2344
- });
2345
-
2346
- return definitions;
2347
- };
2348
-
2349
- // ---------------------------------------------------------------------------
2350
- // Contour definition extraction
2351
- // ---------------------------------------------------------------------------
2352
-
2353
- export interface ContourDefinition {
2354
- /** Local binding name when the contour is assigned to a variable. */
2355
- readonly bindingName?: string;
2356
- /** Contour name string, e.g. "user". */
2357
- readonly name: string;
2358
- /** Original call expression for the contour declaration. */
2359
- readonly call: AstNode;
2360
- /** Options object argument passed to contour(), when present. */
2361
- readonly options: AstNode | null;
2362
- /** Shape object argument passed to contour(). */
2363
- readonly shape: AstNode;
2364
- /** Start offset of the call expression. */
2365
- readonly start: number;
2366
- }
2367
-
2368
- const CONTOUR_PRIMITIVE_NAME = 'contour';
2369
-
2370
- const matchContourPrimitiveName = (
2371
- name: string | undefined | null
2372
- ): string | null => (name === CONTOUR_PRIMITIVE_NAME ? name : null);
2373
-
2374
- const getBareContourCalleeName = (callee: AstNode): string | null => {
2375
- if (callee.type !== 'Identifier') {
2376
- return null;
2377
- }
2378
- return matchContourPrimitiveName(
2379
- (callee as unknown as { name?: string }).name
2380
- );
2381
- };
2382
-
2383
- /**
2384
- * Resolve a namespaced `ns.contour(...)` callee to its primitive name. Mirrors
2385
- * {@link getNamespacedTrailCalleeName}: the receiver identifier must resolve
2386
- * to an `@ontrails/*` namespace import, and — when a scope-aware
2387
- * `safeCallStarts` set is provided — the call site must not be shadowed by a
2388
- * local binding of the same name.
2389
- */
2390
- const getNamespacedContourCalleeName = (
2391
- callExpr: AstNode,
2392
- callee: AstNode,
2393
- context?: ReadonlySet<string> | FrameworkNamespaceContext
2394
- ): string | null => {
2395
- const names = getNamespacedMemberNames(callee);
2396
- if (!names) {
2397
- return null;
2398
- }
2399
- // Unlike the trail/signal variant, contour has no inline-resolution callers
2400
- // that legitimately invoke this without a FrameworkNamespaceContext, so the
2401
- // strict namespace gate stays on. If a future caller needs the permissive
2402
- // fallback, mirror the trail shape and add a regression test first.
2403
- const ctx = asNamespaceContext(context);
2404
- if (!ctx || !isNamespacedCallAllowed(callExpr.start, names.receiver, ctx)) {
2405
- return null;
2406
- }
2407
- return matchContourPrimitiveName(names.property);
2408
- };
2409
-
2410
- /**
2411
- * Resolve the callee name of a contour call expression. Matches both bare
2412
- * `contour(...)` identifiers and namespaced `core.contour(...)` callees where
2413
- * the namespace comes from an `@ontrails/*` import and is unshadowed.
2414
- */
2415
- const getContourCalleeName = (
2416
- node: AstNode,
2417
- context?: ReadonlySet<string> | FrameworkNamespaceContext
2418
- ): string | null => {
2419
- if (node.type !== 'CallExpression') {
2420
- return null;
2421
- }
2422
- const callee = node['callee'] as AstNode | undefined;
2423
- if (!callee) {
2424
- return null;
2425
- }
2426
- return (
2427
- getBareContourCalleeName(callee) ??
2428
- getNamespacedContourCalleeName(node, callee, context)
2429
- );
2430
- };
2431
-
2432
- const extractContourDefinition = (
2433
- node: AstNode,
2434
- context?: ReadonlySet<string> | FrameworkNamespaceContext
2435
- ): Omit<ContourDefinition, 'bindingName'> | null => {
2436
- if (!getContourCalleeName(node, context)) {
2437
- return null;
2438
- }
2439
-
2440
- const args = node['arguments'] as readonly AstNode[] | undefined;
2441
- const [nameArg, shapeArg, optionsArg] = args ?? [];
2442
- const name = extractStringLiteral(nameArg);
2443
- if (!name || shapeArg?.type !== 'ObjectExpression') {
2444
- return null;
2445
- }
2446
-
2447
- return {
2448
- call: node,
2449
- name,
2450
- options: optionsArg?.type === 'ObjectExpression' ? optionsArg : null,
2451
- shape: shapeArg,
2452
- start: node.start,
2453
- };
2454
- };
2455
-
2456
- const getCallStartFromCandidate = (
2457
- node: AstNode | undefined
2458
- ): number | null => {
2459
- if (!node) {
2460
- return null;
2461
- }
2462
- if (node.type === 'CallExpression') {
2463
- return node.start;
2464
- }
2465
- if (node.type !== 'ExpressionStatement') {
2466
- return null;
2467
- }
2468
- const { expression } = node as unknown as { expression?: AstNode };
2469
- return expression?.type === 'CallExpression' ? expression.start : null;
2470
- };
2471
-
2472
- // Statement forms that can directly contain a top-level contour call:
2473
- // `core.contour(...)` as a bare statement,
2474
- // `export const ... = core.contour(...)` (handled via VariableDeclarator),
2475
- // `export default core.contour(...);`.
2476
- const getCandidateCallHosts = (
2477
- statement: AstNode
2478
- ): readonly (AstNode | undefined)[] => {
2479
- if (
2480
- statement.type !== 'ExportNamedDeclaration' &&
2481
- statement.type !== 'ExportDefaultDeclaration'
2482
- ) {
2483
- return [statement];
2484
- }
2485
- const { declaration } = statement as unknown as {
2486
- declaration?: AstNode;
2487
- };
2488
- return [statement, declaration];
2489
- };
2490
-
2491
- const getTopLevelCallStartsFrom = (statement: AstNode): readonly number[] => {
2492
- const hosts = getCandidateCallHosts(statement);
2493
- const starts: number[] = [];
2494
- for (const host of hosts) {
2495
- const start = getCallStartFromCandidate(host);
2496
- if (start !== null) {
2497
- starts.push(start);
2498
- }
2499
- }
2500
- return starts;
2501
- };
2502
-
2503
- /**
2504
- * Collect the `start` offsets of `CallExpression` nodes that appear as
2505
- * top-level `ExpressionStatement`s in a program body — including inside a
2506
- * top-level `ExportNamedDeclaration` / `ExportDefaultDeclaration` wrapper.
2507
- * Used to discriminate top-level statement-form calls from inline nested
2508
- * calls when `topLevelOnly` is enabled.
2509
- */
2510
- const collectTopLevelStatementCallStarts = (
2511
- ast: AstNode
2512
- ): ReadonlySet<number> => {
2513
- const body = (ast as unknown as { body?: readonly AstNode[] }).body ?? [];
2514
- return new Set(body.flatMap(getTopLevelCallStartsFrom));
2515
- };
2516
-
2517
- export interface FindContourDefinitionsOptions {
2518
- /**
2519
- * When true, skip contour calls nested inside other expressions (e.g.
2520
- * `core.contour('inner', {...}).id()` used as a field of an outer contour).
2521
- * Top-level forms are still surfaced: both `const foo = contour(...)`
2522
- * declarations and bare `contour('name', {...});` statement-form calls that
2523
- * appear directly in the program body (optionally wrapped in `export`) are
2524
- * returned.
2525
- *
2526
- * Defaults to `false`: both top-level and inline contours are returned so
2527
- * that reference-site resolution can reach anonymous inline contours.
2528
- */
2529
- readonly topLevelOnly?: boolean;
2530
- }
2531
-
2532
- /**
2533
- * Return every `contour('name', ...)` definition reachable from the AST, in
2534
- * source order, deduplicated by call-expression start offset.
2535
- *
2536
- * Includes both top-level bindings (`const user = contour('user', ...)`) and
2537
- * inline contour calls nested inside other expressions (e.g.
2538
- * `contour('outer', { inner: contour('inner', ...).id() })`). Inline contours
2539
- * carry no `bindingName` because they have no local binding — this asymmetry
2540
- * is why {@link collectNamedContourIds} returns only the top-level subset
2541
- * while {@link collectContourDefinitionIds} returns the full set.
2542
- *
2543
- * Pass `{ topLevelOnly: true }` via `options` to opt out of inline discovery
2544
- * without disturbing callers that rely on the default behavior.
2545
- *
2546
- * @remarks
2547
- * Supplying a pre-built `context` skips the second full-AST traversal inside
2548
- * `buildFrameworkNamespaceContext` — useful for callers (such as
2549
- * {@link collectContourReferenceSites}) that already built one.
2550
- */
2551
- export const findContourDefinitions = (
2552
- ast: AstNode,
2553
- context?: FrameworkNamespaceContext,
2554
- options?: FindContourDefinitionsOptions
2555
- ): ContourDefinition[] => {
2556
- const definitions: ContourDefinition[] = [];
2557
- const seenStarts = new Set<number>();
2558
- const resolvedContext = context ?? buildFrameworkNamespaceContext(ast);
2559
- const topLevelOnly = options?.topLevelOnly === true;
2560
-
2561
- const addContourDefinition = (definition: ContourDefinition): void => {
2562
- if (seenStarts.has(definition.start)) {
2563
- return;
2564
- }
2565
-
2566
- definitions.push(definition);
2567
- seenStarts.add(definition.start);
2568
- };
2569
-
2570
- const addNamedContourDefinition = (
2571
- id: AstNode | undefined,
2572
- init: AstNode | undefined
2573
- ): void => {
2574
- if (!init) {
2575
- return;
2576
- }
2577
-
2578
- const definition = extractContourDefinition(init, resolvedContext);
2579
- if (!definition) {
2580
- return;
2581
- }
2582
-
2583
- const bindingName = extractBindingName(id);
2584
- if (bindingName) {
2585
- addContourDefinition({ ...definition, bindingName });
2586
- return;
2587
- }
2588
-
2589
- addContourDefinition(definition);
2590
- };
2591
-
2592
- // When `topLevelOnly` is set, collect the start offsets of call expressions
2593
- // that sit directly in the program body as `ExpressionStatement`s (optionally
2594
- // wrapped in `export`). These are top-level statement-form contour calls and
2595
- // should still surface alongside `VariableDeclarator` bindings; only calls
2596
- // nested inside other expressions are excluded.
2597
- const topLevelStatementCallStarts = topLevelOnly
2598
- ? collectTopLevelStatementCallStarts(ast)
2599
- : null;
2600
-
2601
- walk(ast, (node) => {
2602
- if (node.type === 'VariableDeclarator') {
2603
- const { id, init } = node as unknown as {
2604
- readonly id?: AstNode;
2605
- readonly init?: AstNode;
2606
- };
2607
- addNamedContourDefinition(id, init);
2608
- return;
2609
- }
2610
-
2611
- if (
2612
- topLevelStatementCallStarts &&
2613
- !topLevelStatementCallStarts.has(node.start)
2614
- ) {
2615
- return;
2616
- }
2617
-
2618
- const definition = extractContourDefinition(node, resolvedContext);
2619
- if (definition) {
2620
- addContourDefinition(definition);
2621
- }
2622
- });
2623
-
2624
- return definitions.toSorted((left, right) => left.start - right.start);
2625
- };
2626
-
2627
- /**
2628
- * Collect the `name` of every contour definition in a parsed file, including
2629
- * inline contours nested inside other expressions. Returns the same set of
2630
- * names that {@link findContourDefinitions} discovers under default options.
2631
- */
2632
- export const collectContourDefinitionIds = (
2633
- ast: AstNode
2634
- ): ReadonlySet<string> =>
2635
- new Set(findContourDefinitions(ast).map((def) => def.name));
2636
-
2637
- /**
2638
- * Collect the `localBinding → contourName` map for `const foo = contour(...)`
2639
- * declarations. Inline contour calls are intentionally excluded because they
2640
- * have no local binding — use {@link collectContourDefinitionIds} when the
2641
- * full set of declared names is required.
2642
- */
2643
- export const collectNamedContourIds = (
2644
- ast: AstNode
2645
- ): ReadonlyMap<string, string> => {
2646
- const ids = new Map<string, string>();
2647
-
2648
- for (const def of findContourDefinitions(ast)) {
2649
- if (def.bindingName) {
2650
- ids.set(def.bindingName, def.name);
2651
- }
2652
- }
2653
-
2654
- return ids;
2655
- };
2656
-
2657
- const resolveNamedImportedName = (
2658
- specifier: AstNode,
2659
- localName: string
2660
- ): string => {
2661
- const { imported } = specifier as unknown as { imported?: AstNode };
2662
- const importedName = imported
2663
- ? (identifierName(imported) ?? extractStringLiteral(imported))
2664
- : null;
2665
- return importedName ?? localName;
2666
- };
2667
-
2668
- const extractImportSpecifierAlias = (
2669
- specifier: AstNode
2670
- ): { readonly localName: string; readonly importedName: string } | null => {
2671
- if (
2672
- specifier.type !== 'ImportSpecifier' &&
2673
- specifier.type !== 'ImportDefaultSpecifier'
2674
- ) {
2675
- return null;
2676
- }
2677
-
2678
- const { local } = specifier as unknown as { local?: AstNode };
2679
- const localName = identifierName(local);
2680
- if (!localName) {
2681
- return null;
2682
- }
2683
-
2684
- // Default imports bind the default export of the source module to the local
2685
- // name. We cannot statically recover the exported name without compose-file
2686
- // analysis, so the local name is the best identifier we have for resolving
2687
- // against `knownContourIds`. Treat the alias as an identity mapping; the
2688
- // downstream resolver will fall through to `knownContourIds` on the binding
2689
- // name and report it as missing when not found.
2690
- if (specifier.type === 'ImportDefaultSpecifier') {
2691
- return { importedName: localName, localName };
2692
- }
2693
-
2694
- return {
2695
- importedName: resolveNamedImportedName(specifier, localName),
2696
- localName,
2697
- };
2698
- };
2699
-
2700
- /**
2701
- * Collect `import {
2702
- foo as bar
2703
- } from '...';` and `import bar from '...'`
2704
- * specifier mappings keyed by local binding name. The value is the original
2705
- * exported name for named imports. Default imports map to themselves because
2706
- * the exported name cannot be recovered statically — callers should fall
2707
- * through to `knownContourIds` membership on the local binding name.
2708
- */
2709
- export const collectImportAliasMap = (
2710
- ast: AstNode
2711
- ): ReadonlyMap<string, string> => {
2712
- const aliases = new Map<string, string>();
2713
-
2714
- walk(ast, (node) => {
2715
- if (node.type !== 'ImportDeclaration') {
2716
- return;
2717
- }
2718
-
2719
- const specifiers =
2720
- (node['specifiers'] as readonly AstNode[] | undefined) ?? [];
2721
- for (const specifier of specifiers) {
2722
- const alias = extractImportSpecifierAlias(specifier);
2723
- if (alias) {
2724
- aliases.set(alias.localName, alias.importedName);
2725
- }
2726
- }
2727
- });
2728
-
2729
- return aliases;
2730
- };
2731
-
2732
- const addUserNamespaceBindingsFromDeclaration = (
2733
- node: AstNode,
2734
- into: Set<string>
2735
- ): void => {
2736
- if (isFrameworkNamespaceSource(getImportSourceValue(node))) {
2737
- return;
2738
- }
2739
- const specifiers =
2740
- (node['specifiers'] as readonly AstNode[] | undefined) ?? [];
2741
- for (const specifier of specifiers) {
2742
- if (specifier.type !== 'ImportNamespaceSpecifier') {
2743
- continue;
2744
- }
2745
- const { local } = specifier as unknown as { local?: AstNode };
2746
- const localName = identifierName(local);
2747
- if (localName) {
2748
- into.add(localName);
2749
- }
2750
- }
2751
- };
2752
-
2753
- /**
2754
- * Collect local binding names introduced by `import * as <name> from '<src>'`
2755
- * declarations whose source is NOT an `@ontrails/*` framework package. These
2756
- * are user-defined namespace imports of contour modules (e.g. `import * as
2757
- * contours from './contours'`), used to resolve `contours.user` member-access
2758
- * references to contour ids.
2759
- *
2760
- * Framework namespace imports (`import * as core from '@ontrails/core'`) are
2761
- * intentionally excluded — they carry framework primitives like
2762
- * `core.contour(...)` and are resolved by {@link buildFrameworkNamespaceContext}.
2763
- * Mixing them here would treat `core.contour` as a reference to a contour
2764
- * named "contour", producing false positives.
2765
- */
2766
- export const collectUserNamespaceImportBindings = (
2767
- ast: AstNode
2768
- ): ReadonlySet<string> => {
2769
- const bindings = new Set<string>();
2770
-
2771
- walk(ast, (node) => {
2772
- if (node.type !== 'ImportDeclaration') {
2773
- return;
2774
- }
2775
- addUserNamespaceBindingsFromDeclaration(node, bindings);
2776
- });
2777
-
2778
- return bindings;
2779
- };
2780
-
2781
- /**
2782
- * Resolution context for user-namespace member access like `contours.user`.
2783
- * Bundles the set of local namespace-binding names (from `import * as x from
2784
- * './contours'`) with an optional set of proven-safe `MemberExpression` start
2785
- * offsets from a scope-aware pre-pass. When `safeMemberStarts` is present, a
2786
- * member access only resolves to a user-namespace target if its start is in
2787
- * the set — so a function-local shadow of the namespace import does not leak
2788
- * through. When absent, the name-only gate is used as a
2789
- * backward-compatible fallback for ad-hoc callers.
2790
- */
2791
- export interface UserNamespaceContext {
2792
- readonly bindings: ReadonlySet<string>;
2793
- readonly safeMemberStarts?: ReadonlySet<number>;
2794
- }
2795
-
2796
- /**
2797
- * Walk the AST with a scope stack and collect `MemberExpression` start offsets
2798
- * whose receiver is a user-namespace binding that is NOT shadowed by any
2799
- * enclosing scope. Mirrors `collectFrameworkNamespacedCallStarts` for the
2800
- * framework-namespace path so `contours.user` inside
2801
- * `function f(contours) { ... }` is rejected as shadowed.
2802
- */
2803
- /**
2804
- * Return the receiver-identifier name of a non-computed member access, or
2805
- * `null` for any other node shape (computed access, non-member, etc.).
2806
- */
2807
- const getNonComputedMemberReceiver = (node: AstNode): string | null => {
2808
- if (!isMemberAccessNonComputed(node)) {
2809
- return null;
2810
- }
2811
- const { object } = node as unknown as { object?: AstNode };
2812
- return object ? identifierName(object) : null;
2813
- };
2814
-
2815
- const collectUserNamespacedMemberStarts = (
2816
- ast: AstNode,
2817
- bindings: ReadonlySet<string>
2818
- ): ReadonlySet<number> => {
2819
- const starts = new Set<number>();
2820
- if (bindings.size === 0) {
2821
- return starts;
2822
- }
2823
-
2824
- walkWithScopes(ast, (node, scopes) => {
2825
- const receiver = getNonComputedMemberReceiver(node);
2826
- if (!receiver || !bindings.has(receiver) || isShadowed(receiver, scopes)) {
2827
- return;
2828
- }
2829
- starts.add(node.start);
2830
- });
2831
-
2832
- return starts;
2833
- };
2834
-
2835
- /**
2836
- * Build a {@link UserNamespaceContext} for `ast`, including the scope-aware
2837
- * `safeMemberStarts` gate. Prefer this over bare
2838
- * {@link collectUserNamespaceImportBindings} so member access like
2839
- * `contours.user` is rejected when `contours` is shadowed by a local binding.
2840
- */
2841
- export const buildUserNamespaceContext = (
2842
- ast: AstNode
2843
- ): UserNamespaceContext => {
2844
- const bindings = collectUserNamespaceImportBindings(ast);
2845
- return {
2846
- bindings,
2847
- safeMemberStarts: collectUserNamespacedMemberStarts(ast, bindings),
2848
- };
2849
- };
2850
-
2851
- export interface ContourReferenceSite {
2852
- /** Field on the source contour that declares the reference. */
2853
- readonly field: string;
2854
- /** Source contour name. */
2855
- readonly source: string;
2856
- /** Start offset of the field declaration. */
2857
- readonly start: number;
2858
- /** Target contour name. */
2859
- readonly target: string;
2860
- }
2861
-
2862
- /**
2863
- * Read a property key or member access identifier.
2864
- *
2865
- * Returns the identifier name for `Identifier` keys, or the underlying
2866
- * string literal value for computed access via `['name']` / `"name"`.
2867
- */
2868
- export const getPropertyName = (node: unknown): string | null => {
2869
- if (typeof node !== 'object' || node === null) {
2870
- return null;
2871
- }
2872
-
2873
- const { name } = node as { readonly name?: unknown };
2874
- if (typeof name === 'string') {
2875
- return name;
2876
- }
2877
-
2878
- return isAstNode(node) ? extractStringLiteral(node) : null;
2879
- };
2880
-
2881
- const stripContourSuffix = (name: string): string => {
2882
- const suffix = 'Contour';
2883
- return name.endsWith(suffix) ? name.slice(0, -suffix.length) : name;
2884
- };
2885
-
2886
- const resolveKnownContourName = (
2887
- name: string,
2888
- knownContourIds?: ReadonlySet<string>
2889
- ): string | null => {
2890
- if (knownContourIds?.has(name)) {
2891
- return name;
2892
- }
2893
-
2894
- // Support the common `const userContour = contour('user', ...)` naming
2895
- // pattern when callers refer to the binding name instead of the contour ID.
2896
- // Exact matches always win; suffix stripping is a fallback only.
2897
- const stripped = stripContourSuffix(name);
2898
- if (stripped !== name && knownContourIds?.has(stripped)) {
2899
- return stripped;
2900
- }
2901
-
2902
- return null;
2903
- };
2904
-
2905
- /**
2906
- * Resolve a local binding name to a contour ID, honoring import aliases.
2907
- *
2908
- * Strategies, in order:
2909
- * 1. Local `const foo = contour('name', ...)` binding → the contour name.
2910
- * 2. `knownContourIds` membership on the binding name itself (or the
2911
- * conventional `Contour` suffix strip).
2912
- * 3. `import { foo as bar }` → use the original exported name `foo`
2913
- * (and apply strategy 2 / suffix-stripping against it so aliased imports
2914
- * resolve correctly). If the imported name still isn't recognized, the
2915
- * imported name is returned so the caller can report it missing.
2916
- *
2917
- * Returns `null` only when the name belongs to no known resolution path —
2918
- * no local binding, no known contour ID, no import, and no suffix match.
2919
- * Returning `null` means "this identifier is not a contour reference we can
2920
- * reason about" (e.g. a bare undeclared variable), as opposed to
2921
- * "a contour reference whose target is missing".
2922
- */
2923
- export const deriveContourIdentifierName = (
2924
- bindingName: string,
2925
- namedContourIds: ReadonlyMap<string, string>,
2926
- knownContourIds?: ReadonlySet<string>,
2927
- importAliases?: ReadonlyMap<string, string>
2928
- ): string | null => {
2929
- const localName = namedContourIds.get(bindingName);
2930
- if (localName) {
2931
- return localName;
2932
- }
2933
-
2934
- const known = resolveKnownContourName(bindingName, knownContourIds);
2935
- if (known) {
2936
- return known;
2937
- }
2938
-
2939
- // If the binding came from an import, use the original exported name as
2940
- // the resolution target. This lets `import { foo as bar }` resolve to
2941
- // the exported `foo` rather than the local alias `bar`. If the imported
2942
- // name still isn't recognized, return it so callers can report it as
2943
- // missing under its original name.
2944
- const importedName = importAliases?.get(bindingName);
2945
- if (importedName) {
2946
- return (
2947
- resolveKnownContourName(importedName, knownContourIds) ?? importedName
2948
- );
2949
- }
2950
-
2951
- return null;
2952
- };
2953
-
2954
- const getContourReferenceMember = (
2955
- node: AstNode
2956
- ): {
2957
- readonly object?: AstNode;
2958
- readonly property?: AstNode;
2959
- readonly start: number;
2960
- } | null => {
2961
- if (
2962
- node.type !== 'MemberExpression' &&
2963
- node.type !== 'StaticMemberExpression'
2964
- ) {
2965
- return null;
2966
- }
2967
-
2968
- return node as unknown as {
2969
- readonly object?: AstNode;
2970
- readonly property?: AstNode;
2971
- readonly start: number;
2972
- };
2973
- };
2974
-
2975
- const asUserNamespaceContext = (
2976
- input: ReadonlySet<string> | UserNamespaceContext | undefined
2977
- ): UserNamespaceContext | undefined => {
2978
- if (!input) {
2979
- return undefined;
2980
- }
2981
- return input instanceof Set
2982
- ? { bindings: input }
2983
- : (input as UserNamespaceContext);
2984
- };
2985
-
2986
- /**
2987
- * Resolve a user-namespace member access like `contours.user` to its contour
2988
- * id. Returns the property name (e.g. `'user'`) when the receiver identifier
2989
- * is a known user-defined namespace binding AND — when the caller provides a
2990
- * {@link UserNamespaceContext} with `safeMemberStarts` — the member access
2991
- * site is in that set (i.e. the receiver is not shadowed by any enclosing
2992
- * scope). Otherwise returns `null`.
2993
- *
2994
- * The property name is taken as the contour id verbatim — we cannot statically
2995
- * resolve what `contours.user` binds to without reading the other file, so we
2996
- * treat the member name as the candidate target and let
2997
- * {@link deriveContourIdentifierName}'s downstream `knownContourIds` check
2998
- * report a missing target.
2999
- */
3000
- export const isUserNamespaceReceiverAllowed = (
3001
- receiver: string,
3002
- memberStart: number,
3003
- ctx: UserNamespaceContext
3004
- ): boolean => {
3005
- if (!ctx.bindings.has(receiver)) {
3006
- return false;
3007
- }
3008
- // Scope-aware gate: when the pre-pass produced a set, the member access
3009
- // must appear in it. Without the set, fall back to the bare name check.
3010
- return ctx.safeMemberStarts ? ctx.safeMemberStarts.has(memberStart) : true;
3011
- };
3012
-
3013
- const getContourReferenceTargetFromNamespaceMember = (
3014
- member: {
3015
- readonly object?: AstNode;
3016
- readonly property?: AstNode;
3017
- readonly start: number;
3018
- },
3019
- userNamespace?: ReadonlySet<string> | UserNamespaceContext
3020
- ): string | null => {
3021
- const ctx = asUserNamespaceContext(userNamespace);
3022
- if (!ctx || ctx.bindings.size === 0) {
3023
- return null;
3024
- }
3025
- const receiver = member.object ? identifierName(member.object) : null;
3026
- if (
3027
- !receiver ||
3028
- !isUserNamespaceReceiverAllowed(receiver, member.start, ctx)
3029
- ) {
3030
- return null;
3031
- }
3032
- const { property } = member;
3033
- if (!property || property.type !== 'Identifier') {
3034
- return null;
3035
- }
3036
- return identifierName(property);
3037
- };
3038
-
3039
- const getContourReferenceTargetFromObject = (
3040
- object: AstNode,
3041
- namedContourIds: ReadonlyMap<string, string>,
3042
- knownContourIds?: ReadonlySet<string>,
3043
- importAliases?: ReadonlyMap<string, string>,
3044
- context?: ReadonlySet<string> | FrameworkNamespaceContext,
3045
- userNamespace?: ReadonlySet<string> | UserNamespaceContext
3046
- ): string | null => {
3047
- if (object.type === 'Identifier') {
3048
- const bindingName = identifierName(object);
3049
- return bindingName
3050
- ? deriveContourIdentifierName(
3051
- bindingName,
3052
- namedContourIds,
3053
- knownContourIds,
3054
- importAliases
3055
- )
3056
- : null;
3057
- }
3058
-
3059
- const member = getContourReferenceMember(object);
3060
- if (member) {
3061
- const namespaceTarget = getContourReferenceTargetFromNamespaceMember(
3062
- member,
3063
- userNamespace
3064
- );
3065
- if (namespaceTarget) {
3066
- return namespaceTarget;
3067
- }
3068
- }
3069
-
3070
- return extractContourDefinition(object, context)?.name ?? null;
3071
- };
3072
-
3073
- const CONTOUR_ID_WRAPPER_METHODS = new Set([
3074
- 'brand',
3075
- 'catch',
3076
- 'default',
3077
- 'describe',
3078
- 'meta',
3079
- 'nullable',
3080
- 'nullish',
3081
- 'optional',
3082
- 'readonly',
3083
- ]);
3084
-
3085
- const getContourIdCallMember = (
3086
- node: AstNode
3087
- ): {
3088
- readonly member: NonNullable<ReturnType<typeof getContourReferenceMember>>;
3089
- readonly propertyName: string;
3090
- } | null => {
3091
- const callee = node['callee'] as AstNode | undefined;
3092
- const member = callee ? getContourReferenceMember(callee) : null;
3093
- const propertyName = member ? identifierName(member.property) : null;
3094
- return member && propertyName ? { member, propertyName } : null;
3095
- };
3096
-
3097
- const getContourIdCallObject = function getContourIdCallObject(
3098
- node: AstNode | undefined
3099
- ): AstNode | null {
3100
- const current = node;
3101
- if (!current || current.type !== 'CallExpression') {
3102
- return null;
3103
- }
3104
-
3105
- const member = getContourIdCallMember(current);
3106
- if (!member) {
3107
- return null;
3108
- }
3109
- if (member.propertyName === 'id') {
3110
- return member.member.object ?? null;
3111
- }
3112
-
3113
- return CONTOUR_ID_WRAPPER_METHODS.has(member.propertyName)
3114
- ? getContourIdCallObject(member.member.object)
3115
- : null;
3116
- };
3117
-
3118
- const extractContourReferenceTarget = (
3119
- node: AstNode | undefined,
3120
- namedContourIds: ReadonlyMap<string, string>,
3121
- knownContourIds?: ReadonlySet<string>,
3122
- importAliases?: ReadonlyMap<string, string>,
3123
- context?: ReadonlySet<string> | FrameworkNamespaceContext,
3124
- userNamespace?: ReadonlySet<string> | UserNamespaceContext
3125
- ): string | null => {
3126
- const object = getContourIdCallObject(node);
3127
- return object
3128
- ? getContourReferenceTargetFromObject(
3129
- object,
3130
- namedContourIds,
3131
- knownContourIds,
3132
- importAliases,
3133
- context,
3134
- userNamespace
3135
- )
3136
- : null;
3137
- };
3138
-
3139
- const getContourShapeProperties = (
3140
- definition: ContourDefinition
3141
- ): readonly AstNode[] =>
3142
- (definition.shape['properties'] as readonly AstNode[] | undefined) ?? [];
3143
-
3144
- const buildContourReferenceSite = (
3145
- definition: ContourDefinition,
3146
- property: AstNode,
3147
- namedContourIds: ReadonlyMap<string, string>,
3148
- knownContourIds?: ReadonlySet<string>,
3149
- importAliases?: ReadonlyMap<string, string>,
3150
- context?: ReadonlySet<string> | FrameworkNamespaceContext,
3151
- userNamespace?: ReadonlySet<string> | UserNamespaceContext
3152
- ): ContourReferenceSite | null => {
3153
- if (property.type !== 'Property') {
3154
- return null;
3155
- }
3156
-
3157
- const field = getPropertyName(property.key);
3158
- const target = extractContourReferenceTarget(
3159
- property.value as AstNode | undefined,
3160
- namedContourIds,
3161
- knownContourIds,
3162
- importAliases,
3163
- context,
3164
- userNamespace
3165
- );
3166
- if (!field || !target) {
3167
- return null;
3168
- }
3169
-
3170
- return {
3171
- field,
3172
- source: definition.name,
3173
- start: property.start,
3174
- target,
3175
- };
3176
- };
3177
-
3178
- const findContourReferenceSitesForDefinition = (
3179
- definition: ContourDefinition,
3180
- namedContourIds: ReadonlyMap<string, string>,
3181
- knownContourIds?: ReadonlySet<string>,
3182
- importAliases?: ReadonlyMap<string, string>,
3183
- context?: ReadonlySet<string> | FrameworkNamespaceContext,
3184
- userNamespace?: ReadonlySet<string> | UserNamespaceContext
3185
- ): readonly ContourReferenceSite[] =>
3186
- getContourShapeProperties(definition).flatMap((property) => {
3187
- const reference = buildContourReferenceSite(
3188
- definition,
3189
- property,
3190
- namedContourIds,
3191
- knownContourIds,
3192
- importAliases,
3193
- context,
3194
- userNamespace
3195
- );
3196
- return reference ? [reference] : [];
3197
- });
3198
-
3199
- /** Collect all contour field references declared via `.id()` in a parsed file. */
3200
- export const collectContourReferenceSites = (
3201
- ast: AstNode,
3202
- knownContourIds?: ReadonlySet<string>
3203
- ): readonly ContourReferenceSite[] => {
3204
- const namedContourIds = collectNamedContourIds(ast);
3205
- const importAliases = collectImportAliasMap(ast);
3206
- const userNamespace = buildUserNamespaceContext(ast);
3207
- const context = buildFrameworkNamespaceContext(ast);
3208
- return findContourDefinitions(ast, context).flatMap((definition) =>
3209
- findContourReferenceSitesForDefinition(
3210
- definition,
3211
- namedContourIds,
3212
- knownContourIds,
3213
- importAliases,
3214
- context,
3215
- userNamespace
3216
- )
3217
- );
3218
- };
3219
-
3220
- /** Collect contour reference targets keyed by source contour name. */
3221
- export const collectContourReferenceTargetsByName = (
3222
- ast: AstNode,
3223
- knownContourIds?: ReadonlySet<string>
3224
- ): ReadonlyMap<string, readonly string[]> => {
3225
- const targetsByName = new Map<string, Set<string>>();
3226
-
3227
- for (const reference of collectContourReferenceSites(ast, knownContourIds)) {
3228
- const existing = targetsByName.get(reference.source);
3229
- if (existing) {
3230
- existing.add(reference.target);
3231
- continue;
3232
- }
3233
-
3234
- targetsByName.set(reference.source, new Set([reference.target]));
3235
- }
3236
-
3237
- return new Map(
3238
- [...targetsByName.entries()].map(([name, targets]) => [name, [...targets]])
3239
- );
3240
- };
3241
-
3242
- // ---------------------------------------------------------------------------
3243
- // Blaze body extraction
3244
- // ---------------------------------------------------------------------------
3245
-
3246
- /**
3247
- * Extract top-level `blaze:` property values from an ObjectExpression's direct properties.
3248
- *
3249
- * Does not recurse into nested objects, so `meta: { blaze: ... }` is ignored.
3250
- */
3251
- const extractBlazeFromConfig = (config: AstNode): AstNode[] => {
3252
- const bodies: AstNode[] = [];
3253
- const properties = config['properties'] as readonly AstNode[] | undefined;
3254
- if (!properties) {
3255
- return bodies;
3256
- }
3257
- for (const prop of properties) {
3258
- if (
3259
- isProperty(prop) &&
3260
- identifierName(prop.key) === 'blaze' &&
3261
- isAstNode(prop.value)
3262
- ) {
3263
- bodies.push(prop.value);
3264
- }
3265
- }
3266
- return bodies;
3267
- };
3268
-
3269
- /**
3270
- * Find `blaze:` property values.
3271
- *
3272
- * When given an ObjectExpression (trail config), returns only its direct `blaze:`
3273
- * properties. When given a full AST, finds trail definitions first and extracts
3274
- * `blaze:` from each config — in both cases ignoring nested `blaze:` properties
3275
- * (e.g. `meta: { blaze: ... }`).
3276
- */
3277
- export const findBlazeBodies = (node: AstNode): AstNode[] => {
3278
- if (node.type === 'ObjectExpression') {
3279
- return extractBlazeFromConfig(node);
3280
- }
3281
-
3282
- // Full AST — find trail definitions and extract blaze from their configs
3283
- const bodies: AstNode[] = [];
3284
- for (const def of findTrailDefinitions(node)) {
3285
- bodies.push(...extractBlazeFromConfig(def.config));
3286
- }
3287
- return bodies;
3288
- };
3289
-
3290
- /**
3291
- * Collect all `signal('id', { ... })` / `signal({ id: 'x', ... })` definition IDs.
3292
- *
3293
- * Uses `findTrailDefinitions` under the hood — it already recognizes both
3294
- * `trail` and `signal` call sites, distinguished by the `kind` field.
3295
- */
3296
- export const collectSignalDefinitionIds = (
3297
- ast: AstNode
3298
- ): ReadonlySet<string> => {
3299
- const ids = new Set<string>();
3300
- for (const def of findTrailDefinitions(ast)) {
3301
- if (def.kind === 'signal') {
3302
- ids.add(def.id);
3303
- }
3304
- }
3305
- return ids;
3306
- };
3307
-
3308
- const unwrapTopLevelDeclaration = (stmt: AstNode): AstNode => {
3309
- if (
3310
- stmt.type === 'ExportNamedDeclaration' ||
3311
- stmt.type === 'ExportDefaultDeclaration'
3312
- ) {
3313
- return (stmt as unknown as { declaration?: AstNode }).declaration ?? stmt;
3314
- }
3315
- return stmt;
3316
- };
3317
-
3318
- const collectSignalIdsFromDeclaration = (
3319
- declaration: AstNode,
3320
- context: FrameworkNamespaceContext,
3321
- ids: Map<string, string>
3322
- ): void => {
3323
- const declarations =
3324
- (
3325
- unwrapTopLevelDeclaration(declaration) as unknown as {
3326
- declarations?: readonly AstNode[];
3327
- }
3328
- ).declarations ?? [];
3329
-
3330
- for (const node of declarations) {
3331
- const { id, init } = node as unknown as {
3332
- readonly id?: AstNode;
3333
- readonly init?: AstNode;
3334
- };
3335
- if (!init) {
3336
- continue;
3337
- }
3338
-
3339
- const def = extractTrailDefinition(init, context);
3340
- const name = extractBindingName(id);
3341
- if (def?.kind === 'signal' && name && !ids.has(name)) {
3342
- ids.set(name, def.id);
3343
- }
3344
- }
3345
- };
3346
-
3347
- const collectStringIdsFromDeclaration = (
3348
- declaration: AstNode,
3349
- ids: Map<string, string>
3350
- ): void => {
3351
- const declarations =
3352
- (
3353
- unwrapTopLevelDeclaration(declaration) as unknown as {
3354
- declarations?: readonly AstNode[];
3355
- }
3356
- ).declarations ?? [];
3357
-
3358
- for (const node of declarations) {
3359
- const { id, init } = node as unknown as {
3360
- readonly id?: AstNode;
3361
- readonly init?: AstNode;
3362
- };
3363
- if (!init) {
3364
- continue;
3365
- }
3366
-
3367
- const name = extractBindingName(id);
3368
- const value =
3369
- extractStringLiteral(init) ?? extractPlainTemplateLiteral(init);
3370
- if (name && value !== null && !ids.has(name)) {
3371
- ids.set(name, value);
3372
- }
3373
- }
3374
- };
3375
-
3376
- export type SignalIdentifierResolution =
3377
- | {
3378
- readonly id: string;
3379
- readonly kind: 'signal' | 'string';
3380
- }
3381
- | {
3382
- readonly kind: 'shadowed' | 'unbound';
3383
- };
3384
-
3385
- export interface SignalIdentifierResolver {
3386
- readonly resolve: (reference: AstNode) => SignalIdentifierResolution;
3387
- }
3388
-
3389
- interface SignalScopeFrame {
3390
- readonly bindings: ReadonlySet<string>;
3391
- readonly end: number;
3392
- readonly signals: ReadonlyMap<string, string>;
3393
- readonly start: number;
3394
- readonly strings: ReadonlyMap<string, string>;
3395
- }
3396
-
3397
- const collectSignalFrameValues = (
3398
- node: AstNode,
3399
- context: FrameworkNamespaceContext
3400
- ): {
3401
- readonly signals: ReadonlyMap<string, string>;
3402
- readonly strings: ReadonlyMap<string, string>;
3403
- } => {
3404
- const signals = new Map<string, string>();
3405
- const strings = new Map<string, string>();
3406
-
3407
- const collectDeclaration = (statement: AstNode): void => {
3408
- const declaration = unwrapTopLevelDeclaration(statement);
3409
- if (declaration.type !== 'VariableDeclaration') {
3410
- return;
3411
- }
3412
- collectSignalIdsFromDeclaration(declaration, context, signals);
3413
- collectStringIdsFromDeclaration(declaration, strings);
3414
- };
3415
-
3416
- if (
3417
- node.type === 'Program' ||
3418
- node.type === 'BlockStatement' ||
3419
- node.type === 'FunctionBody'
3420
- ) {
3421
- const body = (node as unknown as { body?: readonly AstNode[] }).body ?? [];
3422
- for (const statement of body) {
3423
- collectDeclaration(statement);
3424
- }
3425
- }
3426
-
3427
- if (node.type === 'ForStatement') {
3428
- const { init } = node as unknown as { init?: AstNode };
3429
- if (init) {
3430
- collectDeclaration(init);
3431
- }
3432
- }
3433
-
3434
- if (node.type === 'SwitchStatement') {
3435
- const cases =
3436
- (node as unknown as { cases?: readonly AstNode[] }).cases ?? [];
3437
- for (const item of cases) {
3438
- const consequent =
3439
- (item as unknown as { consequent?: readonly AstNode[] }).consequent ??
3440
- [];
3441
- for (const statement of consequent) {
3442
- collectDeclaration(statement);
3443
- }
3444
- }
3445
- }
3446
-
3447
- return { signals, strings };
3448
- };
3449
-
3450
- const collectSignalScopeFrames = (
3451
- ast: AstNode,
3452
- context: FrameworkNamespaceContext
3453
- ): readonly SignalScopeFrame[] => {
3454
- const frames: SignalScopeFrame[] = [];
3455
-
3456
- walk(ast, (node) => {
3457
- if (!(node.type in SCOPE_FRAME_COLLECTORS)) {
3458
- return;
3459
- }
3460
- const values = collectSignalFrameValues(node, context);
3461
- frames.push({
3462
- bindings: collectScopeFrameBindings(node),
3463
- end: node.end,
3464
- signals: values.signals,
3465
- start: node.start,
3466
- strings: values.strings,
3467
- });
3468
- });
3469
-
3470
- return frames;
3471
- };
3472
-
3473
- const isInsideFrame = (reference: AstNode, frame: SignalScopeFrame): boolean =>
3474
- frame.start <= reference.start && reference.end <= frame.end;
3475
-
3476
- const compareInnermostFrame = (
3477
- a: SignalScopeFrame,
3478
- b: SignalScopeFrame
3479
- ): number => {
3480
- const aSize = a.end - a.start;
3481
- const bSize = b.end - b.start;
3482
- return aSize - bSize || b.start - a.start;
3483
- };
3484
-
3485
- export const buildSignalIdentifierResolver = (
3486
- ast: AstNode
3487
- ): SignalIdentifierResolver => {
3488
- const context = buildFrameworkNamespaceContext(ast);
3489
- const frames = collectSignalScopeFrames(ast, context);
3490
-
3491
- return {
3492
- resolve(reference: AstNode): SignalIdentifierResolution {
3493
- const name = identifierName(reference);
3494
- if (!name) {
3495
- return { kind: 'unbound' };
3496
- }
3497
-
3498
- const containingFrames = frames
3499
- .filter((frame) => isInsideFrame(reference, frame))
3500
- .toSorted(compareInnermostFrame);
3501
-
3502
- for (const frame of containingFrames) {
3503
- if (!frame.bindings.has(name)) {
3504
- continue;
3505
- }
3506
- const signalId = frame.signals.get(name);
3507
- if (signalId) {
3508
- return { id: signalId, kind: 'signal' };
3509
- }
3510
- const stringId = frame.strings.get(name);
3511
- if (stringId) {
3512
- return { id: stringId, kind: 'string' };
3513
- }
3514
- return { kind: 'shadowed' };
3515
- }
3516
-
3517
- return { kind: 'unbound' };
3518
- },
3519
- };
3520
- };
3521
-
3522
- /** Collect `const foo = trail('id', ...)` bindings from a parsed file. */
3523
- export const collectNamedTrailIds = (
3524
- ast: AstNode
3525
- ): ReadonlyMap<string, string> => {
3526
- const ids = new Map<string, string>();
3527
- const context = buildFrameworkNamespaceContext(ast);
3528
-
3529
- walk(ast, (node) => {
3530
- if (node.type !== 'VariableDeclarator') {
3531
- return;
3532
- }
3533
-
3534
- const { id, init } = node as unknown as {
3535
- readonly id?: AstNode;
3536
- readonly init?: AstNode;
3537
- };
3538
- if (!init) {
3539
- return;
3540
- }
3541
-
3542
- const def = extractTrailDefinition(init, context);
3543
- const name = extractBindingName(id);
3544
- if (def?.kind === 'trail' && name) {
3545
- ids.set(name, def.id);
3546
- }
3547
- });
3548
-
3549
- return ids;
3550
- };
3551
-
3552
- /** Extract the raw `composes: [...]` array elements from a trail config. */
3553
- export const getComposeElements = (config: AstNode): readonly AstNode[] => {
3554
- const composesProp = findConfigProperty(config, 'composes');
3555
- if (!composesProp) {
3556
- return [];
3557
- }
3558
-
3559
- const arrayNode = composesProp.value;
3560
- if (!arrayNode || (arrayNode as AstNode).type !== 'ArrayExpression') {
3561
- return [];
3562
- }
3563
-
3564
- const elements = (arrayNode as AstNode)['elements'] as
3565
- | readonly AstNode[]
3566
- | undefined;
3567
- return elements ?? [];
3568
- };
3569
-
3570
- /**
3571
- * Resolve a single `composes: [...]` element to its target trail ID.
3572
- *
3573
- * Handles string literals, identifier references (via `namedTrailIds` map or
3574
- * `const NAME = '...'` resolution), and inline `trail(...)` call expressions.
3575
- */
3576
- export const deriveComposeElementId = (
3577
- element: AstNode,
3578
- sourceCode: string,
3579
- namedTrailIds: ReadonlyMap<string, string>
3580
- ): string | null => {
3581
- if (isStringLiteral(element)) {
3582
- return getStringValue(element);
3583
- }
3584
-
3585
- if (element.type === 'Identifier') {
3586
- const name = identifierName(element);
3587
- return name
3588
- ? (namedTrailIds.get(name) ?? deriveConstString(name, sourceCode))
3589
- : null;
3590
- }
3591
-
3592
- const inlineDef = extractTrailDefinition(element);
3593
- return inlineDef?.kind === 'trail' ? inlineDef.id : null;
3594
- };
3595
-
3596
- /**
3597
- * Collect all trail IDs referenced by a single trail definition's
3598
- * `composes: [...]` array, deduplicated.
3599
- */
3600
- export const extractDefinitionComposeTargetIds = (
3601
- config: AstNode,
3602
- sourceCode: string,
3603
- namedTrailIds: ReadonlyMap<string, string>
3604
- ): readonly string[] => [
3605
- ...new Set(
3606
- getComposeElements(config).flatMap((element) => {
3607
- const id = deriveComposeElementId(element, sourceCode, namedTrailIds);
3608
- return id ? [id] : [];
3609
- })
3610
- ),
3611
- ];
3612
-
3613
- /** Collect all trail IDs referenced by declared `composes: [...]` arrays. */
3614
- export const collectComposeTargetTrailIds = (
3615
- ast: AstNode,
3616
- sourceCode: string
3617
- ): ReadonlySet<string> => {
3618
- const ids = new Set<string>();
3619
- const namedTrailIds = collectNamedTrailIds(ast);
3620
-
3621
- for (const def of findTrailDefinitions(ast)) {
3622
- if (def.kind !== 'trail') {
3623
- continue;
3624
- }
3625
-
3626
- for (const id of extractDefinitionComposeTargetIds(
3627
- def.config,
3628
- sourceCode,
3629
- namedTrailIds
3630
- )) {
3631
- ids.add(id);
3632
- }
3633
- }
3634
-
3635
- return ids;
3636
- };
3637
-
3638
- const INTENT_VALUE_SET = new Set<string>(intentValues);
3639
- const DEFAULT_INTENT: Intent = 'write';
3640
-
3641
- const normalizeTrailIntent = (value: string): Intent =>
3642
- INTENT_VALUE_SET.has(value) ? (value as Intent) : DEFAULT_INTENT;
3643
-
3644
- const extractTrailIntent = (config: AstNode): Intent => {
3645
- const intentProp = findConfigProperty(config, 'intent');
3646
- if (!intentProp || !isStringLiteral(intentProp.value as AstNode)) {
3647
- return DEFAULT_INTENT;
3648
- }
3649
-
3650
- const value = getStringValue(intentProp.value as AstNode);
3651
- return value ? normalizeTrailIntent(value) : DEFAULT_INTENT;
3652
- };
3653
-
3654
- /** Collect the normalized intent for every trail definition in a parsed file. */
3655
- export const collectTrailIntentsById = (
3656
- ast: AstNode
3657
- ): ReadonlyMap<string, Intent> => {
3658
- const intents = new Map<string, Intent>();
3659
-
3660
- for (const def of findTrailDefinitions(ast)) {
3661
- if (def.kind === 'trail') {
3662
- intents.set(def.id, extractTrailIntent(def.config));
3663
- }
3664
- }
3665
-
3666
- return intents;
3667
- };
3668
-
3669
- // ---------------------------------------------------------------------------
3670
- // Store / factory pattern extraction
3671
- // ---------------------------------------------------------------------------
3672
-
3673
- export interface StoreTableDefinition {
3674
- /** Table name declared inside store({ ... }). */
3675
- readonly name: string;
3676
- /**
3677
- * Local binding name of the enclosing `store(...)` declaration, if the
3678
- * `store(...)` call is bound to a `const`/`let`/`var` (e.g. `db` in
3679
- * `const db = store({ ... })`). Null for anonymous stores.
3680
- */
3681
- readonly storeBinding: string | null;
3682
- /**
3683
- * Stable composite key for this table in the form `${storeBinding}:${name}`,
3684
- * falling back to the bare `name` when the store is anonymous. Use this for
3685
- * compose-rule / compose-file keying so two stores with the same table name
3686
- * never collide.
3687
- */
3688
- readonly key: string;
3689
- /** Start offset of the table property declaration. */
3690
- readonly start: number;
3691
- /** Whether the authored table opts into version tracking. */
3692
- readonly versioned: boolean;
3693
- }
3694
-
3695
- /**
3696
- * Build a composite key for a store table: `${storeBinding}:${tableName}`,
3697
- * falling back to the bare `tableName` when the enclosing store has no local
3698
- * binding. Centralized so rule keying stays stable.
3699
- *
3700
- * @remarks
3701
- * The key is intentionally file-local (no module path prefix). Compose-file
3702
- * aggregation in `ProjectContext` merges keys from all files, so two files
3703
- * with `const db = store({ notes: ... })` both produce `db:notes` — this is
3704
- * the desired behavior because the warden checks for *pattern completeness*
3705
- * across the project and matching keys signals that the same logical table
3706
- * is covered. If two genuinely different tables share a binding and name,
3707
- * that is a code-level naming collision the developer should resolve.
3708
- */
3709
- export const makeStoreTableKey = (
3710
- storeBinding: string | null,
3711
- tableName: string
3712
- ): string => (storeBinding ? `${storeBinding}:${tableName}` : tableName);
3713
-
3714
- const isBooleanLiteral = (node: AstNode | undefined): boolean =>
3715
- Boolean(
3716
- node &&
3717
- ((node.type === 'BooleanLiteral' &&
3718
- (node as unknown as { value?: unknown }).value === true) ||
3719
- (node.type === 'Literal' &&
3720
- (node as unknown as { value?: unknown }).value === true))
3721
- );
3722
-
3723
- /**
3724
- * Check if a node is a `CallExpression` to the identifier `name`.
3725
- *
3726
- * e.g. `isNamedCall(node, 'store')` matches `store({...})` but not
3727
- * `someObj.store()` or `storeAlt()`.
3728
- */
3729
- export const isNamedCall = (node: AstNode | undefined, name: string): boolean =>
3730
- !!node &&
3731
- node.type === 'CallExpression' &&
3732
- identifierName((node as unknown as { callee?: AstNode }).callee) === name;
3733
-
3734
- /**
3735
- * Narrow a member-expression node (`a.b` or `a['b']`) to its `object` /
3736
- * `property` pair, returning `null` for anything else.
3737
- */
3738
- export const getMemberExpression = (
3739
- node: AstNode | undefined
3740
- ): { readonly object?: AstNode; readonly property?: AstNode } | null => {
3741
- if (
3742
- !node ||
3743
- (node.type !== 'MemberExpression' && node.type !== 'StaticMemberExpression')
3744
- ) {
3745
- return null;
3746
- }
3747
-
3748
- return node as unknown as {
3749
- readonly object?: AstNode;
3750
- readonly property?: AstNode;
3751
- };
3752
- };
3753
-
3754
- /**
3755
- * Resolve a `<store>.tables.<name>` member expression to its store binding
3756
- * and table name.
3757
- *
3758
- * Returns `null` for anything that isn't a two-level member access ending in
3759
- * `.tables.<name>`. The store binding is the identifier of the object owning
3760
- * `.tables` — typically the local binding from `const db = store(...)`.
3761
- */
3762
- export const extractStoreTableFromMember = (
3763
- node: AstNode | undefined
3764
- ): {
3765
- readonly storeBinding: string | null;
3766
- readonly tableName: string;
3767
- } | null => {
3768
- const member = getMemberExpression(node);
3769
- const tableName = member ? getPropertyName(member.property) : null;
3770
- const tablesMember = member ? getMemberExpression(member.object) : null;
3771
- if (!tableName || !tablesMember) {
3772
- return null;
3773
- }
3774
-
3775
- if (getPropertyName(tablesMember.property) !== 'tables') {
3776
- return null;
3777
- }
3778
-
3779
- const storeBinding = identifierName(tablesMember.object) ?? null;
3780
- return { storeBinding, tableName };
3781
- };
3782
-
3783
- /**
3784
- * Collect `const foo = <store>.tables.<name>` bindings from a parsed file,
3785
- * keyed by the local binding name. Values are the composite table key
3786
- * (`${storeBinding}:${tableName}`) so callers can dedupe across stores that
3787
- * share a table name.
3788
- */
3789
- export const collectNamedStoreTableIds = (
3790
- ast: AstNode
3791
- ): ReadonlyMap<string, string> => {
3792
- const ids = new Map<string, string>();
3793
-
3794
- walk(ast, (node) => {
3795
- if (node.type !== 'VariableDeclarator') {
3796
- return;
3797
- }
3798
-
3799
- const { id, init } = node as unknown as {
3800
- readonly id?: AstNode;
3801
- readonly init?: AstNode;
3802
- };
3803
- const name = extractBindingName(id);
3804
- const table = extractStoreTableFromMember(init);
3805
- if (name && table) {
3806
- ids.set(name, makeStoreTableKey(table.storeBinding, table.tableName));
3807
- }
3808
- });
3809
-
3810
- return ids;
3811
- };
3812
-
3813
- /**
3814
- * Resolve an argument node to a composite store-table key
3815
- * (`${storeBinding}:${tableName}` or bare `tableName` when anonymous).
3816
- *
3817
- * Handles the two authoring patterns:
3818
- * - direct member access: `db.tables.notes`
3819
- * - identifier reference: `const notesTable = db.tables.notes; crud(notesTable, …)`
3820
- */
3821
- export const deriveStoreTableId = (
3822
- node: AstNode | undefined,
3823
- namedStoreTableIds: ReadonlyMap<string, string>
3824
- ): string | null => {
3825
- if (!node) {
3826
- return null;
3827
- }
3828
-
3829
- if (node.type === 'Identifier') {
3830
- const name = identifierName(node);
3831
- return name ? (namedStoreTableIds.get(name) ?? null) : null;
3832
- }
3833
-
3834
- const member = extractStoreTableFromMember(node);
3835
- return member
3836
- ? makeStoreTableKey(member.storeBinding, member.tableName)
3837
- : null;
3838
- };
3839
-
3840
- const extractStoreTableDefinitions = (
3841
- node: AstNode,
3842
- storeBinding: string | null
3843
- ): readonly StoreTableDefinition[] => {
3844
- if (!isNamedCall(node, 'store')) {
3845
- return [];
3846
- }
3847
-
3848
- const [tablesArg] = ((node as unknown as { arguments?: readonly AstNode[] })
3849
- .arguments ?? []) as readonly AstNode[];
3850
- if (!tablesArg || tablesArg.type !== 'ObjectExpression') {
3851
- return [];
3852
- }
3853
-
3854
- const properties = tablesArg['properties'] as readonly AstNode[] | undefined;
3855
- if (!properties) {
3856
- return [];
3857
- }
3858
-
3859
- return properties.flatMap((property) => {
3860
- if (property.type !== 'Property') {
3861
- return [];
3862
- }
3863
-
3864
- const name = getPropertyName(property.key);
3865
- const value = property.value as AstNode | undefined;
3866
- if (!name || value?.type !== 'ObjectExpression') {
3867
- return [];
3868
- }
3869
-
3870
- const versionedProp = findConfigProperty(value, 'versioned');
3871
- return [
3872
- {
3873
- key: makeStoreTableKey(storeBinding, name),
3874
- name,
3875
- start: property.start,
3876
- storeBinding,
3877
- versioned: isBooleanLiteral(
3878
- versionedProp?.value as AstNode | undefined
3879
- ),
3880
- },
3881
- ];
3882
- });
3883
- };
3884
-
3885
- export const findStoreTableDefinitions = (
3886
- ast: AstNode
3887
- ): readonly StoreTableDefinition[] => {
3888
- const definitions: StoreTableDefinition[] = [];
3889
- const seenStoreCalls = new WeakSet<AstNode>();
3890
-
3891
- // First pass: bound stores (walk VariableDeclarators so we know the binding).
3892
- walk(ast, (node) => {
3893
- if (node.type !== 'VariableDeclarator') {
3894
- return;
3895
- }
3896
-
3897
- const { id, init } = node as unknown as {
3898
- readonly id?: AstNode;
3899
- readonly init?: AstNode;
3900
- };
3901
- if (!init || !isNamedCall(init, 'store')) {
3902
- return;
3903
- }
3904
-
3905
- seenStoreCalls.add(init);
3906
- const storeBinding = extractBindingName(id);
3907
- definitions.push(...extractStoreTableDefinitions(init, storeBinding));
3908
- });
3909
-
3910
- // Second pass: anonymous `store({...})` calls not bound to a variable
3911
- // (e.g. an inline default export). Use the bare table name as the key.
3912
- walk(ast, (node) => {
3913
- if (!isNamedCall(node, 'store') || seenStoreCalls.has(node)) {
3914
- return;
3915
- }
3916
- definitions.push(...extractStoreTableDefinitions(node, null));
3917
- });
3918
-
3919
- return definitions;
3920
- };
3921
-
3922
- export const collectCrudTableIds = (ast: AstNode): ReadonlySet<string> => {
3923
- const ids = new Set<string>();
3924
- const namedStoreTableIds = collectNamedStoreTableIds(ast);
3925
-
3926
- walk(ast, (node) => {
3927
- if (!isNamedCall(node, 'crud')) {
3928
- return;
3929
- }
3930
-
3931
- const [tableArg] = ((node as unknown as { arguments?: readonly AstNode[] })
3932
- .arguments ?? []) as readonly AstNode[];
3933
- const tableId = deriveStoreTableId(tableArg, namedStoreTableIds);
3934
- if (tableId) {
3935
- ids.add(tableId);
3936
- }
3937
- });
3938
-
3939
- return ids;
3940
- };
3941
-
3942
- export const collectReconcileTableIds = (ast: AstNode): ReadonlySet<string> => {
3943
- const ids = new Set<string>();
3944
- const namedStoreTableIds = collectNamedStoreTableIds(ast);
3945
-
3946
- walk(ast, (node) => {
3947
- if (!isNamedCall(node, 'reconcile')) {
3948
- return;
3949
- }
3950
-
3951
- const [configArg] = ((
3952
- node as unknown as {
3953
- arguments?: readonly AstNode[];
3954
- }
3955
- ).arguments ?? []) as readonly AstNode[];
3956
- if (!configArg || configArg.type !== 'ObjectExpression') {
3957
- return;
3958
- }
3959
-
3960
- const tableProp = findConfigProperty(configArg, 'table');
3961
- const tableId = deriveStoreTableId(
3962
- tableProp?.value as AstNode | undefined,
3963
- namedStoreTableIds
3964
- );
3965
- if (tableId) {
3966
- ids.add(tableId);
3967
- }
3968
- });
3969
-
3970
- return ids;
3971
- };
3972
-
3973
- const STORE_SIGNAL_OPERATIONS = new Set(['created', 'removed', 'updated']);
3974
-
3975
- const extractStoreSignalIdFromMember = (
3976
- node: AstNode | undefined,
3977
- namedStoreTableIds: ReadonlyMap<string, string>
3978
- ): string | null => {
3979
- const member = getMemberExpression(node);
3980
- const operation = member ? getPropertyName(member.property) : null;
3981
- if (!operation || !STORE_SIGNAL_OPERATIONS.has(operation)) {
3982
- return null;
3983
- }
3984
-
3985
- const signalsMember = member ? getMemberExpression(member.object) : null;
3986
- if (!signalsMember || getPropertyName(signalsMember.property) !== 'signals') {
3987
- return null;
3988
- }
3989
-
3990
- const tableId = deriveStoreTableId(signalsMember.object, namedStoreTableIds);
3991
- return tableId ? `${tableId}.${operation}` : null;
3992
- };
3993
-
3994
- const collectNamedStoreSignalIds = (
3995
- ast: AstNode,
3996
- namedStoreTableIds: ReadonlyMap<string, string>
3997
- ): ReadonlyMap<string, string> => {
3998
- const ids = new Map<string, string>();
3999
-
4000
- walk(ast, (node) => {
4001
- if (node.type !== 'VariableDeclarator') {
4002
- return;
4003
- }
4004
-
4005
- const { id, init } = node as unknown as {
4006
- readonly id?: AstNode;
4007
- readonly init?: AstNode;
4008
- };
4009
- const name = extractBindingName(id);
4010
- const signalId = extractStoreSignalIdFromMember(init, namedStoreTableIds);
4011
- if (name && signalId) {
4012
- ids.set(name, signalId);
4013
- }
4014
- });
4015
-
4016
- return ids;
4017
- };
4018
-
4019
- const getOnElements = (config: AstNode): readonly AstNode[] => {
4020
- const onProp = findConfigProperty(config, 'on');
4021
- if (!onProp) {
4022
- return [];
4023
- }
4024
-
4025
- const arrayNode = onProp.value;
4026
- if (!arrayNode || (arrayNode as AstNode).type !== 'ArrayExpression') {
4027
- return [];
4028
- }
4029
-
4030
- const elements = (arrayNode as AstNode)['elements'] as
4031
- | readonly AstNode[]
4032
- | undefined;
4033
- return elements ?? [];
4034
- };
4035
-
4036
- const resolveNamedOnSignalId = (
4037
- element: AstNode,
4038
- sourceCode: string,
4039
- namedStoreSignalIds: ReadonlyMap<string, string>
4040
- ): string | null => {
4041
- if (element.type !== 'Identifier') {
4042
- return null;
4043
- }
4044
-
4045
- const name = identifierName(element);
4046
- return name
4047
- ? (namedStoreSignalIds.get(name) ?? deriveConstString(name, sourceCode))
4048
- : null;
4049
- };
4050
-
4051
- const resolveInlineOnSignalId = (element: AstNode): string | null => {
4052
- const definition = extractTrailDefinition(element);
4053
- return definition?.kind === 'signal' ? definition.id : null;
4054
- };
4055
-
4056
- const resolveOnElementSignalId = (
4057
- element: AstNode,
4058
- sourceCode: string,
4059
- namedStoreSignalIds: ReadonlyMap<string, string>,
4060
- namedStoreTableIds: ReadonlyMap<string, string>
4061
- ): string | null => {
4062
- if (isStringLiteral(element)) {
4063
- return getStringValue(element);
4064
- }
4065
-
4066
- return (
4067
- extractStoreSignalIdFromMember(element, namedStoreTableIds) ??
4068
- resolveNamedOnSignalId(element, sourceCode, namedStoreSignalIds) ??
4069
- resolveInlineOnSignalId(element)
4070
- );
4071
- };
4072
-
4073
- const addOnTargetSignalIds = (
4074
- config: AstNode,
4075
- ids: Set<string>,
4076
- sourceCode: string,
4077
- namedStoreSignalIds: ReadonlyMap<string, string>,
4078
- namedStoreTableIds: ReadonlyMap<string, string>
4079
- ): void => {
4080
- for (const element of getOnElements(config)) {
4081
- const signalId = resolveOnElementSignalId(
4082
- element,
4083
- sourceCode,
4084
- namedStoreSignalIds,
4085
- namedStoreTableIds
4086
- );
4087
- if (signalId) {
4088
- ids.add(signalId);
4089
- }
4090
- }
4091
- };
4092
-
4093
- export const collectOnTargetSignalIds = (
4094
- ast: AstNode,
4095
- sourceCode: string
4096
- ): ReadonlySet<string> => {
4097
- const ids = new Set<string>();
4098
- const namedStoreTableIds = collectNamedStoreTableIds(ast);
4099
- const namedStoreSignalIds = collectNamedStoreSignalIds(
4100
- ast,
4101
- namedStoreTableIds
4102
- );
4103
-
4104
- for (const definition of findTrailDefinitions(ast)) {
4105
- if (definition.kind === 'trail') {
4106
- addOnTargetSignalIds(
4107
- definition.config,
4108
- ids,
4109
- sourceCode,
4110
- namedStoreSignalIds,
4111
- namedStoreTableIds
4112
- );
4113
- }
4114
- }
4115
-
4116
- return ids;
4117
- };
4118
-
4119
- // ---------------------------------------------------------------------------
4120
- // Misc helpers
4121
- // ---------------------------------------------------------------------------
4122
-
4123
- /** Check if a node is a call to `.blaze()` on some object. */
4124
- export const isBlazeCall = (node: AstNode): boolean => {
4125
- if (node.type !== 'CallExpression') {
4126
- return false;
4127
- }
4128
- const callee = node['callee'] as AstNode | undefined;
4129
- if (!callee) {
4130
- return false;
4131
- }
4132
- if (
4133
- callee.type !== 'StaticMemberExpression' &&
4134
- callee.type !== 'MemberExpression'
4135
- ) {
4136
- return false;
4137
- }
4138
- const prop = (callee as unknown as { property?: AstNode }).property;
4139
- return (
4140
- prop?.type === 'Identifier' &&
4141
- (prop as unknown as { name: string }).name === 'blaze'
4142
- );
4143
- };