@ontrails/warden 1.0.0-beta.3 → 1.0.0-beta.32

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