@ontrails/warden 1.0.0-beta.14 → 1.0.0-beta.16

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 (351) hide show
  1. package/CHANGELOG.md +149 -0
  2. package/README.md +77 -34
  3. package/bin/warden.ts +22 -0
  4. package/package.json +26 -6
  5. package/src/ast.ts +28 -0
  6. package/src/cli.ts +1169 -116
  7. package/src/command.ts +927 -0
  8. package/src/config.ts +184 -0
  9. package/src/drift.ts +76 -34
  10. package/src/formatters.ts +54 -7
  11. package/src/guide.ts +234 -0
  12. package/src/index.ts +151 -38
  13. package/src/project-context.ts +163 -0
  14. package/src/resolve.ts +530 -0
  15. package/src/rules/activation-orphan.ts +97 -0
  16. package/src/rules/ast.ts +2884 -124
  17. package/src/rules/circular-refs.ts +154 -0
  18. package/src/rules/{context-no-trailhead-types.ts → context-no-surface-types.ts} +72 -12
  19. package/src/rules/contour-exists.ts +251 -0
  20. package/src/rules/contour-ids.ts +15 -0
  21. package/src/rules/cross-declarations.ts +277 -69
  22. package/src/rules/dead-internal-trail.ts +141 -0
  23. package/src/rules/draft-file-marking.ts +51 -3
  24. package/src/rules/draft-visible-debt.ts +40 -15
  25. package/src/rules/error-mapping-completeness.ts +288 -0
  26. package/src/rules/example-valid.ts +401 -0
  27. package/src/rules/fires-declarations.ts +758 -0
  28. package/src/rules/implementation-returns-result.ts +1042 -122
  29. package/src/rules/incomplete-accessor-for-standard-op.ts +272 -0
  30. package/src/rules/incomplete-crud.ts +580 -0
  31. package/src/rules/index.ts +156 -19
  32. package/src/rules/intent-propagation.ts +127 -0
  33. package/src/rules/layer-field-name-drift.ts +96 -0
  34. package/src/rules/metadata.ts +508 -0
  35. package/src/rules/missing-reconcile.ts +98 -0
  36. package/src/rules/missing-visibility.ts +110 -0
  37. package/src/rules/no-dev-permit-in-source.ts +99 -0
  38. package/src/rules/no-direct-implementation-call.ts +1 -1
  39. package/src/rules/no-legacy-layer-imports.ts +193 -0
  40. package/src/rules/no-native-error-result.ts +111 -0
  41. package/src/rules/no-sync-result-assumption.ts +1131 -96
  42. package/src/rules/no-throw-in-detour-recover.ts +225 -0
  43. package/src/rules/no-throw-in-implementation.ts +6 -4
  44. package/src/rules/on-references-exist.ts +194 -0
  45. package/src/rules/orphaned-signal.ts +150 -0
  46. package/src/rules/owner-projection-parity.ts +146 -0
  47. package/src/rules/permit-governance.ts +25 -0
  48. package/src/rules/public-internal-deep-imports.ts +517 -0
  49. package/src/rules/public-output-schema.ts +29 -0
  50. package/src/rules/public-union-output-discriminants.ts +150 -0
  51. package/src/rules/read-intent-fires.ts +187 -0
  52. package/src/rules/reference-exists.ts +98 -0
  53. package/src/rules/registry-names.ts +113 -0
  54. package/src/rules/resolved-import-boundary.ts +146 -0
  55. package/src/rules/{provision-declarations.ts → resource-declarations.ts} +208 -138
  56. package/src/rules/{provision-exists.ts → resource-exists.ts} +46 -51
  57. package/src/rules/resource-id-grammar.ts +65 -0
  58. package/src/rules/scan.ts +0 -26
  59. package/src/rules/scheduled-destroy-intent.ts +44 -0
  60. package/src/rules/signal-graph-coaching.ts +191 -0
  61. package/src/rules/specs.ts +5 -1
  62. package/src/rules/static-resource-accessor-preference.ts +657 -0
  63. package/src/rules/types.ts +187 -7
  64. package/src/rules/unmaterialized-activation-source.ts +84 -0
  65. package/src/rules/unreachable-detour-shadowing.ts +344 -0
  66. package/src/rules/valid-describe-refs.ts +160 -32
  67. package/src/rules/valid-detour-contract.ts +78 -0
  68. package/src/rules/warden-export-symmetry.ts +533 -0
  69. package/src/rules/warden-rules-use-ast.ts +996 -0
  70. package/src/rules/webhook-route-collision.ts +243 -0
  71. package/src/trails/activation-orphan.trail.ts +81 -0
  72. package/src/trails/circular-refs.trail.ts +29 -0
  73. package/src/trails/{context-no-trailhead-types.trail.ts → context-no-surface-types.trail.ts} +4 -4
  74. package/src/trails/contour-exists.trail.ts +21 -0
  75. package/src/trails/dead-internal-trail.trail.ts +26 -0
  76. package/src/trails/draft-file-marking.trail.ts +16 -0
  77. package/src/trails/draft-visible-debt.trail.ts +16 -0
  78. package/src/trails/error-mapping-completeness.trail.ts +29 -0
  79. package/src/trails/example-valid.trail.ts +25 -0
  80. package/src/trails/fires-declarations.trail.ts +23 -0
  81. package/src/trails/incomplete-accessor-for-standard-op.trail.ts +76 -0
  82. package/src/trails/incomplete-crud.trail.ts +39 -0
  83. package/src/trails/index.ts +56 -8
  84. package/src/trails/intent-propagation.trail.ts +30 -0
  85. package/src/trails/layer-field-name-drift.trail.ts +39 -0
  86. package/src/trails/missing-reconcile.trail.ts +33 -0
  87. package/src/trails/missing-visibility.trail.ts +22 -0
  88. package/src/trails/no-dev-permit-in-source.trail.ts +16 -0
  89. package/src/trails/no-legacy-layer-imports.trail.ts +35 -0
  90. package/src/trails/no-native-error-result.trail.ts +18 -0
  91. package/src/trails/no-throw-in-detour-recover.trail.ts +24 -0
  92. package/src/trails/on-references-exist.trail.ts +21 -0
  93. package/src/trails/orphaned-signal.trail.ts +36 -0
  94. package/src/trails/owner-projection-parity.trail.ts +26 -0
  95. package/src/trails/permit-governance.trail.ts +51 -0
  96. package/src/trails/public-internal-deep-imports.trail.ts +94 -0
  97. package/src/trails/public-output-schema.trail.ts +55 -0
  98. package/src/trails/public-union-output-discriminants.trail.ts +33 -0
  99. package/src/trails/read-intent-fires.trail.ts +20 -0
  100. package/src/trails/reference-exists.trail.ts +25 -0
  101. package/src/trails/resolved-import-boundary.trail.ts +109 -0
  102. package/src/trails/{provision-declarations.trail.ts → resource-declarations.trail.ts} +6 -6
  103. package/src/trails/{provision-exists.trail.ts → resource-exists.trail.ts} +7 -7
  104. package/src/trails/resource-id-grammar.trail.ts +39 -0
  105. package/src/trails/run.ts +133 -24
  106. package/src/trails/scheduled-destroy-intent.trail.ts +56 -0
  107. package/src/trails/schema.ts +122 -4
  108. package/src/trails/signal-graph-coaching.trail.ts +74 -0
  109. package/src/trails/static-resource-accessor-preference.trail.ts +25 -0
  110. package/src/trails/unmaterialized-activation-source.trail.ts +69 -0
  111. package/src/trails/unreachable-detour-shadowing.trail.ts +45 -0
  112. package/src/trails/valid-detour-contract.trail.ts +71 -0
  113. package/src/trails/warden-export-symmetry.trail.ts +16 -0
  114. package/src/trails/warden-rules-use-ast.trail.ts +45 -0
  115. package/src/trails/webhook-route-collision.trail.ts +50 -0
  116. package/src/trails/wrap-rule.ts +133 -14
  117. package/src/workspaces.ts +238 -0
  118. package/.turbo/turbo-build.log +0 -1
  119. package/.turbo/turbo-lint.log +0 -3
  120. package/.turbo/turbo-typecheck.log +0 -1
  121. package/dist/cli.d.ts +0 -46
  122. package/dist/cli.d.ts.map +0 -1
  123. package/dist/cli.js +0 -247
  124. package/dist/cli.js.map +0 -1
  125. package/dist/draft.d.ts +0 -5
  126. package/dist/draft.d.ts.map +0 -1
  127. package/dist/draft.js +0 -16
  128. package/dist/draft.js.map +0 -1
  129. package/dist/drift.d.ts +0 -29
  130. package/dist/drift.d.ts.map +0 -1
  131. package/dist/drift.js +0 -61
  132. package/dist/drift.js.map +0 -1
  133. package/dist/formatters.d.ts +0 -30
  134. package/dist/formatters.d.ts.map +0 -1
  135. package/dist/formatters.js +0 -98
  136. package/dist/formatters.js.map +0 -1
  137. package/dist/index.d.ts +0 -37
  138. package/dist/index.d.ts.map +0 -1
  139. package/dist/index.js +0 -38
  140. package/dist/index.js.map +0 -1
  141. package/dist/rules/ast.d.ts +0 -82
  142. package/dist/rules/ast.d.ts.map +0 -1
  143. package/dist/rules/ast.js +0 -363
  144. package/dist/rules/ast.js.map +0 -1
  145. package/dist/rules/context-no-surface-types.d.ts +0 -12
  146. package/dist/rules/context-no-surface-types.d.ts.map +0 -1
  147. package/dist/rules/context-no-surface-types.js +0 -96
  148. package/dist/rules/context-no-surface-types.js.map +0 -1
  149. package/dist/rules/context-no-trailhead-types.d.ts +0 -12
  150. package/dist/rules/context-no-trailhead-types.d.ts.map +0 -1
  151. package/dist/rules/context-no-trailhead-types.js +0 -96
  152. package/dist/rules/context-no-trailhead-types.js.map +0 -1
  153. package/dist/rules/cross-declarations.d.ts +0 -13
  154. package/dist/rules/cross-declarations.d.ts.map +0 -1
  155. package/dist/rules/cross-declarations.js +0 -264
  156. package/dist/rules/cross-declarations.js.map +0 -1
  157. package/dist/rules/draft-file-marking.d.ts +0 -6
  158. package/dist/rules/draft-file-marking.d.ts.map +0 -1
  159. package/dist/rules/draft-file-marking.js +0 -65
  160. package/dist/rules/draft-file-marking.js.map +0 -1
  161. package/dist/rules/draft-visible-debt.d.ts +0 -12
  162. package/dist/rules/draft-visible-debt.d.ts.map +0 -1
  163. package/dist/rules/draft-visible-debt.js +0 -45
  164. package/dist/rules/draft-visible-debt.js.map +0 -1
  165. package/dist/rules/follow-declarations.d.ts +0 -13
  166. package/dist/rules/follow-declarations.d.ts.map +0 -1
  167. package/dist/rules/follow-declarations.js +0 -264
  168. package/dist/rules/follow-declarations.js.map +0 -1
  169. package/dist/rules/implementation-returns-result.d.ts +0 -13
  170. package/dist/rules/implementation-returns-result.d.ts.map +0 -1
  171. package/dist/rules/implementation-returns-result.js +0 -277
  172. package/dist/rules/implementation-returns-result.js.map +0 -1
  173. package/dist/rules/index.d.ts +0 -20
  174. package/dist/rules/index.d.ts.map +0 -1
  175. package/dist/rules/index.js +0 -49
  176. package/dist/rules/index.js.map +0 -1
  177. package/dist/rules/no-direct-impl-in-route.d.ts +0 -12
  178. package/dist/rules/no-direct-impl-in-route.d.ts.map +0 -1
  179. package/dist/rules/no-direct-impl-in-route.js +0 -47
  180. package/dist/rules/no-direct-impl-in-route.js.map +0 -1
  181. package/dist/rules/no-direct-implementation-call.d.ts +0 -12
  182. package/dist/rules/no-direct-implementation-call.d.ts.map +0 -1
  183. package/dist/rules/no-direct-implementation-call.js +0 -39
  184. package/dist/rules/no-direct-implementation-call.js.map +0 -1
  185. package/dist/rules/no-sync-result-assumption.d.ts +0 -6
  186. package/dist/rules/no-sync-result-assumption.d.ts.map +0 -1
  187. package/dist/rules/no-sync-result-assumption.js +0 -98
  188. package/dist/rules/no-sync-result-assumption.js.map +0 -1
  189. package/dist/rules/no-throw-in-detour-target.d.ts +0 -12
  190. package/dist/rules/no-throw-in-detour-target.d.ts.map +0 -1
  191. package/dist/rules/no-throw-in-detour-target.js +0 -87
  192. package/dist/rules/no-throw-in-detour-target.js.map +0 -1
  193. package/dist/rules/no-throw-in-implementation.d.ts +0 -9
  194. package/dist/rules/no-throw-in-implementation.d.ts.map +0 -1
  195. package/dist/rules/no-throw-in-implementation.js +0 -34
  196. package/dist/rules/no-throw-in-implementation.js.map +0 -1
  197. package/dist/rules/prefer-schema-inference.d.ts +0 -7
  198. package/dist/rules/prefer-schema-inference.d.ts.map +0 -1
  199. package/dist/rules/prefer-schema-inference.js +0 -86
  200. package/dist/rules/prefer-schema-inference.js.map +0 -1
  201. package/dist/rules/provision-declarations.d.ts +0 -14
  202. package/dist/rules/provision-declarations.d.ts.map +0 -1
  203. package/dist/rules/provision-declarations.js +0 -344
  204. package/dist/rules/provision-declarations.js.map +0 -1
  205. package/dist/rules/provision-exists.d.ts +0 -6
  206. package/dist/rules/provision-exists.d.ts.map +0 -1
  207. package/dist/rules/provision-exists.js +0 -90
  208. package/dist/rules/provision-exists.js.map +0 -1
  209. package/dist/rules/scan.d.ts +0 -8
  210. package/dist/rules/scan.d.ts.map +0 -1
  211. package/dist/rules/scan.js +0 -32
  212. package/dist/rules/scan.js.map +0 -1
  213. package/dist/rules/service-declarations.d.ts +0 -16
  214. package/dist/rules/service-declarations.d.ts.map +0 -1
  215. package/dist/rules/service-declarations.js +0 -346
  216. package/dist/rules/service-declarations.js.map +0 -1
  217. package/dist/rules/service-exists.d.ts +0 -8
  218. package/dist/rules/service-exists.d.ts.map +0 -1
  219. package/dist/rules/service-exists.js +0 -91
  220. package/dist/rules/service-exists.js.map +0 -1
  221. package/dist/rules/specs.d.ts +0 -29
  222. package/dist/rules/specs.d.ts.map +0 -1
  223. package/dist/rules/specs.js +0 -192
  224. package/dist/rules/specs.js.map +0 -1
  225. package/dist/rules/structure.d.ts +0 -13
  226. package/dist/rules/structure.d.ts.map +0 -1
  227. package/dist/rules/structure.js +0 -142
  228. package/dist/rules/structure.js.map +0 -1
  229. package/dist/rules/types.d.ts +0 -54
  230. package/dist/rules/types.d.ts.map +0 -1
  231. package/dist/rules/types.js +0 -2
  232. package/dist/rules/types.js.map +0 -1
  233. package/dist/rules/valid-describe-refs.d.ts +0 -7
  234. package/dist/rules/valid-describe-refs.d.ts.map +0 -1
  235. package/dist/rules/valid-describe-refs.js +0 -51
  236. package/dist/rules/valid-describe-refs.js.map +0 -1
  237. package/dist/rules/valid-detour-refs.d.ts +0 -6
  238. package/dist/rules/valid-detour-refs.d.ts.map +0 -1
  239. package/dist/rules/valid-detour-refs.js +0 -117
  240. package/dist/rules/valid-detour-refs.js.map +0 -1
  241. package/dist/trails/context-no-surface-types.trail.d.ts +0 -13
  242. package/dist/trails/context-no-surface-types.trail.d.ts.map +0 -1
  243. package/dist/trails/context-no-surface-types.trail.js +0 -21
  244. package/dist/trails/context-no-surface-types.trail.js.map +0 -1
  245. package/dist/trails/context-no-trailhead-types.trail.d.ts +0 -13
  246. package/dist/trails/context-no-trailhead-types.trail.d.ts.map +0 -1
  247. package/dist/trails/context-no-trailhead-types.trail.js +0 -21
  248. package/dist/trails/context-no-trailhead-types.trail.js.map +0 -1
  249. package/dist/trails/cross-declarations.trail.d.ts +0 -13
  250. package/dist/trails/cross-declarations.trail.d.ts.map +0 -1
  251. package/dist/trails/cross-declarations.trail.js +0 -22
  252. package/dist/trails/cross-declarations.trail.js.map +0 -1
  253. package/dist/trails/follow-declarations.trail.d.ts +0 -13
  254. package/dist/trails/follow-declarations.trail.d.ts.map +0 -1
  255. package/dist/trails/follow-declarations.trail.js +0 -22
  256. package/dist/trails/follow-declarations.trail.js.map +0 -1
  257. package/dist/trails/implementation-returns-result.trail.d.ts +0 -13
  258. package/dist/trails/implementation-returns-result.trail.d.ts.map +0 -1
  259. package/dist/trails/implementation-returns-result.trail.js +0 -20
  260. package/dist/trails/implementation-returns-result.trail.js.map +0 -1
  261. package/dist/trails/index.d.ts +0 -16
  262. package/dist/trails/index.d.ts.map +0 -1
  263. package/dist/trails/index.js +0 -15
  264. package/dist/trails/index.js.map +0 -1
  265. package/dist/trails/no-direct-impl-in-route.trail.d.ts +0 -13
  266. package/dist/trails/no-direct-impl-in-route.trail.d.ts.map +0 -1
  267. package/dist/trails/no-direct-impl-in-route.trail.js +0 -22
  268. package/dist/trails/no-direct-impl-in-route.trail.js.map +0 -1
  269. package/dist/trails/no-direct-implementation-call.trail.d.ts +0 -13
  270. package/dist/trails/no-direct-implementation-call.trail.d.ts.map +0 -1
  271. package/dist/trails/no-direct-implementation-call.trail.js +0 -16
  272. package/dist/trails/no-direct-implementation-call.trail.js.map +0 -1
  273. package/dist/trails/no-sync-result-assumption.trail.d.ts +0 -13
  274. package/dist/trails/no-sync-result-assumption.trail.d.ts.map +0 -1
  275. package/dist/trails/no-sync-result-assumption.trail.js +0 -19
  276. package/dist/trails/no-sync-result-assumption.trail.js.map +0 -1
  277. package/dist/trails/no-throw-in-detour-target.trail.d.ts +0 -15
  278. package/dist/trails/no-throw-in-detour-target.trail.d.ts.map +0 -1
  279. package/dist/trails/no-throw-in-detour-target.trail.js +0 -20
  280. package/dist/trails/no-throw-in-detour-target.trail.js.map +0 -1
  281. package/dist/trails/no-throw-in-implementation.trail.d.ts +0 -13
  282. package/dist/trails/no-throw-in-implementation.trail.d.ts.map +0 -1
  283. package/dist/trails/no-throw-in-implementation.trail.js +0 -20
  284. package/dist/trails/no-throw-in-implementation.trail.js.map +0 -1
  285. package/dist/trails/prefer-schema-inference.trail.d.ts +0 -13
  286. package/dist/trails/prefer-schema-inference.trail.d.ts.map +0 -1
  287. package/dist/trails/prefer-schema-inference.trail.js +0 -21
  288. package/dist/trails/prefer-schema-inference.trail.js.map +0 -1
  289. package/dist/trails/provision-declarations.trail.d.ts +0 -13
  290. package/dist/trails/provision-declarations.trail.d.ts.map +0 -1
  291. package/dist/trails/provision-declarations.trail.js +0 -25
  292. package/dist/trails/provision-declarations.trail.js.map +0 -1
  293. package/dist/trails/provision-exists.trail.d.ts +0 -15
  294. package/dist/trails/provision-exists.trail.d.ts.map +0 -1
  295. package/dist/trails/provision-exists.trail.js +0 -27
  296. package/dist/trails/provision-exists.trail.js.map +0 -1
  297. package/dist/trails/run.d.ts +0 -17
  298. package/dist/trails/run.d.ts.map +0 -1
  299. package/dist/trails/run.js +0 -39
  300. package/dist/trails/run.js.map +0 -1
  301. package/dist/trails/schema.d.ts +0 -53
  302. package/dist/trails/schema.d.ts.map +0 -1
  303. package/dist/trails/schema.js +0 -42
  304. package/dist/trails/schema.js.map +0 -1
  305. package/dist/trails/service-declarations.trail.d.ts +0 -26
  306. package/dist/trails/service-declarations.trail.d.ts.map +0 -1
  307. package/dist/trails/service-declarations.trail.js +0 -27
  308. package/dist/trails/service-declarations.trail.js.map +0 -1
  309. package/dist/trails/service-exists.trail.d.ts +0 -32
  310. package/dist/trails/service-exists.trail.d.ts.map +0 -1
  311. package/dist/trails/service-exists.trail.js +0 -29
  312. package/dist/trails/service-exists.trail.js.map +0 -1
  313. package/dist/trails/topo.d.ts +0 -3
  314. package/dist/trails/topo.d.ts.map +0 -1
  315. package/dist/trails/topo.js +0 -5
  316. package/dist/trails/topo.js.map +0 -1
  317. package/dist/trails/valid-describe-refs.trail.d.ts +0 -15
  318. package/dist/trails/valid-describe-refs.trail.d.ts.map +0 -1
  319. package/dist/trails/valid-describe-refs.trail.js +0 -18
  320. package/dist/trails/valid-describe-refs.trail.js.map +0 -1
  321. package/dist/trails/valid-detour-refs.trail.d.ts +0 -15
  322. package/dist/trails/valid-detour-refs.trail.d.ts.map +0 -1
  323. package/dist/trails/valid-detour-refs.trail.js +0 -24
  324. package/dist/trails/valid-detour-refs.trail.js.map +0 -1
  325. package/dist/trails/wrap-rule.d.ts +0 -29
  326. package/dist/trails/wrap-rule.d.ts.map +0 -1
  327. package/dist/trails/wrap-rule.js +0 -47
  328. package/dist/trails/wrap-rule.js.map +0 -1
  329. package/src/__tests__/cli.test.ts +0 -311
  330. package/src/__tests__/cross-declarations.test.ts +0 -303
  331. package/src/__tests__/drift.test.ts +0 -144
  332. package/src/__tests__/formatters.test.ts +0 -157
  333. package/src/__tests__/implementation-returns-result.test.ts +0 -129
  334. package/src/__tests__/no-direct-implementation-call.test.ts +0 -83
  335. package/src/__tests__/no-sync-result-assumption.test.ts +0 -85
  336. package/src/__tests__/no-throw-in-detour-target.test.ts +0 -78
  337. package/src/__tests__/prefer-schema-inference.test.ts +0 -84
  338. package/src/__tests__/provision-declarations.test.ts +0 -318
  339. package/src/__tests__/provision-exists.test.ts +0 -122
  340. package/src/__tests__/rules.test.ts +0 -227
  341. package/src/__tests__/trails.test.ts +0 -19
  342. package/src/__tests__/valid-describe-refs.test.ts +0 -60
  343. package/src/__tests__/wrap-rule.test.ts +0 -41
  344. package/src/rules/no-direct-impl-in-route.ts +0 -81
  345. package/src/rules/no-throw-in-detour-target.ts +0 -150
  346. package/src/rules/valid-detour-refs.ts +0 -189
  347. package/src/trails/no-direct-impl-in-route.trail.ts +0 -22
  348. package/src/trails/no-throw-in-detour-target.trail.ts +0 -20
  349. package/src/trails/valid-detour-refs.trail.ts +0 -24
  350. package/tsconfig.json +0 -9
  351. package/tsconfig.tsbuildinfo +0 -1
package/src/rules/ast.ts CHANGED
@@ -5,6 +5,11 @@
5
5
  * walker and helpers for finding trail implementation bodies.
6
6
  */
7
7
 
8
+ import { resolve } from 'node:path';
9
+ import { fileURLToPath } from 'node:url';
10
+
11
+ import { DRAFT_ID_PREFIX, intentValues } from '@ontrails/core';
12
+ import type { Intent } from '@ontrails/core';
8
13
  import { parseSync } from 'oxc-parser';
9
14
 
10
15
  // ---------------------------------------------------------------------------
@@ -16,11 +21,19 @@ export interface AstNode {
16
21
  readonly start: number;
17
22
  readonly end: number;
18
23
  readonly key?: { readonly name?: string };
19
- readonly value?: AstNode;
24
+ readonly value?: unknown;
20
25
  readonly body?: AstNode | readonly AstNode[];
21
26
  readonly [key: string]: unknown;
22
27
  }
23
28
 
29
+ interface StringLiteralNode extends AstNode {
30
+ readonly type: 'Literal' | 'StringLiteral';
31
+ readonly value?: unknown;
32
+ }
33
+
34
+ const isAstNode = (value: unknown): value is AstNode =>
35
+ Boolean(value && typeof value === 'object' && (value as AstNode).type);
36
+
24
37
  // ---------------------------------------------------------------------------
25
38
  // Parser
26
39
  // ---------------------------------------------------------------------------
@@ -92,7 +105,7 @@ const walkScopeInner: WalkFn = (node, visit) => {
92
105
  /**
93
106
  * Walk an AST node tree without descending into nested function scopes.
94
107
  * The root node is always traversed; only inner function boundaries are skipped.
95
- * Useful for provision-access analysis where inner functions may shadow
108
+ * Useful for resource-access analysis where inner functions may shadow
96
109
  * the trail context parameter name.
97
110
  */
98
111
  export const walkScope: WalkFn = (node, visit) => {
@@ -130,7 +143,9 @@ export const identifierName = (node: AstNode | undefined): string | null => {
130
143
  };
131
144
 
132
145
  /** Check if a node is a string literal. */
133
- export const isStringLiteral = (node: AstNode | undefined): node is AstNode => {
146
+ export const isStringLiteral = (
147
+ node: AstNode | undefined
148
+ ): node is StringLiteralNode => {
134
149
  if (!node) {
135
150
  return false;
136
151
  }
@@ -149,12 +164,76 @@ export const getStringValue = (node: AstNode): string | null => {
149
164
  return typeof val === 'string' ? val : null;
150
165
  };
151
166
 
167
+ /**
168
+ * Best-effort resolution of `const NAME = 'value'` declarations via regex.
169
+ *
170
+ * Returns the string value if a simple `const <name> = '...'` or `"..."` is
171
+ * found in the source. Returns null for anything more complex. Shared between
172
+ * warden rules that need to resolve identifier references to signal / trail
173
+ * IDs at lint time.
174
+ */
175
+ export const deriveConstString = (
176
+ name: string,
177
+ sourceCode: string
178
+ ): string | null => {
179
+ const pattern = new RegExp(
180
+ `const\\s+${name}\\s*=\\s*(?:'([^']*)'|"([^"]*)")`
181
+ );
182
+ const match = pattern.exec(sourceCode);
183
+ if (!match) {
184
+ return null;
185
+ }
186
+ return match[1] ?? match[2] ?? null;
187
+ };
188
+
152
189
  /** Extract a string literal value, or null when the node is not a string. */
153
190
  export const extractStringLiteral = (
154
191
  node: AstNode | undefined
155
192
  ): string | null =>
156
193
  node && isStringLiteral(node) ? getStringValue(node) : null;
157
194
 
195
+ /**
196
+ * Extract the cooked value from a `TemplateLiteral` with no interpolations
197
+ * (e.g. `` `entity.fallback` ``). Template literals with `${...}` expressions
198
+ * cannot be resolved at lint time and return null.
199
+ *
200
+ * Shared helper used by rules that accept both string literals and simple
201
+ * backtick-literal IDs (e.g. `valid-describe-refs`).
202
+ */
203
+ const getSingleQuasi = (node: AstNode): AstNode | null => {
204
+ const expressions =
205
+ (node['expressions'] as readonly AstNode[] | undefined) ?? [];
206
+ if (expressions.length > 0) {
207
+ return null;
208
+ }
209
+ const quasis = (node['quasis'] as readonly AstNode[] | undefined) ?? [];
210
+ return quasis.length === 1 ? (quasis[0] ?? null) : null;
211
+ };
212
+
213
+ export const extractPlainTemplateLiteral = (
214
+ node: AstNode | undefined
215
+ ): string | null => {
216
+ if (!node || node.type !== 'TemplateLiteral') {
217
+ return null;
218
+ }
219
+ const quasi = getSingleQuasi(node);
220
+ if (!quasi) {
221
+ return null;
222
+ }
223
+ const cooked = (quasi as unknown as { value?: { cooked?: unknown } }).value
224
+ ?.cooked;
225
+ return typeof cooked === 'string' ? cooked : null;
226
+ };
227
+
228
+ /**
229
+ * Extract a string value from either a string literal or a plain template
230
+ * literal (no `${...}` expressions). Returns null for anything else.
231
+ */
232
+ export const extractStringOrTemplateLiteral = (
233
+ node: AstNode | undefined
234
+ ): string | null =>
235
+ extractStringLiteral(node) ?? extractPlainTemplateLiteral(node);
236
+
158
237
  export interface StringLiteralMatch {
159
238
  readonly end: number;
160
239
  readonly node: AstNode;
@@ -162,6 +241,141 @@ export interface StringLiteralMatch {
162
241
  readonly value: string;
163
242
  }
164
243
 
244
+ /**
245
+ * Names of framework constants whose value is a draft-marker prefix literal.
246
+ *
247
+ * String literals that initialize a `const` declaration with one of these
248
+ * names are treated as the framework's own draft-marker declarations, not as
249
+ * draft-id usage. This list is intentionally small and explicit — adding a
250
+ * new framework draft-prefix constant requires updating this set.
251
+ */
252
+ export const FRAMEWORK_DRAFT_PREFIX_CONSTANT_NAMES: ReadonlySet<string> =
253
+ new Set(['DRAFT_ID_PREFIX', 'DRAFT_FILE_PREFIX']);
254
+
255
+ /**
256
+ * Exact string literal value allowed for framework draft-prefix constant
257
+ * declarations. Tightens the exemption so a future framework file cannot
258
+ * redeclare `DRAFT_ID_PREFIX = '_draft.something-else'` and accidentally
259
+ * suppress its own draft-id diagnostic.
260
+ */
261
+ const FRAMEWORK_DRAFT_PREFIX_LITERAL = DRAFT_ID_PREFIX;
262
+
263
+ /**
264
+ * Absolute paths of the two framework files allowed to declare the
265
+ * draft-prefix constants. Anchored against the rule module's own URL so the
266
+ * exemption is scoped to this package's real on-disk location — a consumer
267
+ * repository that happens to declare `const DRAFT_ID_PREFIX = '_draft.leak'`
268
+ * anywhere else cannot hide a genuine leak by matching the identifier name.
269
+ *
270
+ * The two framework files are:
271
+ * - `packages/core/src/draft.ts` (defines `DRAFT_ID_PREFIX`)
272
+ * - `packages/warden/src/draft.ts` (defines `DRAFT_FILE_PREFIX`)
273
+ */
274
+ const FRAMEWORK_DRAFT_CONSTANT_FILES: ReadonlySet<string> = new Set([
275
+ resolve(
276
+ fileURLToPath(new URL('../../../core/src/draft.ts', import.meta.url))
277
+ ),
278
+ resolve(fileURLToPath(new URL('../draft.ts', import.meta.url))),
279
+ ]);
280
+
281
+ /**
282
+ * Collect the source offsets of string literals that initialize a framework
283
+ * draft-prefix constant declaration (e.g. `export const DRAFT_ID_PREFIX =
284
+ * '_draft.'`). Used by draft-awareness rules to skip their own marker
285
+ * constants.
286
+ *
287
+ * Exemption is gated on all three of:
288
+ * 1. The file's absolute path matches one of the two framework files that
289
+ * actually define these constants.
290
+ * 2. The declaration name is `DRAFT_ID_PREFIX` or `DRAFT_FILE_PREFIX`.
291
+ * 3. The string literal value is exactly `'_draft.'`.
292
+ *
293
+ * A consumer file that reuses one of these identifier names cannot hide a
294
+ * `_draft.*` leak — the path gate rejects it outright.
295
+ */
296
+ export const collectFrameworkDraftPrefixConstantOffsets = (
297
+ ast: AstNode,
298
+ filePath: string
299
+ ): ReadonlySet<number> => {
300
+ const offsets = new Set<number>();
301
+
302
+ if (!FRAMEWORK_DRAFT_CONSTANT_FILES.has(resolve(filePath))) {
303
+ return offsets;
304
+ }
305
+
306
+ walk(ast, (node) => {
307
+ if (node.type !== 'VariableDeclarator') {
308
+ return;
309
+ }
310
+
311
+ const { id, init } = node as unknown as {
312
+ readonly id?: AstNode;
313
+ readonly init?: AstNode;
314
+ };
315
+ const name = identifierName(id);
316
+ if (
317
+ !name ||
318
+ !FRAMEWORK_DRAFT_PREFIX_CONSTANT_NAMES.has(name) ||
319
+ !init ||
320
+ !isStringLiteral(init)
321
+ ) {
322
+ return;
323
+ }
324
+
325
+ if (getStringValue(init) !== FRAMEWORK_DRAFT_PREFIX_LITERAL) {
326
+ return;
327
+ }
328
+
329
+ offsets.add(init.start);
330
+ });
331
+
332
+ return offsets;
333
+ };
334
+
335
+ const WARDEN_IGNORE_NEXT_LINE_PRAGMAS = new Set([
336
+ '// warden-ignore-next-line',
337
+ '<!-- warden-ignore-next-line -->',
338
+ ]);
339
+
340
+ /**
341
+ * Split source code into lines for pragma lookups. Callers should split once
342
+ * per `check` invocation and thread the result through to
343
+ * {@link hasIgnoreCommentOnLine} so we avoid re-splitting the full source on
344
+ * every match in files with many draft-like string literals.
345
+ */
346
+ export const splitSourceLines = (sourceCode: string): readonly string[] =>
347
+ sourceCode.split('\n');
348
+
349
+ /**
350
+ * Check whether the line immediately preceding `line` contains a
351
+ * `warden-ignore-next-line` pragma (leading/trailing whitespace tolerated).
352
+ * Pragma scope is strictly one line — an intervening blank line breaks it.
353
+ *
354
+ * Takes a pre-split `lines` array so callers can split the source once per
355
+ * invocation instead of re-splitting for every literal they check.
356
+ *
357
+ * @example
358
+ * ```ts
359
+ * // warden-ignore-next-line
360
+ * const x = '_draft.intentional'; // suppressed
361
+ * ```
362
+ */
363
+ export const hasIgnoreCommentOnLine = (
364
+ lines: readonly string[],
365
+ line: number
366
+ ): boolean => {
367
+ if (line <= 1) {
368
+ return false;
369
+ }
370
+
371
+ const previous = lines[line - 2];
372
+ if (previous === undefined) {
373
+ return false;
374
+ }
375
+
376
+ return WARDEN_IGNORE_NEXT_LINE_PRAGMAS.has(previous.trim());
377
+ };
378
+
165
379
  export const findStringLiterals = (
166
380
  ast: AstNode,
167
381
  predicate?: (value: string, node: AstNode) => boolean
@@ -204,11 +418,11 @@ export const extractFirstStringArg = (node: AstNode): string | null => {
204
418
  return extractStringLiteral(firstArg);
205
419
  };
206
420
 
207
- const isProvisionCall = (node: AstNode | undefined): boolean =>
421
+ const isResourceCall = (node: AstNode | undefined): boolean =>
208
422
  !!node &&
209
423
  node.type === 'CallExpression' &&
210
424
  identifierName((node as unknown as { callee?: AstNode }).callee) ===
211
- 'provision';
425
+ 'resource';
212
426
 
213
427
  const extractBindingName = (node: AstNode | undefined): string | null => {
214
428
  if (!node) {
@@ -223,8 +437,8 @@ const extractBindingName = (node: AstNode | undefined): string | null => {
223
437
  return null;
224
438
  };
225
439
 
226
- /** Collect `const foo = provision('id', ...)` bindings from a parsed file. */
227
- export const collectNamedProvisionIds = (
440
+ /** Collect `const foo = resource('id', ...)` bindings from a parsed file. */
441
+ export const collectNamedResourceIds = (
228
442
  ast: AstNode
229
443
  ): ReadonlyMap<string, string> => {
230
444
  const ids = new Map<string, string>();
@@ -238,28 +452,28 @@ export const collectNamedProvisionIds = (
238
452
  readonly id?: AstNode;
239
453
  readonly init?: AstNode;
240
454
  };
241
- if (!isProvisionCall(init)) {
455
+ if (!isResourceCall(init)) {
242
456
  return;
243
457
  }
244
458
 
245
459
  const name = extractBindingName(id);
246
- const provisionId = init ? extractFirstStringArg(init) : null;
247
- if (name && provisionId) {
248
- ids.set(name, provisionId);
460
+ const resourceId = init ? extractFirstStringArg(init) : null;
461
+ if (name && resourceId) {
462
+ ids.set(name, resourceId);
249
463
  }
250
464
  });
251
465
 
252
466
  return ids;
253
467
  };
254
468
 
255
- /** Collect all inline `provision('id', ...)` definition IDs from a parsed file. */
256
- export const collectProvisionDefinitionIds = (
469
+ /** Collect all inline `resource('id', ...)` definition IDs from a parsed file. */
470
+ export const collectResourceDefinitionIds = (
257
471
  ast: AstNode
258
472
  ): ReadonlySet<string> => {
259
473
  const ids = new Set<string>();
260
474
 
261
475
  walk(ast, (node) => {
262
- if (!isProvisionCall(node)) {
476
+ if (!isResourceCall(node)) {
263
477
  return;
264
478
  }
265
479
 
@@ -272,15 +486,36 @@ export const collectProvisionDefinitionIds = (
272
486
  return ids;
273
487
  };
274
488
 
275
- /** Backward-compatible aliases while the migration is in flight. */
276
- export const collectNamedServiceIds = collectNamedProvisionIds;
277
- /** Backward-compatible aliases while the migration is in flight. */
278
- export const collectServiceDefinitionIds = collectProvisionDefinitionIds;
279
-
280
489
  // ---------------------------------------------------------------------------
281
490
  // Config property extraction helpers
282
491
  // ---------------------------------------------------------------------------
283
492
 
493
+ /**
494
+ * Extract the identifying name of a `Property` key, supporting both
495
+ * identifier keys (`{ foo: 1 }`) and string-literal keys
496
+ * (`{ "foo": 1 }`). Computed keys are intentionally not resolved — a
497
+ * computed expression could evaluate to anything and we only want to
498
+ * match keys that are statically equivalent to a plain identifier.
499
+ */
500
+ const staticPropertyKeyName = (key: AstNode): string | null => {
501
+ if (key.type === 'Identifier') {
502
+ return (key as unknown as { name?: string }).name ?? null;
503
+ }
504
+ return isStringLiteral(key) ? getStringValue(key) : null;
505
+ };
506
+
507
+ const propertyKeyName = (prop: AstNode): string | null => {
508
+ if (prop.type !== 'Property') {
509
+ return null;
510
+ }
511
+ const { computed } = prop as unknown as { computed?: boolean };
512
+ if (computed) {
513
+ return null;
514
+ }
515
+ const key = prop.key as AstNode | undefined;
516
+ return key ? staticPropertyKeyName(key) : null;
517
+ };
518
+
284
519
  /** Find a Property node by key name inside an ObjectExpression config. */
285
520
  export const findConfigProperty = (
286
521
  config: AstNode,
@@ -294,7 +529,7 @@ export const findConfigProperty = (
294
529
  return null;
295
530
  }
296
531
  for (const prop of properties) {
297
- if (prop.type === 'Property' && prop.key?.name === propertyName) {
532
+ if (propertyKeyName(prop) === propertyName) {
298
533
  return prop;
299
534
  }
300
535
  }
@@ -322,144 +557,2669 @@ export interface TrailDefinition {
322
557
  *
323
558
  * Returns the trail ID, kind, and config object node for each definition.
324
559
  */
325
- const TRAIL_CALLEE_NAMES = new Set(['trail', 'signal']);
560
+ const TRAIL_CALLEE_NAMES = new Set(['signal', 'trail']);
326
561
 
327
- const getTrailCalleeName = (node: AstNode): string | null => {
328
- if (node.type !== 'CallExpression') {
329
- return null;
330
- }
331
- const callee = node['callee'] as AstNode | undefined;
332
- if (!callee || callee.type !== 'Identifier') {
333
- return null;
334
- }
335
- const { name } = callee as unknown as { name?: string };
336
- return name && TRAIL_CALLEE_NAMES.has(name) ? name : null;
337
- };
562
+ /**
563
+ * Source prefix for the Trails framework package whose namespace imports are
564
+ * recognized as carriers of `trail()` / `signal()` / `contour()` primitives.
565
+ *
566
+ * A namespaced callee like `core.trail(...)` is only treated as a framework
567
+ * call when the receiver identifier resolves to an `import * as core from
568
+ * '@ontrails/...'` in the same file. An unrelated `analytics.trail(...)`
569
+ * whose `analytics` comes from a different module (or no import at all)
570
+ * is ignored.
571
+ */
572
+ const FRAMEWORK_NAMESPACE_SOURCE_PREFIX = '@ontrails/';
338
573
 
339
- /** Extract args from a trail() call, handling both two-arg and single-object forms. */
340
- const extractTrailArgs = (
341
- node: AstNode
342
- ): { idArg: AstNode | null; configArg: AstNode } | null => {
343
- const args = node['arguments'] as readonly AstNode[] | undefined;
344
- if (!args || args.length === 0) {
345
- return null;
346
- }
574
+ const isFrameworkNamespaceSource = (value: unknown): boolean =>
575
+ typeof value === 'string' &&
576
+ value.startsWith(FRAMEWORK_NAMESPACE_SOURCE_PREFIX);
347
577
 
348
- const [firstArg, secondArg] = args;
349
- if (!firstArg) {
350
- return null;
351
- }
578
+ /**
579
+ * Collect local binding names introduced by `import * as <name> from
580
+ * '@ontrails/...'` declarations. Used to gate namespaced framework-primitive
581
+ * calls so an unrelated `analytics.trail(...)` doesn't match.
582
+ */
583
+ const getImportSourceValue = (node: AstNode): unknown => {
584
+ const sourceNode = (node as unknown as { source?: AstNode }).source;
585
+ return sourceNode
586
+ ? (sourceNode as unknown as { value?: unknown }).value
587
+ : undefined;
588
+ };
352
589
 
353
- // Two-arg form: trail('id', { ... })
354
- if (secondArg && firstArg.type !== 'ObjectExpression') {
355
- return { configArg: secondArg, idArg: firstArg };
590
+ const addNamespaceImportBindings = (
591
+ node: AstNode,
592
+ names: Set<string>
593
+ ): void => {
594
+ const specifiers =
595
+ (node['specifiers'] as readonly AstNode[] | undefined) ?? [];
596
+ for (const spec of specifiers) {
597
+ if (spec.type !== 'ImportNamespaceSpecifier') {
598
+ continue;
599
+ }
600
+ const { local } = spec as unknown as { local?: AstNode };
601
+ const localName = identifierName(local);
602
+ if (localName) {
603
+ names.add(localName);
604
+ }
356
605
  }
357
-
358
- // Single-object form: trail({ id: 'x', ... })
359
- return firstArg.type === 'ObjectExpression'
360
- ? { configArg: firstArg, idArg: null }
361
- : null;
362
606
  };
363
607
 
364
- /** Extract the string value from an `id` property inside a config ObjectExpression. */
365
- const extractIdFromConfig = (config: AstNode): string | null => {
366
- const idProp = findConfigProperty(config, 'id');
367
- if (!idProp || !idProp.value) {
368
- return null;
608
+ const TOP_LEVEL_NAMED_DECL_TYPES = new Set([
609
+ 'ClassDeclaration',
610
+ 'FunctionDeclaration',
611
+ 'TSEnumDeclaration',
612
+ 'TSModuleDeclaration',
613
+ ]);
614
+
615
+ const removeVarDeclarationShadowedNames = (
616
+ stmt: AstNode,
617
+ names: Set<string>
618
+ ): void => {
619
+ const declarations =
620
+ (stmt as unknown as { declarations?: readonly AstNode[] }).declarations ??
621
+ [];
622
+ for (const d of declarations) {
623
+ const { id } = d as unknown as { id?: AstNode };
624
+ const n = identifierName(id);
625
+ if (n) {
626
+ names.delete(n);
627
+ }
369
628
  }
370
- const val = (idProp.value as unknown as { value?: unknown }).value;
371
- return typeof val === 'string' ? val : null;
372
629
  };
373
630
 
374
- const extractTrailId = (trailArgs: {
375
- idArg: AstNode | null;
376
- configArg: AstNode;
377
- }): string | null => {
378
- if (trailArgs.idArg) {
379
- return (trailArgs.idArg as unknown as { value?: string }).value ?? null;
631
+ const removeNamedDeclShadowedName = (
632
+ stmt: AstNode,
633
+ names: Set<string>
634
+ ): void => {
635
+ const { id } = stmt as unknown as { id?: AstNode };
636
+ const n = identifierName(id);
637
+ if (n) {
638
+ names.delete(n);
380
639
  }
381
- return extractIdFromConfig(trailArgs.configArg);
382
640
  };
383
641
 
384
- const extractTrailDefinition = (node: AstNode): TrailDefinition | null => {
385
- const calleeName = getTrailCalleeName(node);
386
- if (!calleeName) {
387
- return null;
642
+ const removeTopLevelShadowedNames = (
643
+ stmt: AstNode,
644
+ names: Set<string>
645
+ ): void => {
646
+ if (
647
+ stmt.type === 'ExportNamedDeclaration' ||
648
+ stmt.type === 'ExportDefaultDeclaration'
649
+ ) {
650
+ const { declaration } = stmt as unknown as { declaration?: AstNode };
651
+ if (declaration) {
652
+ removeTopLevelShadowedNames(declaration, names);
653
+ }
654
+ return;
388
655
  }
389
-
390
- const trailArgs = extractTrailArgs(node);
391
- if (!trailArgs) {
392
- return null;
656
+ if (stmt.type === 'VariableDeclaration') {
657
+ removeVarDeclarationShadowedNames(stmt, names);
658
+ return;
393
659
  }
394
-
395
- const trailId = extractTrailId(trailArgs);
396
- if (!trailId) {
397
- return null;
660
+ if (TOP_LEVEL_NAMED_DECL_TYPES.has(stmt.type)) {
661
+ removeNamedDeclShadowedName(stmt, names);
398
662
  }
399
-
400
- return {
401
- config: trailArgs.configArg,
402
- id: trailId,
403
- kind: calleeName,
404
- start: node.start,
405
- };
406
663
  };
407
664
 
408
- export const findTrailDefinitions = (ast: AstNode): TrailDefinition[] => {
409
- const definitions: TrailDefinition[] = [];
410
-
665
+ const collectFrameworkNamespaceBindings = (
666
+ ast: AstNode
667
+ ): ReadonlySet<string> => {
668
+ const names = new Set<string>();
411
669
  walk(ast, (node) => {
412
- const def = extractTrailDefinition(node);
413
- if (def) {
414
- definitions.push(def);
670
+ if (node.type !== 'ImportDeclaration') {
671
+ return;
672
+ }
673
+ if (!isFrameworkNamespaceSource(getImportSourceValue(node))) {
674
+ return;
415
675
  }
676
+ addNamespaceImportBindings(node, names);
416
677
  });
417
-
418
- return definitions;
678
+ if (names.size === 0) {
679
+ return names;
680
+ }
681
+ // A same-named top-level declaration (class / enum / namespace / var /
682
+ // function / lexical binding) shadows the namespace import at module scope.
683
+ // The scope walker treats Program as the outermost frame and skips it when
684
+ // testing for inner shadows, so we have to strip these collisions here.
685
+ if (ast.type === 'Program') {
686
+ const body = (ast as unknown as { body?: readonly AstNode[] }).body ?? [];
687
+ for (const stmt of body) {
688
+ removeTopLevelShadowedNames(stmt, names);
689
+ }
690
+ }
691
+ return names;
419
692
  };
420
693
 
421
694
  // ---------------------------------------------------------------------------
422
- // Blaze body extraction
695
+ // Scope-aware framework-namespace resolution
423
696
  // ---------------------------------------------------------------------------
697
+ //
698
+ // A module-level `import * as core from '@ontrails/core'` makes `core` a
699
+ // framework-namespace binding, but a function-local `const core = {...}` (or
700
+ // param, `let`, `var`, `function`, class, catch param) shadows the import for
701
+ // the duration of that scope. A name-only check is not enough to trust
702
+ // `core.trail(...)` — we have to walk scopes outward from each call site and
703
+ // verify the first declaration of the receiver IS the namespace import.
704
+ //
705
+ // {@link collectFrameworkNamespacedCallStarts} performs that walk once per
706
+ // AST and returns the set of `CallExpression` start offsets whose receiver is
707
+ // provably the framework binding. Downstream helpers gate on this set instead
708
+ // of the bare names, so a local shadow cannot sneak through.
709
+
710
+ type PatternExpander = (node: AstNode) => readonly AstNode[];
711
+
712
+ const expandAssignmentPattern: PatternExpander = (node) => {
713
+ const { left } = node as unknown as { left?: AstNode };
714
+ return left ? [left] : [];
715
+ };
424
716
 
425
- /**
426
- * Extract top-level `blaze:` property values from an ObjectExpression's direct properties.
427
- *
428
- * Does not recurse into nested objects, so `meta: { blaze: ... }` is ignored.
429
- */
430
- const extractBlazeFromConfig = (config: AstNode): AstNode[] => {
431
- const bodies: AstNode[] = [];
432
- const properties = config['properties'] as readonly AstNode[] | undefined;
433
- if (!properties) {
434
- return bodies;
717
+ const expandRestElement: PatternExpander = (node) => {
718
+ const { argument } = node as unknown as { argument?: AstNode };
719
+ return argument ? [argument] : [];
720
+ };
721
+
722
+ const expandArrayPattern: PatternExpander = (node) => {
723
+ const elements =
724
+ (node as unknown as { elements?: readonly (AstNode | null)[] }).elements ??
725
+ [];
726
+ return elements.filter((e): e is AstNode => e !== null);
727
+ };
728
+
729
+ const expandObjectPatternProperty = (prop: AstNode): AstNode | null => {
730
+ if (prop.type === 'RestElement') {
731
+ return prop;
435
732
  }
436
- for (const prop of properties) {
437
- if (prop.type === 'Property' && prop.key?.name === 'blaze' && prop.value) {
438
- bodies.push(prop.value);
733
+ const { value } = prop as unknown as { value?: AstNode };
734
+ return value ?? null;
735
+ };
736
+
737
+ const expandObjectPattern: PatternExpander = (node) => {
738
+ const properties =
739
+ (node as unknown as { properties?: readonly AstNode[] }).properties ?? [];
740
+ return properties
741
+ .map(expandObjectPatternProperty)
742
+ .filter((n): n is AstNode => n !== null);
743
+ };
744
+
745
+ const PATTERN_EXPANDERS: Record<string, PatternExpander> = {
746
+ ArrayPattern: expandArrayPattern,
747
+ AssignmentPattern: expandAssignmentPattern,
748
+ ObjectPattern: expandObjectPattern,
749
+ RestElement: expandRestElement,
750
+ };
751
+
752
+ const processPatternNode = (
753
+ node: AstNode,
754
+ into: Set<string>,
755
+ stack: AstNode[]
756
+ ): void => {
757
+ if (node.type === 'Identifier') {
758
+ const { name } = node as unknown as { name?: string };
759
+ if (name) {
760
+ into.add(name);
439
761
  }
762
+ return;
763
+ }
764
+ const expand = PATTERN_EXPANDERS[node.type];
765
+ if (expand) {
766
+ stack.push(...expand(node));
440
767
  }
441
- return bodies;
442
768
  };
443
769
 
444
- /**
445
- * Find `blaze:` property values.
446
- *
447
- * When given an ObjectExpression (trail config), returns only its direct `blaze:`
448
- * properties. When given a full AST, finds trail definitions first and extracts
449
- * `blaze:` from each config — in both cases ignoring nested `blaze:` properties
450
- * (e.g. `meta: { blaze: ... }`).
451
- */
452
- export const findBlazeBodies = (node: AstNode): AstNode[] => {
453
- if (node.type === 'ObjectExpression') {
454
- return extractBlazeFromConfig(node);
770
+ const addPatternBindingNames = (
771
+ pattern: AstNode | undefined,
772
+ into: Set<string>
773
+ ): void => {
774
+ if (!pattern) {
775
+ return;
455
776
  }
777
+ const stack: AstNode[] = [pattern];
778
+ while (stack.length > 0) {
779
+ const node = stack.pop();
780
+ if (node) {
781
+ processPatternNode(node, into, stack);
782
+ }
783
+ }
784
+ };
456
785
 
457
- // Full AST — find trail definitions and extract blaze from their configs
458
- const bodies: AstNode[] = [];
459
- for (const def of findTrailDefinitions(node)) {
460
- bodies.push(...extractBlazeFromConfig(def.config));
786
+ const addVarDeclarationBindingNames = (
787
+ decl: AstNode,
788
+ into: Set<string>
789
+ ): void => {
790
+ const declarations =
791
+ (decl as unknown as { declarations?: readonly AstNode[] }).declarations ??
792
+ [];
793
+ for (const d of declarations) {
794
+ addPatternBindingNames((d as unknown as { id?: AstNode }).id, into);
461
795
  }
462
- return bodies;
796
+ };
797
+
798
+ const addFunctionOrClassBindingName = (
799
+ node: AstNode,
800
+ into: Set<string>
801
+ ): void => {
802
+ const { id } = node as unknown as { id?: AstNode };
803
+ const name = identifierName(id);
804
+ if (name) {
805
+ into.add(name);
806
+ }
807
+ };
808
+
809
+ const addBlockStatementBindings = (stmt: AstNode, into: Set<string>): void => {
810
+ if (stmt.type === 'VariableDeclaration') {
811
+ addVarDeclarationBindingNames(stmt, into);
812
+ return;
813
+ }
814
+ if (
815
+ stmt.type === 'FunctionDeclaration' ||
816
+ stmt.type === 'ClassDeclaration' ||
817
+ stmt.type === 'TSEnumDeclaration' ||
818
+ stmt.type === 'TSModuleDeclaration'
819
+ ) {
820
+ addFunctionOrClassBindingName(stmt, into);
821
+ }
822
+ };
823
+
824
+ const collectTopLevelStatementBindings = (
825
+ stmt: AstNode,
826
+ into: Set<string>
827
+ ): void => {
828
+ if (
829
+ stmt.type === 'ExportNamedDeclaration' ||
830
+ stmt.type === 'ExportDefaultDeclaration'
831
+ ) {
832
+ const { declaration } = stmt as unknown as { declaration?: AstNode };
833
+ if (declaration) {
834
+ collectTopLevelStatementBindings(declaration, into);
835
+ }
836
+ return;
837
+ }
838
+ addBlockStatementBindings(stmt, into);
839
+ };
840
+
841
+ const FUNCTION_BOUNDARY_TYPES = new Set([
842
+ 'ArrowFunctionExpression',
843
+ 'FunctionDeclaration',
844
+ 'FunctionExpression',
845
+ 'StaticBlock',
846
+ ]);
847
+
848
+ const forEachAstChild = (
849
+ node: AstNode,
850
+ visit: (child: AstNode) => void
851
+ ): void => {
852
+ for (const val of Object.values(node)) {
853
+ if (Array.isArray(val)) {
854
+ for (const item of val) {
855
+ if (item && typeof item === 'object' && (item as AstNode).type) {
856
+ visit(item as AstNode);
857
+ }
858
+ }
859
+ } else if (val && typeof val === 'object' && (val as AstNode).type) {
860
+ visit(val as AstNode);
861
+ }
862
+ }
863
+ };
864
+
865
+ const recordHoistedBinding = (
866
+ node: AstNode,
867
+ into: Set<string>,
868
+ inNestedBlock: boolean
869
+ ): void => {
870
+ if (node.type === 'VariableDeclaration') {
871
+ const { kind } = node as unknown as { kind?: string };
872
+ if (kind === 'var') {
873
+ addVarDeclarationBindingNames(node, into);
874
+ }
875
+ return;
876
+ }
877
+ // In strict/module code, function/class/enum/module declarations inside a
878
+ // nested block (`if { function foo() {} }`, `switch` case, etc.) are
879
+ // block-scoped. Only hoist them to the enclosing function frame when they
880
+ // sit directly in the function body, not inside a further block.
881
+ if (inNestedBlock) {
882
+ return;
883
+ }
884
+ if (
885
+ node.type === 'FunctionDeclaration' ||
886
+ node.type === 'ClassDeclaration' ||
887
+ node.type === 'TSEnumDeclaration' ||
888
+ node.type === 'TSModuleDeclaration'
889
+ ) {
890
+ addFunctionOrClassBindingName(node, into);
891
+ }
892
+ };
893
+
894
+ const NESTED_BLOCK_BOUNDARY_TYPES = new Set([
895
+ 'BlockStatement',
896
+ 'ForStatement',
897
+ 'ForInStatement',
898
+ 'ForOfStatement',
899
+ 'SwitchStatement',
900
+ 'CatchClause',
901
+ ]);
902
+
903
+ const visitForHoisted = (
904
+ node: AstNode,
905
+ isRoot: boolean,
906
+ into: Set<string>,
907
+ inNestedBlock: boolean
908
+ ): void => {
909
+ if (!isRoot && FUNCTION_BOUNDARY_TYPES.has(node.type)) {
910
+ return;
911
+ }
912
+ recordHoistedBinding(node, into, inNestedBlock);
913
+ const childInNestedBlock =
914
+ inNestedBlock || (!isRoot && NESTED_BLOCK_BOUNDARY_TYPES.has(node.type));
915
+ forEachAstChild(node, (child) => {
916
+ visitForHoisted(child, false, into, childInNestedBlock);
917
+ });
918
+ };
919
+
920
+ /**
921
+ * Collect `var` declarations and `function` declarations hoisted to the
922
+ * nearest function scope from anywhere inside `root`, without crossing a
923
+ * nested function or static-block boundary.
924
+ */
925
+ const collectHoistedVarAndFunctionBindings = (
926
+ root: AstNode,
927
+ into: Set<string>
928
+ ): void => {
929
+ visitForHoisted(root, true, into, false);
930
+ };
931
+
932
+ type FrameCollector = (node: AstNode, into: Set<string>) => void;
933
+
934
+ const collectProgramFrame: FrameCollector = (node, into) => {
935
+ const body = (node as unknown as { body?: readonly AstNode[] }).body ?? [];
936
+ for (const stmt of body) {
937
+ collectTopLevelStatementBindings(stmt, into);
938
+ }
939
+ };
940
+
941
+ const collectFunctionFrame: FrameCollector = (node, into) => {
942
+ const params =
943
+ (node as unknown as { params?: readonly AstNode[] }).params ?? [];
944
+ for (const param of params) {
945
+ addPatternBindingNames(param, into);
946
+ }
947
+ // Hoisted vars and function declarations inside the body live in the
948
+ // function's var-environment. A `var ns = ...;` inside an `if` still
949
+ // shadows a module-level `ns` for the whole function.
950
+ const { body } = node as unknown as { body?: AstNode };
951
+ if (body) {
952
+ collectHoistedVarAndFunctionBindings(body, into);
953
+ }
954
+ };
955
+
956
+ const collectBlockFrame: FrameCollector = (node, into) => {
957
+ const body = (node as unknown as { body?: readonly AstNode[] }).body ?? [];
958
+ for (const stmt of body) {
959
+ addBlockStatementBindings(stmt, into);
960
+ }
961
+ };
962
+
963
+ const collectForStatementFrame: FrameCollector = (node, into) => {
964
+ const { init } = node as unknown as { init?: AstNode };
965
+ if (init && init.type === 'VariableDeclaration') {
966
+ addVarDeclarationBindingNames(init, into);
967
+ }
968
+ };
969
+
970
+ const collectForInOfFrame: FrameCollector = (node, into) => {
971
+ const { left } = node as unknown as { left?: AstNode };
972
+ if (left && left.type === 'VariableDeclaration') {
973
+ addVarDeclarationBindingNames(left, into);
974
+ }
975
+ };
976
+
977
+ const collectSwitchStatementFrame: FrameCollector = (node, into) => {
978
+ // `switch` shares one scope across every case. A binding in one case
979
+ // shadows the namespace across sibling cases (fall-through or otherwise).
980
+ const cases = (node as unknown as { cases?: readonly AstNode[] }).cases ?? [];
981
+ for (const c of cases) {
982
+ const consequent =
983
+ (c as unknown as { consequent?: readonly AstNode[] }).consequent ?? [];
984
+ for (const stmt of consequent) {
985
+ addBlockStatementBindings(stmt, into);
986
+ }
987
+ }
988
+ };
989
+
990
+ const collectCatchClauseFrame: FrameCollector = (node, into) => {
991
+ const { param } = node as unknown as { param?: AstNode };
992
+ addPatternBindingNames(param, into);
993
+ };
994
+
995
+ const collectClassExpressionFrame: FrameCollector = (node, into) => {
996
+ // A named `class expr` (`const C = class foo { ... }`) binds its own name
997
+ // inside its body only. ClassDeclaration names are hoisted into the
998
+ // enclosing block/program frame instead, so only class *expression* names
999
+ // need their own frame here.
1000
+ addFunctionOrClassBindingName(node, into);
1001
+ };
1002
+
1003
+ const SCOPE_FRAME_COLLECTORS: Record<string, FrameCollector> = {
1004
+ ArrowFunctionExpression: collectFunctionFrame,
1005
+ BlockStatement: collectBlockFrame,
1006
+ CatchClause: collectCatchClauseFrame,
1007
+ ClassExpression: collectClassExpressionFrame,
1008
+ ForInStatement: collectForInOfFrame,
1009
+ ForOfStatement: collectForInOfFrame,
1010
+ ForStatement: collectForStatementFrame,
1011
+ // oxc-parser emits `FunctionBody` for `function` expression bodies; without
1012
+ // this entry, a `const ns = ...` at the top of a function-expression body
1013
+ // would not push a scope frame, and a module-level namespace import with
1014
+ // the same name would be incorrectly recognized inside.
1015
+ FunctionBody: collectBlockFrame,
1016
+ FunctionDeclaration: collectFunctionFrame,
1017
+ FunctionExpression: collectFunctionFrame,
1018
+ Program: collectProgramFrame,
1019
+ StaticBlock: collectBlockFrame,
1020
+ SwitchStatement: collectSwitchStatementFrame,
1021
+ };
1022
+
1023
+ /**
1024
+ * Collect the identifier bindings introduced *directly* by a scope frame
1025
+ * node. Scope frames correspond to JS lexical scopes (function bodies, blocks,
1026
+ * catch clauses, for-statements, switch statements, module/script roots).
1027
+ */
1028
+ export const collectScopeFrameBindings = (
1029
+ node: AstNode
1030
+ ): ReadonlySet<string> => {
1031
+ const names = new Set<string>();
1032
+ const collector = SCOPE_FRAME_COLLECTORS[node.type];
1033
+ if (collector) {
1034
+ collector(node, names);
1035
+ }
1036
+ return names;
1037
+ };
1038
+
1039
+ export type ScopeAwareVisitor = (
1040
+ node: AstNode,
1041
+ scopes: readonly ReadonlySet<string>[]
1042
+ ) => void;
1043
+
1044
+ export interface ScopeWalkOptions {
1045
+ readonly initialScopes?: readonly ReadonlySet<string>[];
1046
+ readonly stopAtNestedFunctions?: boolean;
1047
+ }
1048
+
1049
+ const asAstNode = (node: unknown): AstNode | null => {
1050
+ if (!node || typeof node !== 'object') {
1051
+ return null;
1052
+ }
1053
+ const astNode = node as AstNode;
1054
+ return astNode.type ? astNode : null;
1055
+ };
1056
+
1057
+ /**
1058
+ * Walk an AST subtree while threading lexical scope bindings through each
1059
+ * visit. Callers can seed outer scopes and optionally stop at nested function
1060
+ * boundaries when only the current implementation body should be analyzed.
1061
+ */
1062
+ export const walkWithScopes = (
1063
+ node: unknown,
1064
+ visit: ScopeAwareVisitor,
1065
+ options: ScopeWalkOptions = {}
1066
+ ): void => {
1067
+ const root = asAstNode(node);
1068
+ if (!root) {
1069
+ return;
1070
+ }
1071
+
1072
+ const stack = [...(options.initialScopes ?? [])];
1073
+
1074
+ const walkNode = (current: AstNode, isRoot: boolean): void => {
1075
+ if (
1076
+ !isRoot &&
1077
+ options.stopAtNestedFunctions &&
1078
+ FUNCTION_BOUNDARY_TYPES.has(current.type)
1079
+ ) {
1080
+ return;
1081
+ }
1082
+
1083
+ const isScope = current.type in SCOPE_FRAME_COLLECTORS;
1084
+ if (isScope) {
1085
+ stack.unshift(collectScopeFrameBindings(current));
1086
+ }
1087
+
1088
+ try {
1089
+ visit(current, stack);
1090
+ forEachAstChild(current, (child) => {
1091
+ walkNode(child, false);
1092
+ });
1093
+ } finally {
1094
+ if (isScope) {
1095
+ stack.shift();
1096
+ }
1097
+ }
1098
+ };
1099
+
1100
+ walkNode(root, true);
1101
+ };
1102
+
1103
+ const isShadowed = (
1104
+ receiverName: string,
1105
+ scopeStack: readonly ReadonlySet<string>[]
1106
+ ): boolean => {
1107
+ // The module-level Program frame is the last entry and contains the
1108
+ // namespace imports themselves. A "shadow" must come from a frame *inside*
1109
+ // that one — i.e. any frame except the outermost.
1110
+ for (let i = 0; i < scopeStack.length - 1; i += 1) {
1111
+ const frame = scopeStack[i];
1112
+ if (frame?.has(receiverName)) {
1113
+ return true;
1114
+ }
1115
+ }
1116
+ return false;
1117
+ };
1118
+
1119
+ /**
1120
+ * Return `true` when `node` is a non-computed member access (`a.b` /
1121
+ * `a?.b`) and `false` for anything else, including computed access
1122
+ * (`a[b]`) or non-member nodes. Exported as the canonical predicate so
1123
+ * rule modules do not re-implement the check.
1124
+ *
1125
+ * @remarks
1126
+ * Declared near the top of the file so the scope walker can use it
1127
+ * without hitting `no-use-before-define`. A few sibling helpers in this
1128
+ * module still inline the same shape under different local names for
1129
+ * historical reasons; prefer this export for new call sites.
1130
+ */
1131
+ export const isMemberAccessNonComputed = (node: AstNode): boolean => {
1132
+ if (
1133
+ node.type !== 'MemberExpression' &&
1134
+ node.type !== 'StaticMemberExpression'
1135
+ ) {
1136
+ return false;
1137
+ }
1138
+ return (node as unknown as { computed?: boolean }).computed !== true;
1139
+ };
1140
+
1141
+ const resolveNamespacedMemberNames = (
1142
+ callee: AstNode
1143
+ ): { readonly receiver: string; readonly property: string } | null => {
1144
+ if (!isMemberAccessNonComputed(callee)) {
1145
+ return null;
1146
+ }
1147
+ const { object } = callee as unknown as { object?: AstNode };
1148
+ const receiver = identifierName(object);
1149
+ if (!receiver) {
1150
+ return null;
1151
+ }
1152
+ const prop = (callee as unknown as { property?: AstNode }).property;
1153
+ const property =
1154
+ prop?.type === 'Identifier'
1155
+ ? ((prop as unknown as { name?: string }).name ?? null)
1156
+ : null;
1157
+ return property ? { property, receiver } : null;
1158
+ };
1159
+
1160
+ const getFrameworkCallReceiver = (
1161
+ node: AstNode,
1162
+ frameworkNamespaces: ReadonlySet<string>
1163
+ ): string | null => {
1164
+ if (node.type !== 'CallExpression') {
1165
+ return null;
1166
+ }
1167
+ const callee = node['callee'] as AstNode | undefined;
1168
+ if (!callee) {
1169
+ return null;
1170
+ }
1171
+ const names = resolveNamespacedMemberNames(callee);
1172
+ if (!names || !frameworkNamespaces.has(names.receiver)) {
1173
+ return null;
1174
+ }
1175
+ return names.receiver;
1176
+ };
1177
+
1178
+ /**
1179
+ * Walk the AST with a scope stack and collect `CallExpression` start offsets
1180
+ * whose callee is `<receiver>.<property>` where `<receiver>` is proven to
1181
+ * resolve to a framework namespace import (i.e. not shadowed by any
1182
+ * enclosing scope). Used to gate namespaced `core.trail(...)` /
1183
+ * `core.signal(...)` / `core.contour(...)` resolution against local shadows.
1184
+ */
1185
+ const collectFrameworkNamespacedCallStarts = (
1186
+ ast: AstNode,
1187
+ frameworkNamespaces: ReadonlySet<string>
1188
+ ): ReadonlySet<number> => {
1189
+ const starts = new Set<number>();
1190
+ if (frameworkNamespaces.size === 0) {
1191
+ return starts;
1192
+ }
1193
+
1194
+ walkWithScopes(ast, (node, scopes) => {
1195
+ const receiver = getFrameworkCallReceiver(node, frameworkNamespaces);
1196
+ if (!receiver || isShadowed(receiver, scopes)) {
1197
+ return;
1198
+ }
1199
+ starts.add(node.start);
1200
+ });
1201
+
1202
+ return starts;
1203
+ };
1204
+
1205
+ const matchTrailPrimitiveName = (
1206
+ name: string | undefined | null
1207
+ ): string | null => (name && TRAIL_CALLEE_NAMES.has(name) ? name : null);
1208
+
1209
+ const getBareTrailCalleeName = (callee: AstNode): string | null => {
1210
+ if (callee.type !== 'Identifier') {
1211
+ return null;
1212
+ }
1213
+ return matchTrailPrimitiveName((callee as unknown as { name?: string }).name);
1214
+ };
1215
+
1216
+ /**
1217
+ * Extract the `{ receiverName, propertyName }` of a non-computed member-call
1218
+ * callee, or null for anything else. Computed access (`ns[trail]()`) is
1219
+ * intentionally rejected: the bracketed expression may resolve to any runtime
1220
+ * value, so we cannot prove the call targets a specific member.
1221
+ */
1222
+ const isNonComputedMemberAccess = (callee: AstNode): boolean => {
1223
+ if (
1224
+ callee.type !== 'MemberExpression' &&
1225
+ callee.type !== 'StaticMemberExpression'
1226
+ ) {
1227
+ return false;
1228
+ }
1229
+ return (callee as unknown as { computed?: boolean }).computed !== true;
1230
+ };
1231
+
1232
+ const getNamespacedMemberNames = (
1233
+ callee: AstNode
1234
+ ): { readonly receiver: string; readonly property: string } | null => {
1235
+ if (!isNonComputedMemberAccess(callee)) {
1236
+ return null;
1237
+ }
1238
+ const { object } = callee as unknown as { object?: AstNode };
1239
+ const receiver = identifierName(object);
1240
+ if (!receiver) {
1241
+ return null;
1242
+ }
1243
+ const prop = (callee as unknown as { property?: AstNode }).property;
1244
+ const property =
1245
+ prop?.type === 'Identifier'
1246
+ ? ((prop as unknown as { name?: string }).name ?? null)
1247
+ : null;
1248
+ return property ? { property, receiver } : null;
1249
+ };
1250
+
1251
+ /**
1252
+ * Resolution context for namespaced framework-primitive calls. Bundles the
1253
+ * bare namespace-binding set with an optional set of proven-safe
1254
+ * `CallExpression` start offsets from a scope-aware pre-pass. When the set of
1255
+ * safe starts is present, a namespaced call only resolves if its start is in
1256
+ * that set — so a function-local shadow of the namespace import does not
1257
+ * leak through. When absent (e.g. from test helpers), the name-only gate is
1258
+ * used as a backward-compatible fallback.
1259
+ */
1260
+ export interface FrameworkNamespaceContext {
1261
+ readonly namespaces: ReadonlySet<string>;
1262
+ readonly safeCallStarts?: ReadonlySet<number>;
1263
+ }
1264
+
1265
+ const asNamespaceContext = (
1266
+ input: ReadonlySet<string> | FrameworkNamespaceContext | undefined
1267
+ ): FrameworkNamespaceContext | undefined => {
1268
+ if (!input) {
1269
+ return undefined;
1270
+ }
1271
+ return input instanceof Set
1272
+ ? { namespaces: input }
1273
+ : (input as FrameworkNamespaceContext);
1274
+ };
1275
+
1276
+ const isNamespacedCallAllowed = (
1277
+ callStart: number,
1278
+ receiver: string,
1279
+ ctx: FrameworkNamespaceContext
1280
+ ): boolean => {
1281
+ if (!ctx.namespaces.has(receiver)) {
1282
+ return false;
1283
+ }
1284
+ // When `safeCallStarts` is present, it is the authoritative gate — it was
1285
+ // built by a scope-aware pre-pass and already excludes shadowed receivers.
1286
+ // Without it, fall back to the bare name check (used by unit-test hooks).
1287
+ return ctx.safeCallStarts ? ctx.safeCallStarts.has(callStart) : true;
1288
+ };
1289
+
1290
+ /**
1291
+ * Resolve a namespaced `ns.trail(...)` / `ns.signal(...)` callee to its
1292
+ * primitive name. When a {@link FrameworkNamespaceContext} is provided, the
1293
+ * receiver must be a framework namespace binding AND — when a
1294
+ * `safeCallStarts` set is present — the call site must appear in that set,
1295
+ * meaning the receiver is not shadowed by any enclosing scope.
1296
+ *
1297
+ * When `context` is `undefined`, this falls back to permissive matching
1298
+ * (any `ns.trail(...)` shape resolves). Inline resolution paths that do
1299
+ * not have the surrounding AST available (e.g. `crosses: [core.trail(...)]`
1300
+ * or `on: [core.signal(...)]`) rely on this fallback. Scope-aware call
1301
+ * sites always pass a context, so this only affects inline contexts where
1302
+ * a best-effort name match is the intended behavior.
1303
+ */
1304
+ const getNamespacedTrailCalleeName = (
1305
+ callExpr: AstNode,
1306
+ callee: AstNode,
1307
+ context?: ReadonlySet<string> | FrameworkNamespaceContext
1308
+ ): string | null => {
1309
+ const names = getNamespacedMemberNames(callee);
1310
+ if (!names) {
1311
+ return null;
1312
+ }
1313
+ const ctx = asNamespaceContext(context);
1314
+ if (ctx && !isNamespacedCallAllowed(callExpr.start, names.receiver, ctx)) {
1315
+ return null;
1316
+ }
1317
+ return matchTrailPrimitiveName(names.property);
1318
+ };
1319
+
1320
+ /**
1321
+ * Resolve the callee name of a trail/signal call expression.
1322
+ *
1323
+ * Matches both bare `trail(...)` / `signal(...)` identifiers and namespaced
1324
+ * member-expression callees like `core.trail(...)` or `ns.signal(...)`, where
1325
+ * the namespace must come from an `@ontrails/*` import and, when the scope
1326
+ * pre-pass is wired in, be unshadowed at the call site.
1327
+ */
1328
+ const getTrailCalleeName = (
1329
+ node: AstNode,
1330
+ context?: ReadonlySet<string> | FrameworkNamespaceContext
1331
+ ): string | null => {
1332
+ if (node.type !== 'CallExpression') {
1333
+ return null;
1334
+ }
1335
+ const callee = node['callee'] as AstNode | undefined;
1336
+ if (!callee) {
1337
+ return null;
1338
+ }
1339
+ return (
1340
+ getBareTrailCalleeName(callee) ??
1341
+ getNamespacedTrailCalleeName(node, callee, context)
1342
+ );
1343
+ };
1344
+
1345
+ /**
1346
+ * Test hook: exposes {@link getTrailCalleeName} for unit tests.
1347
+ *
1348
+ * Kept unexported from the module's public surface (no re-export from
1349
+ * `index.ts`) so internal refactors stay free.
1350
+ */
1351
+ export const __getTrailCalleeNameForTest = getTrailCalleeName;
1352
+
1353
+ /**
1354
+ * Test hook: exposes {@link collectFrameworkNamespaceBindings} for unit tests.
1355
+ *
1356
+ * Not re-exported from `index.ts`; the double-underscore prefix marks it as an
1357
+ * internal-only handle so consumer code cannot rely on it.
1358
+ */
1359
+ export const __collectFrameworkNamespaceBindingsForTest =
1360
+ collectFrameworkNamespaceBindings;
1361
+
1362
+ /** Extract args from a trail() call, handling both two-arg and single-object forms. */
1363
+ const extractTrailArgs = (
1364
+ node: AstNode
1365
+ ): { idArg: AstNode | null; configArg: AstNode } | null => {
1366
+ const args = node['arguments'] as readonly AstNode[] | undefined;
1367
+ if (!args || args.length === 0) {
1368
+ return null;
1369
+ }
1370
+
1371
+ const [firstArg, secondArg] = args;
1372
+ if (!firstArg) {
1373
+ return null;
1374
+ }
1375
+
1376
+ // Two-arg form: trail('id', { ... })
1377
+ if (secondArg && firstArg.type !== 'ObjectExpression') {
1378
+ return { configArg: secondArg, idArg: firstArg };
1379
+ }
1380
+
1381
+ // Single-object form: trail({ id: 'x', ... })
1382
+ return firstArg.type === 'ObjectExpression'
1383
+ ? { configArg: firstArg, idArg: null }
1384
+ : null;
1385
+ };
1386
+
1387
+ /** Extract the string value from an `id` property inside a config ObjectExpression. */
1388
+ const extractIdFromConfig = (config: AstNode): string | null => {
1389
+ const idProp = findConfigProperty(config, 'id');
1390
+ if (!idProp || !idProp.value) {
1391
+ return null;
1392
+ }
1393
+ return extractStringOrTemplateLiteral(idProp.value as AstNode);
1394
+ };
1395
+
1396
+ const extractTrailId = (trailArgs: {
1397
+ idArg: AstNode | null;
1398
+ configArg: AstNode;
1399
+ }): string | null => {
1400
+ if (trailArgs.idArg) {
1401
+ return extractStringOrTemplateLiteral(trailArgs.idArg);
1402
+ }
1403
+ return extractIdFromConfig(trailArgs.configArg);
1404
+ };
1405
+
1406
+ const extractTrailDefinition = (
1407
+ node: AstNode,
1408
+ context?: ReadonlySet<string> | FrameworkNamespaceContext
1409
+ ): TrailDefinition | null => {
1410
+ const calleeName = getTrailCalleeName(node, context);
1411
+ if (!calleeName) {
1412
+ return null;
1413
+ }
1414
+
1415
+ const trailArgs = extractTrailArgs(node);
1416
+ if (!trailArgs) {
1417
+ return null;
1418
+ }
1419
+
1420
+ const trailId = extractTrailId(trailArgs);
1421
+ if (!trailId) {
1422
+ return null;
1423
+ }
1424
+
1425
+ return {
1426
+ config: trailArgs.configArg,
1427
+ id: trailId,
1428
+ kind: calleeName,
1429
+ start: node.start,
1430
+ };
1431
+ };
1432
+
1433
+ const buildFrameworkNamespaceContext = (
1434
+ ast: AstNode
1435
+ ): FrameworkNamespaceContext => {
1436
+ const namespaces = collectFrameworkNamespaceBindings(ast);
1437
+ return {
1438
+ namespaces,
1439
+ safeCallStarts: collectFrameworkNamespacedCallStarts(ast, namespaces),
1440
+ };
1441
+ };
1442
+
1443
+ export const findTrailDefinitions = (ast: AstNode): TrailDefinition[] => {
1444
+ const definitions: TrailDefinition[] = [];
1445
+ const context = buildFrameworkNamespaceContext(ast);
1446
+
1447
+ walk(ast, (node) => {
1448
+ const def = extractTrailDefinition(node, context);
1449
+ if (def) {
1450
+ definitions.push(def);
1451
+ }
1452
+ });
1453
+
1454
+ return definitions;
1455
+ };
1456
+
1457
+ // ---------------------------------------------------------------------------
1458
+ // Contour definition extraction
1459
+ // ---------------------------------------------------------------------------
1460
+
1461
+ export interface ContourDefinition {
1462
+ /** Local binding name when the contour is assigned to a variable. */
1463
+ readonly bindingName?: string;
1464
+ /** Contour name string, e.g. "user". */
1465
+ readonly name: string;
1466
+ /** Original call expression for the contour declaration. */
1467
+ readonly call: AstNode;
1468
+ /** Options object argument passed to contour(), when present. */
1469
+ readonly options: AstNode | null;
1470
+ /** Shape object argument passed to contour(). */
1471
+ readonly shape: AstNode;
1472
+ /** Start offset of the call expression. */
1473
+ readonly start: number;
1474
+ }
1475
+
1476
+ const CONTOUR_PRIMITIVE_NAME = 'contour';
1477
+
1478
+ const matchContourPrimitiveName = (
1479
+ name: string | undefined | null
1480
+ ): string | null => (name === CONTOUR_PRIMITIVE_NAME ? name : null);
1481
+
1482
+ const getBareContourCalleeName = (callee: AstNode): string | null => {
1483
+ if (callee.type !== 'Identifier') {
1484
+ return null;
1485
+ }
1486
+ return matchContourPrimitiveName(
1487
+ (callee as unknown as { name?: string }).name
1488
+ );
1489
+ };
1490
+
1491
+ /**
1492
+ * Resolve a namespaced `ns.contour(...)` callee to its primitive name. Mirrors
1493
+ * {@link getNamespacedTrailCalleeName}: the receiver identifier must resolve
1494
+ * to an `@ontrails/*` namespace import, and — when a scope-aware
1495
+ * `safeCallStarts` set is provided — the call site must not be shadowed by a
1496
+ * local binding of the same name.
1497
+ */
1498
+ const getNamespacedContourCalleeName = (
1499
+ callExpr: AstNode,
1500
+ callee: AstNode,
1501
+ context?: ReadonlySet<string> | FrameworkNamespaceContext
1502
+ ): string | null => {
1503
+ const names = getNamespacedMemberNames(callee);
1504
+ if (!names) {
1505
+ return null;
1506
+ }
1507
+ // Unlike the trail/signal variant, contour has no inline-resolution callers
1508
+ // that legitimately invoke this without a FrameworkNamespaceContext, so the
1509
+ // strict namespace gate stays on. If a future caller needs the permissive
1510
+ // fallback, mirror the trail shape and add a regression test first.
1511
+ const ctx = asNamespaceContext(context);
1512
+ if (!ctx || !isNamespacedCallAllowed(callExpr.start, names.receiver, ctx)) {
1513
+ return null;
1514
+ }
1515
+ return matchContourPrimitiveName(names.property);
1516
+ };
1517
+
1518
+ /**
1519
+ * Resolve the callee name of a contour call expression. Matches both bare
1520
+ * `contour(...)` identifiers and namespaced `core.contour(...)` callees where
1521
+ * the namespace comes from an `@ontrails/*` import and is unshadowed.
1522
+ */
1523
+ const getContourCalleeName = (
1524
+ node: AstNode,
1525
+ context?: ReadonlySet<string> | FrameworkNamespaceContext
1526
+ ): string | null => {
1527
+ if (node.type !== 'CallExpression') {
1528
+ return null;
1529
+ }
1530
+ const callee = node['callee'] as AstNode | undefined;
1531
+ if (!callee) {
1532
+ return null;
1533
+ }
1534
+ return (
1535
+ getBareContourCalleeName(callee) ??
1536
+ getNamespacedContourCalleeName(node, callee, context)
1537
+ );
1538
+ };
1539
+
1540
+ const extractContourDefinition = (
1541
+ node: AstNode,
1542
+ context?: ReadonlySet<string> | FrameworkNamespaceContext
1543
+ ): Omit<ContourDefinition, 'bindingName'> | null => {
1544
+ if (!getContourCalleeName(node, context)) {
1545
+ return null;
1546
+ }
1547
+
1548
+ const args = node['arguments'] as readonly AstNode[] | undefined;
1549
+ const [nameArg, shapeArg, optionsArg] = args ?? [];
1550
+ const name = extractStringLiteral(nameArg);
1551
+ if (!name || shapeArg?.type !== 'ObjectExpression') {
1552
+ return null;
1553
+ }
1554
+
1555
+ return {
1556
+ call: node,
1557
+ name,
1558
+ options: optionsArg?.type === 'ObjectExpression' ? optionsArg : null,
1559
+ shape: shapeArg,
1560
+ start: node.start,
1561
+ };
1562
+ };
1563
+
1564
+ const getCallStartFromCandidate = (
1565
+ node: AstNode | undefined
1566
+ ): number | null => {
1567
+ if (!node) {
1568
+ return null;
1569
+ }
1570
+ if (node.type === 'CallExpression') {
1571
+ return node.start;
1572
+ }
1573
+ if (node.type !== 'ExpressionStatement') {
1574
+ return null;
1575
+ }
1576
+ const { expression } = node as unknown as { expression?: AstNode };
1577
+ return expression?.type === 'CallExpression' ? expression.start : null;
1578
+ };
1579
+
1580
+ // Statement forms that can directly contain a top-level contour call:
1581
+ // `core.contour(...)` as a bare statement,
1582
+ // `export const ... = core.contour(...)` (handled via VariableDeclarator),
1583
+ // `export default core.contour(...);`.
1584
+ const getCandidateCallHosts = (
1585
+ statement: AstNode
1586
+ ): readonly (AstNode | undefined)[] => {
1587
+ if (
1588
+ statement.type !== 'ExportNamedDeclaration' &&
1589
+ statement.type !== 'ExportDefaultDeclaration'
1590
+ ) {
1591
+ return [statement];
1592
+ }
1593
+ const { declaration } = statement as unknown as {
1594
+ declaration?: AstNode;
1595
+ };
1596
+ return [statement, declaration];
1597
+ };
1598
+
1599
+ const getTopLevelCallStartsFrom = (statement: AstNode): readonly number[] => {
1600
+ const hosts = getCandidateCallHosts(statement);
1601
+ const starts: number[] = [];
1602
+ for (const host of hosts) {
1603
+ const start = getCallStartFromCandidate(host);
1604
+ if (start !== null) {
1605
+ starts.push(start);
1606
+ }
1607
+ }
1608
+ return starts;
1609
+ };
1610
+
1611
+ /**
1612
+ * Collect the `start` offsets of `CallExpression` nodes that appear as
1613
+ * top-level `ExpressionStatement`s in a program body — including inside a
1614
+ * top-level `ExportNamedDeclaration` / `ExportDefaultDeclaration` wrapper.
1615
+ * Used to discriminate top-level statement-form calls from inline nested
1616
+ * calls when `topLevelOnly` is enabled.
1617
+ */
1618
+ const collectTopLevelStatementCallStarts = (
1619
+ ast: AstNode
1620
+ ): ReadonlySet<number> => {
1621
+ const body = (ast as unknown as { body?: readonly AstNode[] }).body ?? [];
1622
+ return new Set(body.flatMap(getTopLevelCallStartsFrom));
1623
+ };
1624
+
1625
+ export interface FindContourDefinitionsOptions {
1626
+ /**
1627
+ * When true, skip contour calls nested inside other expressions (e.g.
1628
+ * `core.contour('inner', {...}).id()` used as a field of an outer contour).
1629
+ * Top-level forms are still surfaced: both `const foo = contour(...)`
1630
+ * declarations and bare `contour('name', {...});` statement-form calls that
1631
+ * appear directly in the program body (optionally wrapped in `export`) are
1632
+ * returned.
1633
+ *
1634
+ * Defaults to `false`: both top-level and inline contours are returned so
1635
+ * that reference-site resolution can reach anonymous inline contours.
1636
+ */
1637
+ readonly topLevelOnly?: boolean;
1638
+ }
1639
+
1640
+ /**
1641
+ * Return every `contour('name', ...)` definition reachable from the AST, in
1642
+ * source order, deduplicated by call-expression start offset.
1643
+ *
1644
+ * Includes both top-level bindings (`const user = contour('user', ...)`) and
1645
+ * inline contour calls nested inside other expressions (e.g.
1646
+ * `contour('outer', { inner: contour('inner', ...).id() })`). Inline contours
1647
+ * carry no `bindingName` because they have no local binding — this asymmetry
1648
+ * is why {@link collectNamedContourIds} returns only the top-level subset
1649
+ * while {@link collectContourDefinitionIds} returns the full set.
1650
+ *
1651
+ * Pass `{ topLevelOnly: true }` via `options` to opt out of inline discovery
1652
+ * without disturbing callers that rely on the default behavior.
1653
+ *
1654
+ * @remarks
1655
+ * Supplying a pre-built `context` skips the second full-AST traversal inside
1656
+ * `buildFrameworkNamespaceContext` — useful for callers (such as
1657
+ * {@link collectContourReferenceSites}) that already built one.
1658
+ */
1659
+ export const findContourDefinitions = (
1660
+ ast: AstNode,
1661
+ context?: FrameworkNamespaceContext,
1662
+ options?: FindContourDefinitionsOptions
1663
+ ): ContourDefinition[] => {
1664
+ const definitions: ContourDefinition[] = [];
1665
+ const seenStarts = new Set<number>();
1666
+ const resolvedContext = context ?? buildFrameworkNamespaceContext(ast);
1667
+ const topLevelOnly = options?.topLevelOnly === true;
1668
+
1669
+ const addContourDefinition = (definition: ContourDefinition): void => {
1670
+ if (seenStarts.has(definition.start)) {
1671
+ return;
1672
+ }
1673
+
1674
+ definitions.push(definition);
1675
+ seenStarts.add(definition.start);
1676
+ };
1677
+
1678
+ const addNamedContourDefinition = (
1679
+ id: AstNode | undefined,
1680
+ init: AstNode | undefined
1681
+ ): void => {
1682
+ if (!init) {
1683
+ return;
1684
+ }
1685
+
1686
+ const definition = extractContourDefinition(init, resolvedContext);
1687
+ if (!definition) {
1688
+ return;
1689
+ }
1690
+
1691
+ const bindingName = extractBindingName(id);
1692
+ if (bindingName) {
1693
+ addContourDefinition({ ...definition, bindingName });
1694
+ return;
1695
+ }
1696
+
1697
+ addContourDefinition(definition);
1698
+ };
1699
+
1700
+ // When `topLevelOnly` is set, collect the start offsets of call expressions
1701
+ // that sit directly in the program body as `ExpressionStatement`s (optionally
1702
+ // wrapped in `export`). These are top-level statement-form contour calls and
1703
+ // should still surface alongside `VariableDeclarator` bindings; only calls
1704
+ // nested inside other expressions are excluded.
1705
+ const topLevelStatementCallStarts = topLevelOnly
1706
+ ? collectTopLevelStatementCallStarts(ast)
1707
+ : null;
1708
+
1709
+ walk(ast, (node) => {
1710
+ if (node.type === 'VariableDeclarator') {
1711
+ const { id, init } = node as unknown as {
1712
+ readonly id?: AstNode;
1713
+ readonly init?: AstNode;
1714
+ };
1715
+ addNamedContourDefinition(id, init);
1716
+ return;
1717
+ }
1718
+
1719
+ if (
1720
+ topLevelStatementCallStarts &&
1721
+ !topLevelStatementCallStarts.has(node.start)
1722
+ ) {
1723
+ return;
1724
+ }
1725
+
1726
+ const definition = extractContourDefinition(node, resolvedContext);
1727
+ if (definition) {
1728
+ addContourDefinition(definition);
1729
+ }
1730
+ });
1731
+
1732
+ return definitions.toSorted((left, right) => left.start - right.start);
1733
+ };
1734
+
1735
+ /**
1736
+ * Collect the `name` of every contour definition in a parsed file, including
1737
+ * inline contours nested inside other expressions. Returns the same set of
1738
+ * names that {@link findContourDefinitions} discovers under default options.
1739
+ */
1740
+ export const collectContourDefinitionIds = (
1741
+ ast: AstNode
1742
+ ): ReadonlySet<string> =>
1743
+ new Set(findContourDefinitions(ast).map((def) => def.name));
1744
+
1745
+ /**
1746
+ * Collect the `localBinding → contourName` map for `const foo = contour(...)`
1747
+ * declarations. Inline contour calls are intentionally excluded because they
1748
+ * have no local binding — use {@link collectContourDefinitionIds} when the
1749
+ * full set of declared names is required.
1750
+ */
1751
+ export const collectNamedContourIds = (
1752
+ ast: AstNode
1753
+ ): ReadonlyMap<string, string> => {
1754
+ const ids = new Map<string, string>();
1755
+
1756
+ for (const def of findContourDefinitions(ast)) {
1757
+ if (def.bindingName) {
1758
+ ids.set(def.bindingName, def.name);
1759
+ }
1760
+ }
1761
+
1762
+ return ids;
1763
+ };
1764
+
1765
+ const resolveNamedImportedName = (
1766
+ specifier: AstNode,
1767
+ localName: string
1768
+ ): string => {
1769
+ const { imported } = specifier as unknown as { imported?: AstNode };
1770
+ const importedName = imported
1771
+ ? (identifierName(imported) ?? extractStringLiteral(imported))
1772
+ : null;
1773
+ return importedName ?? localName;
1774
+ };
1775
+
1776
+ const extractImportSpecifierAlias = (
1777
+ specifier: AstNode
1778
+ ): { readonly localName: string; readonly importedName: string } | null => {
1779
+ if (
1780
+ specifier.type !== 'ImportSpecifier' &&
1781
+ specifier.type !== 'ImportDefaultSpecifier'
1782
+ ) {
1783
+ return null;
1784
+ }
1785
+
1786
+ const { local } = specifier as unknown as { local?: AstNode };
1787
+ const localName = identifierName(local);
1788
+ if (!localName) {
1789
+ return null;
1790
+ }
1791
+
1792
+ // Default imports bind the default export of the source module to the local
1793
+ // name. We cannot statically recover the exported name without cross-file
1794
+ // analysis, so the local name is the best identifier we have for resolving
1795
+ // against `knownContourIds`. Treat the alias as an identity mapping; the
1796
+ // downstream resolver will fall through to `knownContourIds` on the binding
1797
+ // name and report it as missing when not found.
1798
+ if (specifier.type === 'ImportDefaultSpecifier') {
1799
+ return { importedName: localName, localName };
1800
+ }
1801
+
1802
+ return {
1803
+ importedName: resolveNamedImportedName(specifier, localName),
1804
+ localName,
1805
+ };
1806
+ };
1807
+
1808
+ /**
1809
+ * Collect `import { foo as bar } from '...'` and `import bar from '...'`
1810
+ * specifier mappings keyed by local binding name. The value is the original
1811
+ * exported name for named imports. Default imports map to themselves because
1812
+ * the exported name cannot be recovered statically — callers should fall
1813
+ * through to `knownContourIds` membership on the local binding name.
1814
+ */
1815
+ export const collectImportAliasMap = (
1816
+ ast: AstNode
1817
+ ): ReadonlyMap<string, string> => {
1818
+ const aliases = new Map<string, string>();
1819
+
1820
+ walk(ast, (node) => {
1821
+ if (node.type !== 'ImportDeclaration') {
1822
+ return;
1823
+ }
1824
+
1825
+ const specifiers =
1826
+ (node['specifiers'] as readonly AstNode[] | undefined) ?? [];
1827
+ for (const specifier of specifiers) {
1828
+ const alias = extractImportSpecifierAlias(specifier);
1829
+ if (alias) {
1830
+ aliases.set(alias.localName, alias.importedName);
1831
+ }
1832
+ }
1833
+ });
1834
+
1835
+ return aliases;
1836
+ };
1837
+
1838
+ const addUserNamespaceBindingsFromDeclaration = (
1839
+ node: AstNode,
1840
+ into: Set<string>
1841
+ ): void => {
1842
+ if (isFrameworkNamespaceSource(getImportSourceValue(node))) {
1843
+ return;
1844
+ }
1845
+ const specifiers =
1846
+ (node['specifiers'] as readonly AstNode[] | undefined) ?? [];
1847
+ for (const specifier of specifiers) {
1848
+ if (specifier.type !== 'ImportNamespaceSpecifier') {
1849
+ continue;
1850
+ }
1851
+ const { local } = specifier as unknown as { local?: AstNode };
1852
+ const localName = identifierName(local);
1853
+ if (localName) {
1854
+ into.add(localName);
1855
+ }
1856
+ }
1857
+ };
1858
+
1859
+ /**
1860
+ * Collect local binding names introduced by `import * as <name> from '<src>'`
1861
+ * declarations whose source is NOT an `@ontrails/*` framework package. These
1862
+ * are user-defined namespace imports of contour modules (e.g. `import * as
1863
+ * contours from './contours'`), used to resolve `contours.user` member-access
1864
+ * references to contour ids.
1865
+ *
1866
+ * Framework namespace imports (`import * as core from '@ontrails/core'`) are
1867
+ * intentionally excluded — they carry framework primitives like
1868
+ * `core.contour(...)` and are resolved by {@link buildFrameworkNamespaceContext}.
1869
+ * Mixing them here would treat `core.contour` as a reference to a contour
1870
+ * named "contour", producing false positives.
1871
+ */
1872
+ export const collectUserNamespaceImportBindings = (
1873
+ ast: AstNode
1874
+ ): ReadonlySet<string> => {
1875
+ const bindings = new Set<string>();
1876
+
1877
+ walk(ast, (node) => {
1878
+ if (node.type !== 'ImportDeclaration') {
1879
+ return;
1880
+ }
1881
+ addUserNamespaceBindingsFromDeclaration(node, bindings);
1882
+ });
1883
+
1884
+ return bindings;
1885
+ };
1886
+
1887
+ /**
1888
+ * Resolution context for user-namespace member access like `contours.user`.
1889
+ * Bundles the set of local namespace-binding names (from `import * as x from
1890
+ * './contours'`) with an optional set of proven-safe `MemberExpression` start
1891
+ * offsets from a scope-aware pre-pass. When `safeMemberStarts` is present, a
1892
+ * member access only resolves to a user-namespace target if its start is in
1893
+ * the set — so a function-local shadow of the namespace import does not leak
1894
+ * through. When absent, the name-only gate is used as a
1895
+ * backward-compatible fallback for ad-hoc callers.
1896
+ */
1897
+ export interface UserNamespaceContext {
1898
+ readonly bindings: ReadonlySet<string>;
1899
+ readonly safeMemberStarts?: ReadonlySet<number>;
1900
+ }
1901
+
1902
+ /**
1903
+ * Walk the AST with a scope stack and collect `MemberExpression` start offsets
1904
+ * whose receiver is a user-namespace binding that is NOT shadowed by any
1905
+ * enclosing scope. Mirrors `collectFrameworkNamespacedCallStarts` for the
1906
+ * framework-namespace path so `contours.user` inside
1907
+ * `function f(contours) { ... }` is rejected as shadowed.
1908
+ */
1909
+ /**
1910
+ * Return the receiver-identifier name of a non-computed member access, or
1911
+ * `null` for any other node shape (computed access, non-member, etc.).
1912
+ */
1913
+ const getNonComputedMemberReceiver = (node: AstNode): string | null => {
1914
+ if (!isMemberAccessNonComputed(node)) {
1915
+ return null;
1916
+ }
1917
+ const { object } = node as unknown as { object?: AstNode };
1918
+ return object ? identifierName(object) : null;
1919
+ };
1920
+
1921
+ const collectUserNamespacedMemberStarts = (
1922
+ ast: AstNode,
1923
+ bindings: ReadonlySet<string>
1924
+ ): ReadonlySet<number> => {
1925
+ const starts = new Set<number>();
1926
+ if (bindings.size === 0) {
1927
+ return starts;
1928
+ }
1929
+
1930
+ walkWithScopes(ast, (node, scopes) => {
1931
+ const receiver = getNonComputedMemberReceiver(node);
1932
+ if (!receiver || !bindings.has(receiver) || isShadowed(receiver, scopes)) {
1933
+ return;
1934
+ }
1935
+ starts.add(node.start);
1936
+ });
1937
+
1938
+ return starts;
1939
+ };
1940
+
1941
+ /**
1942
+ * Build a {@link UserNamespaceContext} for `ast`, including the scope-aware
1943
+ * `safeMemberStarts` gate. Prefer this over bare
1944
+ * {@link collectUserNamespaceImportBindings} so member access like
1945
+ * `contours.user` is rejected when `contours` is shadowed by a local binding.
1946
+ */
1947
+ export const buildUserNamespaceContext = (
1948
+ ast: AstNode
1949
+ ): UserNamespaceContext => {
1950
+ const bindings = collectUserNamespaceImportBindings(ast);
1951
+ return {
1952
+ bindings,
1953
+ safeMemberStarts: collectUserNamespacedMemberStarts(ast, bindings),
1954
+ };
1955
+ };
1956
+
1957
+ export interface ContourReferenceSite {
1958
+ /** Field on the source contour that declares the reference. */
1959
+ readonly field: string;
1960
+ /** Source contour name. */
1961
+ readonly source: string;
1962
+ /** Start offset of the field declaration. */
1963
+ readonly start: number;
1964
+ /** Target contour name. */
1965
+ readonly target: string;
1966
+ }
1967
+
1968
+ /**
1969
+ * Read a property key or member access identifier.
1970
+ *
1971
+ * Returns the identifier name for `Identifier` keys, or the underlying
1972
+ * string literal value for computed access via `['name']` / `"name"`.
1973
+ */
1974
+ export const getPropertyName = (node: unknown): string | null => {
1975
+ if (typeof node !== 'object' || node === null) {
1976
+ return null;
1977
+ }
1978
+
1979
+ const { name } = node as { readonly name?: unknown };
1980
+ if (typeof name === 'string') {
1981
+ return name;
1982
+ }
1983
+
1984
+ return isAstNode(node) ? extractStringLiteral(node) : null;
1985
+ };
1986
+
1987
+ const stripContourSuffix = (name: string): string => {
1988
+ const suffix = 'Contour';
1989
+ return name.endsWith(suffix) ? name.slice(0, -suffix.length) : name;
1990
+ };
1991
+
1992
+ const resolveKnownContourName = (
1993
+ name: string,
1994
+ knownContourIds?: ReadonlySet<string>
1995
+ ): string | null => {
1996
+ if (knownContourIds?.has(name)) {
1997
+ return name;
1998
+ }
1999
+
2000
+ // Support the common `const userContour = contour('user', ...)` naming
2001
+ // pattern when callers refer to the binding name instead of the contour ID.
2002
+ // Exact matches always win; suffix stripping is a fallback only.
2003
+ const stripped = stripContourSuffix(name);
2004
+ if (stripped !== name && knownContourIds?.has(stripped)) {
2005
+ return stripped;
2006
+ }
2007
+
2008
+ return null;
2009
+ };
2010
+
2011
+ /**
2012
+ * Resolve a local binding name to a contour ID, honoring import aliases.
2013
+ *
2014
+ * Strategies, in order:
2015
+ * 1. Local `const foo = contour('name', ...)` binding → the contour name.
2016
+ * 2. `knownContourIds` membership on the binding name itself (or the
2017
+ * conventional `Contour` suffix strip).
2018
+ * 3. `import { foo as bar }` → use the original exported name `foo`
2019
+ * (and apply strategy 2 / suffix-stripping against it so aliased imports
2020
+ * resolve correctly). If the imported name still isn't recognized, the
2021
+ * imported name is returned so the caller can report it missing.
2022
+ *
2023
+ * Returns `null` only when the name belongs to no known resolution path —
2024
+ * no local binding, no known contour ID, no import, and no suffix match.
2025
+ * Returning `null` means "this identifier is not a contour reference we can
2026
+ * reason about" (e.g. a bare undeclared variable), as opposed to
2027
+ * "a contour reference whose target is missing".
2028
+ */
2029
+ export const deriveContourIdentifierName = (
2030
+ bindingName: string,
2031
+ namedContourIds: ReadonlyMap<string, string>,
2032
+ knownContourIds?: ReadonlySet<string>,
2033
+ importAliases?: ReadonlyMap<string, string>
2034
+ ): string | null => {
2035
+ const localName = namedContourIds.get(bindingName);
2036
+ if (localName) {
2037
+ return localName;
2038
+ }
2039
+
2040
+ const known = resolveKnownContourName(bindingName, knownContourIds);
2041
+ if (known) {
2042
+ return known;
2043
+ }
2044
+
2045
+ // If the binding came from an import, use the original exported name as
2046
+ // the resolution target. This lets `import { foo as bar }` resolve to
2047
+ // the exported `foo` rather than the local alias `bar`. If the imported
2048
+ // name still isn't recognized, return it so callers can report it as
2049
+ // missing under its original name.
2050
+ const importedName = importAliases?.get(bindingName);
2051
+ if (importedName) {
2052
+ return (
2053
+ resolveKnownContourName(importedName, knownContourIds) ?? importedName
2054
+ );
2055
+ }
2056
+
2057
+ return null;
2058
+ };
2059
+
2060
+ const getContourReferenceMember = (
2061
+ node: AstNode
2062
+ ): {
2063
+ readonly object?: AstNode;
2064
+ readonly property?: AstNode;
2065
+ readonly start: number;
2066
+ } | null => {
2067
+ if (
2068
+ node.type !== 'MemberExpression' &&
2069
+ node.type !== 'StaticMemberExpression'
2070
+ ) {
2071
+ return null;
2072
+ }
2073
+
2074
+ return node as unknown as {
2075
+ readonly object?: AstNode;
2076
+ readonly property?: AstNode;
2077
+ readonly start: number;
2078
+ };
2079
+ };
2080
+
2081
+ const asUserNamespaceContext = (
2082
+ input: ReadonlySet<string> | UserNamespaceContext | undefined
2083
+ ): UserNamespaceContext | undefined => {
2084
+ if (!input) {
2085
+ return undefined;
2086
+ }
2087
+ return input instanceof Set
2088
+ ? { bindings: input }
2089
+ : (input as UserNamespaceContext);
2090
+ };
2091
+
2092
+ /**
2093
+ * Resolve a user-namespace member access like `contours.user` to its contour
2094
+ * id. Returns the property name (e.g. `'user'`) when the receiver identifier
2095
+ * is a known user-defined namespace binding AND — when the caller provides a
2096
+ * {@link UserNamespaceContext} with `safeMemberStarts` — the member access
2097
+ * site is in that set (i.e. the receiver is not shadowed by any enclosing
2098
+ * scope). Otherwise returns `null`.
2099
+ *
2100
+ * The property name is taken as the contour id verbatim — we cannot statically
2101
+ * resolve what `contours.user` binds to without reading the other file, so we
2102
+ * treat the member name as the candidate target and let
2103
+ * {@link deriveContourIdentifierName}'s downstream `knownContourIds` check
2104
+ * report a missing target.
2105
+ */
2106
+ export const isUserNamespaceReceiverAllowed = (
2107
+ receiver: string,
2108
+ memberStart: number,
2109
+ ctx: UserNamespaceContext
2110
+ ): boolean => {
2111
+ if (!ctx.bindings.has(receiver)) {
2112
+ return false;
2113
+ }
2114
+ // Scope-aware gate: when the pre-pass produced a set, the member access
2115
+ // must appear in it. Without the set, fall back to the bare name check.
2116
+ return ctx.safeMemberStarts ? ctx.safeMemberStarts.has(memberStart) : true;
2117
+ };
2118
+
2119
+ const getContourReferenceTargetFromNamespaceMember = (
2120
+ member: {
2121
+ readonly object?: AstNode;
2122
+ readonly property?: AstNode;
2123
+ readonly start: number;
2124
+ },
2125
+ userNamespace?: ReadonlySet<string> | UserNamespaceContext
2126
+ ): string | null => {
2127
+ const ctx = asUserNamespaceContext(userNamespace);
2128
+ if (!ctx || ctx.bindings.size === 0) {
2129
+ return null;
2130
+ }
2131
+ const receiver = member.object ? identifierName(member.object) : null;
2132
+ if (
2133
+ !receiver ||
2134
+ !isUserNamespaceReceiverAllowed(receiver, member.start, ctx)
2135
+ ) {
2136
+ return null;
2137
+ }
2138
+ const { property } = member;
2139
+ if (!property || property.type !== 'Identifier') {
2140
+ return null;
2141
+ }
2142
+ return identifierName(property);
2143
+ };
2144
+
2145
+ const getContourReferenceTargetFromObject = (
2146
+ object: AstNode,
2147
+ namedContourIds: ReadonlyMap<string, string>,
2148
+ knownContourIds?: ReadonlySet<string>,
2149
+ importAliases?: ReadonlyMap<string, string>,
2150
+ context?: ReadonlySet<string> | FrameworkNamespaceContext,
2151
+ userNamespace?: ReadonlySet<string> | UserNamespaceContext
2152
+ ): string | null => {
2153
+ if (object.type === 'Identifier') {
2154
+ const bindingName = identifierName(object);
2155
+ return bindingName
2156
+ ? deriveContourIdentifierName(
2157
+ bindingName,
2158
+ namedContourIds,
2159
+ knownContourIds,
2160
+ importAliases
2161
+ )
2162
+ : null;
2163
+ }
2164
+
2165
+ const member = getContourReferenceMember(object);
2166
+ if (member) {
2167
+ const namespaceTarget = getContourReferenceTargetFromNamespaceMember(
2168
+ member,
2169
+ userNamespace
2170
+ );
2171
+ if (namespaceTarget) {
2172
+ return namespaceTarget;
2173
+ }
2174
+ }
2175
+
2176
+ return extractContourDefinition(object, context)?.name ?? null;
2177
+ };
2178
+
2179
+ const CONTOUR_ID_WRAPPER_METHODS = new Set([
2180
+ 'brand',
2181
+ 'catch',
2182
+ 'default',
2183
+ 'describe',
2184
+ 'meta',
2185
+ 'nullable',
2186
+ 'nullish',
2187
+ 'optional',
2188
+ 'readonly',
2189
+ ]);
2190
+
2191
+ const getContourIdCallMember = (
2192
+ node: AstNode
2193
+ ): {
2194
+ readonly member: NonNullable<ReturnType<typeof getContourReferenceMember>>;
2195
+ readonly propertyName: string;
2196
+ } | null => {
2197
+ const callee = node['callee'] as AstNode | undefined;
2198
+ const member = callee ? getContourReferenceMember(callee) : null;
2199
+ const propertyName = member ? identifierName(member.property) : null;
2200
+ return member && propertyName ? { member, propertyName } : null;
2201
+ };
2202
+
2203
+ const getContourIdCallObject = function getContourIdCallObject(
2204
+ node: AstNode | undefined
2205
+ ): AstNode | null {
2206
+ const current = node;
2207
+ if (!current || current.type !== 'CallExpression') {
2208
+ return null;
2209
+ }
2210
+
2211
+ const member = getContourIdCallMember(current);
2212
+ if (!member) {
2213
+ return null;
2214
+ }
2215
+ if (member.propertyName === 'id') {
2216
+ return member.member.object ?? null;
2217
+ }
2218
+
2219
+ return CONTOUR_ID_WRAPPER_METHODS.has(member.propertyName)
2220
+ ? getContourIdCallObject(member.member.object)
2221
+ : null;
2222
+ };
2223
+
2224
+ const extractContourReferenceTarget = (
2225
+ node: AstNode | undefined,
2226
+ namedContourIds: ReadonlyMap<string, string>,
2227
+ knownContourIds?: ReadonlySet<string>,
2228
+ importAliases?: ReadonlyMap<string, string>,
2229
+ context?: ReadonlySet<string> | FrameworkNamespaceContext,
2230
+ userNamespace?: ReadonlySet<string> | UserNamespaceContext
2231
+ ): string | null => {
2232
+ const object = getContourIdCallObject(node);
2233
+ return object
2234
+ ? getContourReferenceTargetFromObject(
2235
+ object,
2236
+ namedContourIds,
2237
+ knownContourIds,
2238
+ importAliases,
2239
+ context,
2240
+ userNamespace
2241
+ )
2242
+ : null;
2243
+ };
2244
+
2245
+ const getContourShapeProperties = (
2246
+ definition: ContourDefinition
2247
+ ): readonly AstNode[] =>
2248
+ (definition.shape['properties'] as readonly AstNode[] | undefined) ?? [];
2249
+
2250
+ const buildContourReferenceSite = (
2251
+ definition: ContourDefinition,
2252
+ property: AstNode,
2253
+ namedContourIds: ReadonlyMap<string, string>,
2254
+ knownContourIds?: ReadonlySet<string>,
2255
+ importAliases?: ReadonlyMap<string, string>,
2256
+ context?: ReadonlySet<string> | FrameworkNamespaceContext,
2257
+ userNamespace?: ReadonlySet<string> | UserNamespaceContext
2258
+ ): ContourReferenceSite | null => {
2259
+ if (property.type !== 'Property') {
2260
+ return null;
2261
+ }
2262
+
2263
+ const field = getPropertyName(property.key);
2264
+ const target = extractContourReferenceTarget(
2265
+ property.value as AstNode | undefined,
2266
+ namedContourIds,
2267
+ knownContourIds,
2268
+ importAliases,
2269
+ context,
2270
+ userNamespace
2271
+ );
2272
+ if (!field || !target) {
2273
+ return null;
2274
+ }
2275
+
2276
+ return {
2277
+ field,
2278
+ source: definition.name,
2279
+ start: property.start,
2280
+ target,
2281
+ };
2282
+ };
2283
+
2284
+ const findContourReferenceSitesForDefinition = (
2285
+ definition: ContourDefinition,
2286
+ namedContourIds: ReadonlyMap<string, string>,
2287
+ knownContourIds?: ReadonlySet<string>,
2288
+ importAliases?: ReadonlyMap<string, string>,
2289
+ context?: ReadonlySet<string> | FrameworkNamespaceContext,
2290
+ userNamespace?: ReadonlySet<string> | UserNamespaceContext
2291
+ ): readonly ContourReferenceSite[] =>
2292
+ getContourShapeProperties(definition).flatMap((property) => {
2293
+ const reference = buildContourReferenceSite(
2294
+ definition,
2295
+ property,
2296
+ namedContourIds,
2297
+ knownContourIds,
2298
+ importAliases,
2299
+ context,
2300
+ userNamespace
2301
+ );
2302
+ return reference ? [reference] : [];
2303
+ });
2304
+
2305
+ /** Collect all contour field references declared via `.id()` in a parsed file. */
2306
+ export const collectContourReferenceSites = (
2307
+ ast: AstNode,
2308
+ knownContourIds?: ReadonlySet<string>
2309
+ ): readonly ContourReferenceSite[] => {
2310
+ const namedContourIds = collectNamedContourIds(ast);
2311
+ const importAliases = collectImportAliasMap(ast);
2312
+ const userNamespace = buildUserNamespaceContext(ast);
2313
+ const context = buildFrameworkNamespaceContext(ast);
2314
+ return findContourDefinitions(ast, context).flatMap((definition) =>
2315
+ findContourReferenceSitesForDefinition(
2316
+ definition,
2317
+ namedContourIds,
2318
+ knownContourIds,
2319
+ importAliases,
2320
+ context,
2321
+ userNamespace
2322
+ )
2323
+ );
2324
+ };
2325
+
2326
+ /** Collect contour reference targets keyed by source contour name. */
2327
+ export const collectContourReferenceTargetsByName = (
2328
+ ast: AstNode,
2329
+ knownContourIds?: ReadonlySet<string>
2330
+ ): ReadonlyMap<string, readonly string[]> => {
2331
+ const targetsByName = new Map<string, Set<string>>();
2332
+
2333
+ for (const reference of collectContourReferenceSites(ast, knownContourIds)) {
2334
+ const existing = targetsByName.get(reference.source);
2335
+ if (existing) {
2336
+ existing.add(reference.target);
2337
+ continue;
2338
+ }
2339
+
2340
+ targetsByName.set(reference.source, new Set([reference.target]));
2341
+ }
2342
+
2343
+ return new Map(
2344
+ [...targetsByName.entries()].map(([name, targets]) => [name, [...targets]])
2345
+ );
2346
+ };
2347
+
2348
+ // ---------------------------------------------------------------------------
2349
+ // Blaze body extraction
2350
+ // ---------------------------------------------------------------------------
2351
+
2352
+ /**
2353
+ * Extract top-level `blaze:` property values from an ObjectExpression's direct properties.
2354
+ *
2355
+ * Does not recurse into nested objects, so `meta: { blaze: ... }` is ignored.
2356
+ */
2357
+ const extractBlazeFromConfig = (config: AstNode): AstNode[] => {
2358
+ const bodies: AstNode[] = [];
2359
+ const properties = config['properties'] as readonly AstNode[] | undefined;
2360
+ if (!properties) {
2361
+ return bodies;
2362
+ }
2363
+ for (const prop of properties) {
2364
+ if (
2365
+ prop.type === 'Property' &&
2366
+ prop.key?.name === 'blaze' &&
2367
+ isAstNode(prop.value)
2368
+ ) {
2369
+ bodies.push(prop.value);
2370
+ }
2371
+ }
2372
+ return bodies;
2373
+ };
2374
+
2375
+ /**
2376
+ * Find `blaze:` property values.
2377
+ *
2378
+ * When given an ObjectExpression (trail config), returns only its direct `blaze:`
2379
+ * properties. When given a full AST, finds trail definitions first and extracts
2380
+ * `blaze:` from each config — in both cases ignoring nested `blaze:` properties
2381
+ * (e.g. `meta: { blaze: ... }`).
2382
+ */
2383
+ export const findBlazeBodies = (node: AstNode): AstNode[] => {
2384
+ if (node.type === 'ObjectExpression') {
2385
+ return extractBlazeFromConfig(node);
2386
+ }
2387
+
2388
+ // Full AST — find trail definitions and extract blaze from their configs
2389
+ const bodies: AstNode[] = [];
2390
+ for (const def of findTrailDefinitions(node)) {
2391
+ bodies.push(...extractBlazeFromConfig(def.config));
2392
+ }
2393
+ return bodies;
2394
+ };
2395
+
2396
+ /**
2397
+ * Collect all `signal('id', { ... })` / `signal({ id: 'x', ... })` definition IDs.
2398
+ *
2399
+ * Uses `findTrailDefinitions` under the hood — it already recognizes both
2400
+ * `trail` and `signal` call sites, distinguished by the `kind` field.
2401
+ */
2402
+ export const collectSignalDefinitionIds = (
2403
+ ast: AstNode
2404
+ ): ReadonlySet<string> => {
2405
+ const ids = new Set<string>();
2406
+ for (const def of findTrailDefinitions(ast)) {
2407
+ if (def.kind === 'signal') {
2408
+ ids.add(def.id);
2409
+ }
2410
+ }
2411
+ return ids;
2412
+ };
2413
+
2414
+ const unwrapTopLevelDeclaration = (stmt: AstNode): AstNode => {
2415
+ if (
2416
+ stmt.type === 'ExportNamedDeclaration' ||
2417
+ stmt.type === 'ExportDefaultDeclaration'
2418
+ ) {
2419
+ return (stmt as unknown as { declaration?: AstNode }).declaration ?? stmt;
2420
+ }
2421
+ return stmt;
2422
+ };
2423
+
2424
+ const collectSignalIdsFromDeclaration = (
2425
+ declaration: AstNode,
2426
+ context: FrameworkNamespaceContext,
2427
+ ids: Map<string, string>
2428
+ ): void => {
2429
+ const declarations =
2430
+ (
2431
+ unwrapTopLevelDeclaration(declaration) as unknown as {
2432
+ declarations?: readonly AstNode[];
2433
+ }
2434
+ ).declarations ?? [];
2435
+
2436
+ for (const node of declarations) {
2437
+ const { id, init } = node as unknown as {
2438
+ readonly id?: AstNode;
2439
+ readonly init?: AstNode;
2440
+ };
2441
+ if (!init) {
2442
+ continue;
2443
+ }
2444
+
2445
+ const def = extractTrailDefinition(init, context);
2446
+ const name = extractBindingName(id);
2447
+ if (def?.kind === 'signal' && name && !ids.has(name)) {
2448
+ ids.set(name, def.id);
2449
+ }
2450
+ }
2451
+ };
2452
+
2453
+ const collectStringIdsFromDeclaration = (
2454
+ declaration: AstNode,
2455
+ ids: Map<string, string>
2456
+ ): void => {
2457
+ const declarations =
2458
+ (
2459
+ unwrapTopLevelDeclaration(declaration) as unknown as {
2460
+ declarations?: readonly AstNode[];
2461
+ }
2462
+ ).declarations ?? [];
2463
+
2464
+ for (const node of declarations) {
2465
+ const { id, init } = node as unknown as {
2466
+ readonly id?: AstNode;
2467
+ readonly init?: AstNode;
2468
+ };
2469
+ if (!init) {
2470
+ continue;
2471
+ }
2472
+
2473
+ const name = extractBindingName(id);
2474
+ const value =
2475
+ extractStringLiteral(init) ?? extractPlainTemplateLiteral(init);
2476
+ if (name && value !== null && !ids.has(name)) {
2477
+ ids.set(name, value);
2478
+ }
2479
+ }
2480
+ };
2481
+
2482
+ export type SignalIdentifierResolution =
2483
+ | {
2484
+ readonly id: string;
2485
+ readonly kind: 'signal' | 'string';
2486
+ }
2487
+ | {
2488
+ readonly kind: 'shadowed' | 'unbound';
2489
+ };
2490
+
2491
+ export interface SignalIdentifierResolver {
2492
+ readonly resolve: (reference: AstNode) => SignalIdentifierResolution;
2493
+ }
2494
+
2495
+ interface SignalScopeFrame {
2496
+ readonly bindings: ReadonlySet<string>;
2497
+ readonly end: number;
2498
+ readonly signals: ReadonlyMap<string, string>;
2499
+ readonly start: number;
2500
+ readonly strings: ReadonlyMap<string, string>;
2501
+ }
2502
+
2503
+ const collectSignalFrameValues = (
2504
+ node: AstNode,
2505
+ context: FrameworkNamespaceContext
2506
+ ): {
2507
+ readonly signals: ReadonlyMap<string, string>;
2508
+ readonly strings: ReadonlyMap<string, string>;
2509
+ } => {
2510
+ const signals = new Map<string, string>();
2511
+ const strings = new Map<string, string>();
2512
+
2513
+ const collectDeclaration = (statement: AstNode): void => {
2514
+ const declaration = unwrapTopLevelDeclaration(statement);
2515
+ if (declaration.type !== 'VariableDeclaration') {
2516
+ return;
2517
+ }
2518
+ collectSignalIdsFromDeclaration(declaration, context, signals);
2519
+ collectStringIdsFromDeclaration(declaration, strings);
2520
+ };
2521
+
2522
+ if (
2523
+ node.type === 'Program' ||
2524
+ node.type === 'BlockStatement' ||
2525
+ node.type === 'FunctionBody'
2526
+ ) {
2527
+ const body = (node as unknown as { body?: readonly AstNode[] }).body ?? [];
2528
+ for (const statement of body) {
2529
+ collectDeclaration(statement);
2530
+ }
2531
+ }
2532
+
2533
+ if (node.type === 'ForStatement') {
2534
+ const { init } = node as unknown as { init?: AstNode };
2535
+ if (init) {
2536
+ collectDeclaration(init);
2537
+ }
2538
+ }
2539
+
2540
+ if (node.type === 'SwitchStatement') {
2541
+ const cases =
2542
+ (node as unknown as { cases?: readonly AstNode[] }).cases ?? [];
2543
+ for (const item of cases) {
2544
+ const consequent =
2545
+ (item as unknown as { consequent?: readonly AstNode[] }).consequent ??
2546
+ [];
2547
+ for (const statement of consequent) {
2548
+ collectDeclaration(statement);
2549
+ }
2550
+ }
2551
+ }
2552
+
2553
+ return { signals, strings };
2554
+ };
2555
+
2556
+ const collectSignalScopeFrames = (
2557
+ ast: AstNode,
2558
+ context: FrameworkNamespaceContext
2559
+ ): readonly SignalScopeFrame[] => {
2560
+ const frames: SignalScopeFrame[] = [];
2561
+
2562
+ walk(ast, (node) => {
2563
+ if (!(node.type in SCOPE_FRAME_COLLECTORS)) {
2564
+ return;
2565
+ }
2566
+ const values = collectSignalFrameValues(node, context);
2567
+ frames.push({
2568
+ bindings: collectScopeFrameBindings(node),
2569
+ end: node.end,
2570
+ signals: values.signals,
2571
+ start: node.start,
2572
+ strings: values.strings,
2573
+ });
2574
+ });
2575
+
2576
+ return frames;
2577
+ };
2578
+
2579
+ const isInsideFrame = (reference: AstNode, frame: SignalScopeFrame): boolean =>
2580
+ frame.start <= reference.start && reference.end <= frame.end;
2581
+
2582
+ const compareInnermostFrame = (
2583
+ a: SignalScopeFrame,
2584
+ b: SignalScopeFrame
2585
+ ): number => {
2586
+ const aSize = a.end - a.start;
2587
+ const bSize = b.end - b.start;
2588
+ return aSize - bSize || b.start - a.start;
2589
+ };
2590
+
2591
+ export const buildSignalIdentifierResolver = (
2592
+ ast: AstNode
2593
+ ): SignalIdentifierResolver => {
2594
+ const context = buildFrameworkNamespaceContext(ast);
2595
+ const frames = collectSignalScopeFrames(ast, context);
2596
+
2597
+ return {
2598
+ resolve(reference: AstNode): SignalIdentifierResolution {
2599
+ const name = identifierName(reference);
2600
+ if (!name) {
2601
+ return { kind: 'unbound' };
2602
+ }
2603
+
2604
+ const containingFrames = frames
2605
+ .filter((frame) => isInsideFrame(reference, frame))
2606
+ .toSorted(compareInnermostFrame);
2607
+
2608
+ for (const frame of containingFrames) {
2609
+ if (!frame.bindings.has(name)) {
2610
+ continue;
2611
+ }
2612
+ const signalId = frame.signals.get(name);
2613
+ if (signalId) {
2614
+ return { id: signalId, kind: 'signal' };
2615
+ }
2616
+ const stringId = frame.strings.get(name);
2617
+ if (stringId) {
2618
+ return { id: stringId, kind: 'string' };
2619
+ }
2620
+ return { kind: 'shadowed' };
2621
+ }
2622
+
2623
+ return { kind: 'unbound' };
2624
+ },
2625
+ };
2626
+ };
2627
+
2628
+ /** Collect `const foo = trail('id', ...)` bindings from a parsed file. */
2629
+ export const collectNamedTrailIds = (
2630
+ ast: AstNode
2631
+ ): ReadonlyMap<string, string> => {
2632
+ const ids = new Map<string, string>();
2633
+ const context = buildFrameworkNamespaceContext(ast);
2634
+
2635
+ walk(ast, (node) => {
2636
+ if (node.type !== 'VariableDeclarator') {
2637
+ return;
2638
+ }
2639
+
2640
+ const { id, init } = node as unknown as {
2641
+ readonly id?: AstNode;
2642
+ readonly init?: AstNode;
2643
+ };
2644
+ if (!init) {
2645
+ return;
2646
+ }
2647
+
2648
+ const def = extractTrailDefinition(init, context);
2649
+ const name = extractBindingName(id);
2650
+ if (def?.kind === 'trail' && name) {
2651
+ ids.set(name, def.id);
2652
+ }
2653
+ });
2654
+
2655
+ return ids;
2656
+ };
2657
+
2658
+ /** Extract the raw `crosses: [...]` array elements from a trail config. */
2659
+ export const getCrossElements = (config: AstNode): readonly AstNode[] => {
2660
+ const crossesProp = findConfigProperty(config, 'crosses');
2661
+ if (!crossesProp) {
2662
+ return [];
2663
+ }
2664
+
2665
+ const arrayNode = crossesProp.value;
2666
+ if (!arrayNode || (arrayNode as AstNode).type !== 'ArrayExpression') {
2667
+ return [];
2668
+ }
2669
+
2670
+ const elements = (arrayNode as AstNode)['elements'] as
2671
+ | readonly AstNode[]
2672
+ | undefined;
2673
+ return elements ?? [];
2674
+ };
2675
+
2676
+ /**
2677
+ * Resolve a single `crosses: [...]` element to its target trail ID.
2678
+ *
2679
+ * Handles string literals, identifier references (via `namedTrailIds` map or
2680
+ * `const NAME = '...'` resolution), and inline `trail(...)` call expressions.
2681
+ */
2682
+ export const deriveCrossElementId = (
2683
+ element: AstNode,
2684
+ sourceCode: string,
2685
+ namedTrailIds: ReadonlyMap<string, string>
2686
+ ): string | null => {
2687
+ if (isStringLiteral(element)) {
2688
+ return getStringValue(element);
2689
+ }
2690
+
2691
+ if (element.type === 'Identifier') {
2692
+ const name = identifierName(element);
2693
+ return name
2694
+ ? (namedTrailIds.get(name) ?? deriveConstString(name, sourceCode))
2695
+ : null;
2696
+ }
2697
+
2698
+ const inlineDef = extractTrailDefinition(element);
2699
+ return inlineDef?.kind === 'trail' ? inlineDef.id : null;
2700
+ };
2701
+
2702
+ /**
2703
+ * Collect all trail IDs referenced by a single trail definition's
2704
+ * `crosses: [...]` array, deduplicated.
2705
+ */
2706
+ export const extractDefinitionCrossTargetIds = (
2707
+ config: AstNode,
2708
+ sourceCode: string,
2709
+ namedTrailIds: ReadonlyMap<string, string>
2710
+ ): readonly string[] => [
2711
+ ...new Set(
2712
+ getCrossElements(config).flatMap((element) => {
2713
+ const id = deriveCrossElementId(element, sourceCode, namedTrailIds);
2714
+ return id ? [id] : [];
2715
+ })
2716
+ ),
2717
+ ];
2718
+
2719
+ /** Collect all trail IDs referenced by declared `crosses: [...]` arrays. */
2720
+ export const collectCrossTargetTrailIds = (
2721
+ ast: AstNode,
2722
+ sourceCode: string
2723
+ ): ReadonlySet<string> => {
2724
+ const ids = new Set<string>();
2725
+ const namedTrailIds = collectNamedTrailIds(ast);
2726
+
2727
+ for (const def of findTrailDefinitions(ast)) {
2728
+ if (def.kind !== 'trail') {
2729
+ continue;
2730
+ }
2731
+
2732
+ for (const id of extractDefinitionCrossTargetIds(
2733
+ def.config,
2734
+ sourceCode,
2735
+ namedTrailIds
2736
+ )) {
2737
+ ids.add(id);
2738
+ }
2739
+ }
2740
+
2741
+ return ids;
2742
+ };
2743
+
2744
+ const INTENT_VALUE_SET = new Set<string>(intentValues);
2745
+ const DEFAULT_INTENT: Intent = 'write';
2746
+
2747
+ const normalizeTrailIntent = (value: string): Intent =>
2748
+ INTENT_VALUE_SET.has(value) ? (value as Intent) : DEFAULT_INTENT;
2749
+
2750
+ const extractTrailIntent = (config: AstNode): Intent => {
2751
+ const intentProp = findConfigProperty(config, 'intent');
2752
+ if (!intentProp || !isStringLiteral(intentProp.value as AstNode)) {
2753
+ return DEFAULT_INTENT;
2754
+ }
2755
+
2756
+ const value = getStringValue(intentProp.value as AstNode);
2757
+ return value ? normalizeTrailIntent(value) : DEFAULT_INTENT;
2758
+ };
2759
+
2760
+ /** Collect the normalized intent for every trail definition in a parsed file. */
2761
+ export const collectTrailIntentsById = (
2762
+ ast: AstNode
2763
+ ): ReadonlyMap<string, Intent> => {
2764
+ const intents = new Map<string, Intent>();
2765
+
2766
+ for (const def of findTrailDefinitions(ast)) {
2767
+ if (def.kind === 'trail') {
2768
+ intents.set(def.id, extractTrailIntent(def.config));
2769
+ }
2770
+ }
2771
+
2772
+ return intents;
2773
+ };
2774
+
2775
+ // ---------------------------------------------------------------------------
2776
+ // Store / factory pattern extraction
2777
+ // ---------------------------------------------------------------------------
2778
+
2779
+ export interface StoreTableDefinition {
2780
+ /** Table name declared inside store({ ... }). */
2781
+ readonly name: string;
2782
+ /**
2783
+ * Local binding name of the enclosing `store(...)` declaration, if the
2784
+ * `store(...)` call is bound to a `const`/`let`/`var` (e.g. `db` in
2785
+ * `const db = store({ ... })`). Null for anonymous stores.
2786
+ */
2787
+ readonly storeBinding: string | null;
2788
+ /**
2789
+ * Stable composite key for this table in the form `${storeBinding}:${name}`,
2790
+ * falling back to the bare `name` when the store is anonymous. Use this for
2791
+ * cross-rule / cross-file keying so two stores with the same table name
2792
+ * never collide.
2793
+ */
2794
+ readonly key: string;
2795
+ /** Start offset of the table property declaration. */
2796
+ readonly start: number;
2797
+ /** Whether the authored table opts into version tracking. */
2798
+ readonly versioned: boolean;
2799
+ }
2800
+
2801
+ /**
2802
+ * Build a composite key for a store table: `${storeBinding}:${tableName}`,
2803
+ * falling back to the bare `tableName` when the enclosing store has no local
2804
+ * binding. Centralized so rule keying stays stable.
2805
+ *
2806
+ * @remarks
2807
+ * The key is intentionally file-local (no module path prefix). Cross-file
2808
+ * aggregation in `ProjectContext` merges keys from all files, so two files
2809
+ * with `const db = store({ notes: ... })` both produce `db:notes` — this is
2810
+ * the desired behavior because the warden checks for *pattern completeness*
2811
+ * across the project and matching keys signals that the same logical table
2812
+ * is covered. If two genuinely different tables share a binding and name,
2813
+ * that is a code-level naming collision the developer should resolve.
2814
+ */
2815
+ export const makeStoreTableKey = (
2816
+ storeBinding: string | null,
2817
+ tableName: string
2818
+ ): string => (storeBinding ? `${storeBinding}:${tableName}` : tableName);
2819
+
2820
+ const isBooleanLiteral = (node: AstNode | undefined): boolean =>
2821
+ Boolean(
2822
+ node &&
2823
+ ((node.type === 'BooleanLiteral' &&
2824
+ (node as unknown as { value?: unknown }).value === true) ||
2825
+ (node.type === 'Literal' &&
2826
+ (node as unknown as { value?: unknown }).value === true))
2827
+ );
2828
+
2829
+ /**
2830
+ * Check if a node is a `CallExpression` to the identifier `name`.
2831
+ *
2832
+ * e.g. `isNamedCall(node, 'store')` matches `store({...})` but not
2833
+ * `someObj.store()` or `storeAlt()`.
2834
+ */
2835
+ export const isNamedCall = (node: AstNode | undefined, name: string): boolean =>
2836
+ !!node &&
2837
+ node.type === 'CallExpression' &&
2838
+ identifierName((node as unknown as { callee?: AstNode }).callee) === name;
2839
+
2840
+ /**
2841
+ * Narrow a member-expression node (`a.b` or `a['b']`) to its `object` /
2842
+ * `property` pair, returning `null` for anything else.
2843
+ */
2844
+ export const getMemberExpression = (
2845
+ node: AstNode | undefined
2846
+ ): { readonly object?: AstNode; readonly property?: AstNode } | null => {
2847
+ if (
2848
+ !node ||
2849
+ (node.type !== 'MemberExpression' && node.type !== 'StaticMemberExpression')
2850
+ ) {
2851
+ return null;
2852
+ }
2853
+
2854
+ return node as unknown as {
2855
+ readonly object?: AstNode;
2856
+ readonly property?: AstNode;
2857
+ };
2858
+ };
2859
+
2860
+ /**
2861
+ * Resolve a `<store>.tables.<name>` member expression to its store binding
2862
+ * and table name.
2863
+ *
2864
+ * Returns `null` for anything that isn't a two-level member access ending in
2865
+ * `.tables.<name>`. The store binding is the identifier of the object owning
2866
+ * `.tables` — typically the local binding from `const db = store(...)`.
2867
+ */
2868
+ export const extractStoreTableFromMember = (
2869
+ node: AstNode | undefined
2870
+ ): {
2871
+ readonly storeBinding: string | null;
2872
+ readonly tableName: string;
2873
+ } | null => {
2874
+ const member = getMemberExpression(node);
2875
+ const tableName = member ? getPropertyName(member.property) : null;
2876
+ const tablesMember = member ? getMemberExpression(member.object) : null;
2877
+ if (!tableName || !tablesMember) {
2878
+ return null;
2879
+ }
2880
+
2881
+ if (getPropertyName(tablesMember.property) !== 'tables') {
2882
+ return null;
2883
+ }
2884
+
2885
+ const storeBinding = identifierName(tablesMember.object) ?? null;
2886
+ return { storeBinding, tableName };
2887
+ };
2888
+
2889
+ /**
2890
+ * Collect `const foo = <store>.tables.<name>` bindings from a parsed file,
2891
+ * keyed by the local binding name. Values are the composite table key
2892
+ * (`${storeBinding}:${tableName}`) so callers can dedupe across stores that
2893
+ * share a table name.
2894
+ */
2895
+ export const collectNamedStoreTableIds = (
2896
+ ast: AstNode
2897
+ ): ReadonlyMap<string, string> => {
2898
+ const ids = new Map<string, string>();
2899
+
2900
+ walk(ast, (node) => {
2901
+ if (node.type !== 'VariableDeclarator') {
2902
+ return;
2903
+ }
2904
+
2905
+ const { id, init } = node as unknown as {
2906
+ readonly id?: AstNode;
2907
+ readonly init?: AstNode;
2908
+ };
2909
+ const name = extractBindingName(id);
2910
+ const table = extractStoreTableFromMember(init);
2911
+ if (name && table) {
2912
+ ids.set(name, makeStoreTableKey(table.storeBinding, table.tableName));
2913
+ }
2914
+ });
2915
+
2916
+ return ids;
2917
+ };
2918
+
2919
+ /**
2920
+ * Resolve an argument node to a composite store-table key
2921
+ * (`${storeBinding}:${tableName}` or bare `tableName` when anonymous).
2922
+ *
2923
+ * Handles the two authoring patterns:
2924
+ * - direct member access: `db.tables.notes`
2925
+ * - identifier reference: `const notesTable = db.tables.notes; crud(notesTable, …)`
2926
+ */
2927
+ export const deriveStoreTableId = (
2928
+ node: AstNode | undefined,
2929
+ namedStoreTableIds: ReadonlyMap<string, string>
2930
+ ): string | null => {
2931
+ if (!node) {
2932
+ return null;
2933
+ }
2934
+
2935
+ if (node.type === 'Identifier') {
2936
+ const name = identifierName(node);
2937
+ return name ? (namedStoreTableIds.get(name) ?? null) : null;
2938
+ }
2939
+
2940
+ const member = extractStoreTableFromMember(node);
2941
+ return member
2942
+ ? makeStoreTableKey(member.storeBinding, member.tableName)
2943
+ : null;
2944
+ };
2945
+
2946
+ const extractStoreTableDefinitions = (
2947
+ node: AstNode,
2948
+ storeBinding: string | null
2949
+ ): readonly StoreTableDefinition[] => {
2950
+ if (!isNamedCall(node, 'store')) {
2951
+ return [];
2952
+ }
2953
+
2954
+ const [tablesArg] = ((node as unknown as { arguments?: readonly AstNode[] })
2955
+ .arguments ?? []) as readonly AstNode[];
2956
+ if (!tablesArg || tablesArg.type !== 'ObjectExpression') {
2957
+ return [];
2958
+ }
2959
+
2960
+ const properties = tablesArg['properties'] as readonly AstNode[] | undefined;
2961
+ if (!properties) {
2962
+ return [];
2963
+ }
2964
+
2965
+ return properties.flatMap((property) => {
2966
+ if (property.type !== 'Property') {
2967
+ return [];
2968
+ }
2969
+
2970
+ const name = getPropertyName(property.key);
2971
+ const value = property.value as AstNode | undefined;
2972
+ if (!name || value?.type !== 'ObjectExpression') {
2973
+ return [];
2974
+ }
2975
+
2976
+ const versionedProp = findConfigProperty(value, 'versioned');
2977
+ return [
2978
+ {
2979
+ key: makeStoreTableKey(storeBinding, name),
2980
+ name,
2981
+ start: property.start,
2982
+ storeBinding,
2983
+ versioned: isBooleanLiteral(
2984
+ versionedProp?.value as AstNode | undefined
2985
+ ),
2986
+ },
2987
+ ];
2988
+ });
2989
+ };
2990
+
2991
+ export const findStoreTableDefinitions = (
2992
+ ast: AstNode
2993
+ ): readonly StoreTableDefinition[] => {
2994
+ const definitions: StoreTableDefinition[] = [];
2995
+ const seenStoreCalls = new WeakSet<AstNode>();
2996
+
2997
+ // First pass: bound stores (walk VariableDeclarators so we know the binding).
2998
+ walk(ast, (node) => {
2999
+ if (node.type !== 'VariableDeclarator') {
3000
+ return;
3001
+ }
3002
+
3003
+ const { id, init } = node as unknown as {
3004
+ readonly id?: AstNode;
3005
+ readonly init?: AstNode;
3006
+ };
3007
+ if (!init || !isNamedCall(init, 'store')) {
3008
+ return;
3009
+ }
3010
+
3011
+ seenStoreCalls.add(init);
3012
+ const storeBinding = extractBindingName(id);
3013
+ definitions.push(...extractStoreTableDefinitions(init, storeBinding));
3014
+ });
3015
+
3016
+ // Second pass: anonymous `store({...})` calls not bound to a variable
3017
+ // (e.g. an inline default export). Use the bare table name as the key.
3018
+ walk(ast, (node) => {
3019
+ if (!isNamedCall(node, 'store') || seenStoreCalls.has(node)) {
3020
+ return;
3021
+ }
3022
+ definitions.push(...extractStoreTableDefinitions(node, null));
3023
+ });
3024
+
3025
+ return definitions;
3026
+ };
3027
+
3028
+ export const collectCrudTableIds = (ast: AstNode): ReadonlySet<string> => {
3029
+ const ids = new Set<string>();
3030
+ const namedStoreTableIds = collectNamedStoreTableIds(ast);
3031
+
3032
+ walk(ast, (node) => {
3033
+ if (!isNamedCall(node, 'crud')) {
3034
+ return;
3035
+ }
3036
+
3037
+ const [tableArg] = ((node as unknown as { arguments?: readonly AstNode[] })
3038
+ .arguments ?? []) as readonly AstNode[];
3039
+ const tableId = deriveStoreTableId(tableArg, namedStoreTableIds);
3040
+ if (tableId) {
3041
+ ids.add(tableId);
3042
+ }
3043
+ });
3044
+
3045
+ return ids;
3046
+ };
3047
+
3048
+ export const collectReconcileTableIds = (ast: AstNode): ReadonlySet<string> => {
3049
+ const ids = new Set<string>();
3050
+ const namedStoreTableIds = collectNamedStoreTableIds(ast);
3051
+
3052
+ walk(ast, (node) => {
3053
+ if (!isNamedCall(node, 'reconcile')) {
3054
+ return;
3055
+ }
3056
+
3057
+ const [configArg] = ((
3058
+ node as unknown as {
3059
+ arguments?: readonly AstNode[];
3060
+ }
3061
+ ).arguments ?? []) as readonly AstNode[];
3062
+ if (!configArg || configArg.type !== 'ObjectExpression') {
3063
+ return;
3064
+ }
3065
+
3066
+ const tableProp = findConfigProperty(configArg, 'table');
3067
+ const tableId = deriveStoreTableId(
3068
+ tableProp?.value as AstNode | undefined,
3069
+ namedStoreTableIds
3070
+ );
3071
+ if (tableId) {
3072
+ ids.add(tableId);
3073
+ }
3074
+ });
3075
+
3076
+ return ids;
3077
+ };
3078
+
3079
+ const STORE_SIGNAL_OPERATIONS = new Set(['created', 'removed', 'updated']);
3080
+
3081
+ const extractStoreSignalIdFromMember = (
3082
+ node: AstNode | undefined,
3083
+ namedStoreTableIds: ReadonlyMap<string, string>
3084
+ ): string | null => {
3085
+ const member = getMemberExpression(node);
3086
+ const operation = member ? getPropertyName(member.property) : null;
3087
+ if (!operation || !STORE_SIGNAL_OPERATIONS.has(operation)) {
3088
+ return null;
3089
+ }
3090
+
3091
+ const signalsMember = member ? getMemberExpression(member.object) : null;
3092
+ if (!signalsMember || getPropertyName(signalsMember.property) !== 'signals') {
3093
+ return null;
3094
+ }
3095
+
3096
+ const tableId = deriveStoreTableId(signalsMember.object, namedStoreTableIds);
3097
+ return tableId ? `${tableId}.${operation}` : null;
3098
+ };
3099
+
3100
+ const collectNamedStoreSignalIds = (
3101
+ ast: AstNode,
3102
+ namedStoreTableIds: ReadonlyMap<string, string>
3103
+ ): ReadonlyMap<string, string> => {
3104
+ const ids = new Map<string, string>();
3105
+
3106
+ walk(ast, (node) => {
3107
+ if (node.type !== 'VariableDeclarator') {
3108
+ return;
3109
+ }
3110
+
3111
+ const { id, init } = node as unknown as {
3112
+ readonly id?: AstNode;
3113
+ readonly init?: AstNode;
3114
+ };
3115
+ const name = extractBindingName(id);
3116
+ const signalId = extractStoreSignalIdFromMember(init, namedStoreTableIds);
3117
+ if (name && signalId) {
3118
+ ids.set(name, signalId);
3119
+ }
3120
+ });
3121
+
3122
+ return ids;
3123
+ };
3124
+
3125
+ const getOnElements = (config: AstNode): readonly AstNode[] => {
3126
+ const onProp = findConfigProperty(config, 'on');
3127
+ if (!onProp) {
3128
+ return [];
3129
+ }
3130
+
3131
+ const arrayNode = onProp.value;
3132
+ if (!arrayNode || (arrayNode as AstNode).type !== 'ArrayExpression') {
3133
+ return [];
3134
+ }
3135
+
3136
+ const elements = (arrayNode as AstNode)['elements'] as
3137
+ | readonly AstNode[]
3138
+ | undefined;
3139
+ return elements ?? [];
3140
+ };
3141
+
3142
+ const resolveNamedOnSignalId = (
3143
+ element: AstNode,
3144
+ sourceCode: string,
3145
+ namedStoreSignalIds: ReadonlyMap<string, string>
3146
+ ): string | null => {
3147
+ if (element.type !== 'Identifier') {
3148
+ return null;
3149
+ }
3150
+
3151
+ const name = identifierName(element);
3152
+ return name
3153
+ ? (namedStoreSignalIds.get(name) ?? deriveConstString(name, sourceCode))
3154
+ : null;
3155
+ };
3156
+
3157
+ const resolveInlineOnSignalId = (element: AstNode): string | null => {
3158
+ const definition = extractTrailDefinition(element);
3159
+ return definition?.kind === 'signal' ? definition.id : null;
3160
+ };
3161
+
3162
+ const resolveOnElementSignalId = (
3163
+ element: AstNode,
3164
+ sourceCode: string,
3165
+ namedStoreSignalIds: ReadonlyMap<string, string>,
3166
+ namedStoreTableIds: ReadonlyMap<string, string>
3167
+ ): string | null => {
3168
+ if (isStringLiteral(element)) {
3169
+ return getStringValue(element);
3170
+ }
3171
+
3172
+ return (
3173
+ extractStoreSignalIdFromMember(element, namedStoreTableIds) ??
3174
+ resolveNamedOnSignalId(element, sourceCode, namedStoreSignalIds) ??
3175
+ resolveInlineOnSignalId(element)
3176
+ );
3177
+ };
3178
+
3179
+ const addOnTargetSignalIds = (
3180
+ config: AstNode,
3181
+ ids: Set<string>,
3182
+ sourceCode: string,
3183
+ namedStoreSignalIds: ReadonlyMap<string, string>,
3184
+ namedStoreTableIds: ReadonlyMap<string, string>
3185
+ ): void => {
3186
+ for (const element of getOnElements(config)) {
3187
+ const signalId = resolveOnElementSignalId(
3188
+ element,
3189
+ sourceCode,
3190
+ namedStoreSignalIds,
3191
+ namedStoreTableIds
3192
+ );
3193
+ if (signalId) {
3194
+ ids.add(signalId);
3195
+ }
3196
+ }
3197
+ };
3198
+
3199
+ export const collectOnTargetSignalIds = (
3200
+ ast: AstNode,
3201
+ sourceCode: string
3202
+ ): ReadonlySet<string> => {
3203
+ const ids = new Set<string>();
3204
+ const namedStoreTableIds = collectNamedStoreTableIds(ast);
3205
+ const namedStoreSignalIds = collectNamedStoreSignalIds(
3206
+ ast,
3207
+ namedStoreTableIds
3208
+ );
3209
+
3210
+ for (const definition of findTrailDefinitions(ast)) {
3211
+ if (definition.kind === 'trail') {
3212
+ addOnTargetSignalIds(
3213
+ definition.config,
3214
+ ids,
3215
+ sourceCode,
3216
+ namedStoreSignalIds,
3217
+ namedStoreTableIds
3218
+ );
3219
+ }
3220
+ }
3221
+
3222
+ return ids;
463
3223
  };
464
3224
 
465
3225
  // ---------------------------------------------------------------------------