@ontrails/warden 1.0.0-beta.4 → 1.0.0-beta.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (279) hide show
  1. package/CHANGELOG.md +835 -13
  2. package/README.md +133 -26
  3. package/bin/warden.ts +51 -0
  4. package/package.json +27 -6
  5. package/src/adapter-check.ts +136 -0
  6. package/src/cli.ts +1570 -105
  7. package/src/command.ts +986 -0
  8. package/src/config.ts +193 -0
  9. package/src/draft.ts +22 -0
  10. package/src/drift.ts +233 -23
  11. package/src/fix.ts +126 -0
  12. package/src/formatters.ts +78 -13
  13. package/src/guide.ts +245 -0
  14. package/src/index.ts +247 -15
  15. package/src/project-context.ts +446 -0
  16. package/src/project-rules.ts +290 -0
  17. package/src/resolve.ts +531 -0
  18. package/src/rules/activation-orphan.ts +97 -0
  19. package/src/rules/captured-kernel.ts +375 -0
  20. package/src/rules/circular-refs.ts +150 -0
  21. package/src/rules/cli-command-route-coherence.ts +177 -0
  22. package/src/rules/composes-declarations.ts +839 -0
  23. package/src/rules/context-no-surface-types.ts +79 -15
  24. package/src/rules/dead-internal-trail.ts +161 -0
  25. package/src/rules/dead-public-trail.ts +258 -0
  26. package/src/rules/draft-file-marking.ts +155 -0
  27. package/src/rules/draft-visible-debt.ts +82 -0
  28. package/src/rules/duplicate-exported-symbol.ts +211 -0
  29. package/src/rules/duplicate-public-contract.ts +137 -0
  30. package/src/rules/entity-exists.ts +254 -0
  31. package/src/rules/entity-ids.ts +15 -0
  32. package/src/rules/error-mapping-completeness.ts +290 -0
  33. package/src/rules/example-valid.ts +395 -0
  34. package/src/rules/fires-declarations.ts +740 -0
  35. package/src/rules/governed-symbol-residue.ts +438 -0
  36. package/src/rules/implementation-returns-result.ts +1409 -166
  37. package/src/rules/incomplete-accessor-for-standard-op.ts +272 -0
  38. package/src/rules/incomplete-crud.ts +583 -0
  39. package/src/rules/index.ts +277 -10
  40. package/src/rules/intent-propagation.ts +125 -0
  41. package/src/rules/layer-field-name-drift.ts +102 -0
  42. package/src/rules/library-projection-coherence.ts +100 -0
  43. package/src/rules/metadata.ts +871 -0
  44. package/src/rules/missing-reconcile.ts +97 -0
  45. package/src/rules/missing-visibility.ts +111 -0
  46. package/src/rules/no-destructured-compose.ts +196 -0
  47. package/src/rules/no-dev-permit-in-source.ts +99 -0
  48. package/src/rules/no-direct-implementation-call.ts +12 -7
  49. package/src/rules/no-legacy-cli-alias-export.ts +247 -0
  50. package/src/rules/no-legacy-layer-imports.ts +211 -0
  51. package/src/rules/no-native-error-result.ts +118 -0
  52. package/src/rules/no-redundant-result-error-wrap.ts +384 -0
  53. package/src/rules/no-retired-cross-vocabulary.ts +204 -0
  54. package/src/rules/no-sync-result-assumption.ts +1141 -98
  55. package/src/rules/no-throw-in-detour-recover.ts +225 -0
  56. package/src/rules/no-throw-in-implementation.ts +15 -8
  57. package/src/rules/no-top-level-surface.ts +371 -0
  58. package/src/rules/on-references-exist.ts +194 -0
  59. package/src/rules/orphaned-signal.ts +149 -0
  60. package/src/rules/owner-projection-parity.ts +143 -0
  61. package/src/rules/permit-governance.ts +25 -0
  62. package/src/rules/public-export-example-coverage.ts +573 -0
  63. package/src/rules/public-internal-deep-imports.ts +456 -0
  64. package/src/rules/public-output-schema.ts +29 -0
  65. package/src/rules/public-union-output-discriminants.ts +150 -0
  66. package/src/rules/read-intent-fires.ts +188 -0
  67. package/src/rules/reference-exists.ts +97 -0
  68. package/src/rules/registry-names.ts +167 -0
  69. package/src/rules/resolved-import-boundary.ts +146 -0
  70. package/src/rules/resource-declarations.ts +697 -0
  71. package/src/rules/resource-exists.ts +181 -0
  72. package/src/rules/resource-id-grammar.ts +65 -0
  73. package/src/rules/resource-mock-coverage.ts +115 -0
  74. package/src/rules/retired-vocabulary.ts +633 -0
  75. package/src/rules/scan.ts +38 -25
  76. package/src/rules/scheduled-destroy-intent.ts +44 -0
  77. package/src/rules/signal-graph-coaching.ts +220 -0
  78. package/src/rules/source/composition.ts +165 -0
  79. package/src/rules/source/drafts.ts +164 -0
  80. package/src/rules/source/entities.ts +618 -0
  81. package/src/rules/source/pragmas.ts +45 -0
  82. package/src/rules/source/resources.ts +64 -0
  83. package/src/rules/source/signals.ts +397 -0
  84. package/src/rules/source/stores.ts +310 -0
  85. package/src/rules/specs.ts +9 -5
  86. package/src/rules/static-resource-accessor-preference.ts +653 -0
  87. package/src/rules/surface-overlay-coherence.ts +262 -0
  88. package/src/rules/surface-trailhead-coherence.ts +366 -0
  89. package/src/rules/trail-fork-coaching.ts +625 -0
  90. package/src/rules/trail-versioning-source.ts +1076 -0
  91. package/src/rules/trail-versioning-topo.ts +172 -0
  92. package/src/rules/trailhead-override-divergence.ts +356 -0
  93. package/src/rules/types.ts +354 -8
  94. package/src/rules/unmaterialized-activation-source.ts +85 -0
  95. package/src/rules/unreachable-detour-shadowing.ts +339 -0
  96. package/src/rules/valid-describe-refs.ts +162 -32
  97. package/src/rules/valid-detour-contract.ts +78 -0
  98. package/src/rules/warden-export-symmetry.ts +540 -0
  99. package/src/rules/warden-rules-use-ast.ts +1109 -0
  100. package/src/rules/webhook-route-collision.ts +306 -0
  101. package/src/trails/activation-orphan.trail.ts +84 -0
  102. package/src/trails/captured-kernel.trail.ts +108 -0
  103. package/src/trails/circular-refs.trail.ts +29 -0
  104. package/src/trails/cli-command-route-coherence.trail.ts +47 -0
  105. package/src/trails/composes-declarations.trail.ts +22 -0
  106. package/src/trails/context-no-surface-types.trail.ts +21 -0
  107. package/src/trails/dead-internal-trail.trail.ts +26 -0
  108. package/src/trails/dead-public-trail.trail.ts +31 -0
  109. package/src/trails/deprecation-without-guidance.trail.ts +21 -0
  110. package/src/trails/draft-file-marking.trail.ts +16 -0
  111. package/src/trails/draft-visible-debt.trail.ts +16 -0
  112. package/src/trails/duplicate-exported-symbol.trail.ts +48 -0
  113. package/src/trails/duplicate-public-contract.trail.ts +47 -0
  114. package/src/trails/entity-exists.trail.ts +21 -0
  115. package/src/trails/error-mapping-completeness.trail.ts +30 -0
  116. package/src/trails/example-valid.trail.ts +25 -0
  117. package/src/trails/fires-declarations.trail.ts +23 -0
  118. package/src/trails/fork-without-preserved-implementation.trail.ts +31 -0
  119. package/src/trails/governed-symbol-residue.trail.ts +24 -0
  120. package/src/trails/implementation-returns-result.trail.ts +20 -0
  121. package/src/trails/incomplete-accessor-for-standard-op.trail.ts +76 -0
  122. package/src/trails/incomplete-crud.trail.ts +39 -0
  123. package/src/trails/index.ts +89 -0
  124. package/src/trails/intent-propagation.trail.ts +30 -0
  125. package/src/trails/layer-field-name-drift.trail.ts +39 -0
  126. package/src/trails/library-projection-coherence.trail.ts +43 -0
  127. package/src/trails/marker-schema-unsupported.trail.ts +23 -0
  128. package/src/trails/missing-reconcile.trail.ts +33 -0
  129. package/src/trails/missing-visibility.trail.ts +22 -0
  130. package/src/trails/no-destructured-compose.trail.ts +44 -0
  131. package/src/trails/no-dev-permit-in-source.trail.ts +16 -0
  132. package/src/trails/no-direct-implementation-call.trail.ts +16 -0
  133. package/src/trails/no-legacy-cli-alias-export.trail.ts +41 -0
  134. package/src/trails/no-legacy-layer-imports.trail.ts +41 -0
  135. package/src/trails/no-native-error-result.trail.ts +18 -0
  136. package/src/trails/no-redundant-result-error-wrap.trail.ts +55 -0
  137. package/src/trails/no-retired-cross-vocabulary.trail.ts +42 -0
  138. package/src/trails/no-sync-result-assumption.trail.ts +19 -0
  139. package/src/trails/no-throw-in-detour-recover.trail.ts +24 -0
  140. package/src/trails/no-throw-in-implementation.trail.ts +20 -0
  141. package/src/trails/no-top-level-surface.trail.ts +43 -0
  142. package/src/trails/on-references-exist.trail.ts +21 -0
  143. package/src/trails/orphaned-signal.trail.ts +36 -0
  144. package/src/trails/owner-projection-parity.trail.ts +26 -0
  145. package/src/trails/pending-force.trail.ts +21 -0
  146. package/src/trails/permit-governance.trail.ts +51 -0
  147. package/src/trails/prefer-schema-inference.trail.ts +21 -0
  148. package/src/trails/public-export-example-coverage.trail.ts +16 -0
  149. package/src/trails/public-internal-deep-imports.trail.ts +94 -0
  150. package/src/trails/public-output-schema.trail.ts +55 -0
  151. package/src/trails/public-union-output-discriminants.trail.ts +33 -0
  152. package/src/trails/read-intent-fires.trail.ts +20 -0
  153. package/src/trails/reference-exists.trail.ts +25 -0
  154. package/src/trails/resolved-import-boundary.trail.ts +109 -0
  155. package/src/trails/resource-declarations.trail.ts +25 -0
  156. package/src/trails/resource-exists.trail.ts +27 -0
  157. package/src/trails/resource-id-grammar.trail.ts +39 -0
  158. package/src/trails/resource-mock-coverage.trail.ts +40 -0
  159. package/src/trails/run.ts +160 -0
  160. package/src/trails/scheduled-destroy-intent.trail.ts +56 -0
  161. package/src/trails/schema.ts +237 -0
  162. package/src/trails/signal-graph-coaching.trail.ts +77 -0
  163. package/src/trails/static-resource-accessor-preference.trail.ts +25 -0
  164. package/src/trails/surface-overlay-coherence.trail.ts +24 -0
  165. package/src/trails/surface-trailhead-coherence.trail.ts +25 -0
  166. package/src/trails/topo.ts +6 -0
  167. package/src/trails/trail-fork-coaching.trail.ts +42 -0
  168. package/src/trails/trailhead-override-divergence.trail.ts +47 -0
  169. package/src/trails/unmaterialized-activation-source.trail.ts +72 -0
  170. package/src/trails/unreachable-detour-shadowing.trail.ts +45 -0
  171. package/src/trails/valid-describe-refs.trail.ts +18 -0
  172. package/src/trails/valid-detour-contract.trail.ts +71 -0
  173. package/src/trails/version-gap.trail.ts +35 -0
  174. package/src/trails/version-pinned-compose.trail.ts +23 -0
  175. package/src/trails/version-without-examples.trail.ts +38 -0
  176. package/src/trails/warden-export-symmetry.trail.ts +16 -0
  177. package/src/trails/warden-rules-use-ast.trail.ts +64 -0
  178. package/src/trails/webhook-route-collision.trail.ts +50 -0
  179. package/src/trails/wrap-rule.ts +224 -0
  180. package/src/workspaces.ts +199 -0
  181. package/.turbo/turbo-build.log +0 -1
  182. package/.turbo/turbo-lint.log +0 -3
  183. package/.turbo/turbo-typecheck.log +0 -1
  184. package/dist/cli.d.ts +0 -46
  185. package/dist/cli.d.ts.map +0 -1
  186. package/dist/cli.js +0 -218
  187. package/dist/cli.js.map +0 -1
  188. package/dist/drift.d.ts +0 -26
  189. package/dist/drift.d.ts.map +0 -1
  190. package/dist/drift.js +0 -27
  191. package/dist/drift.js.map +0 -1
  192. package/dist/formatters.d.ts +0 -29
  193. package/dist/formatters.d.ts.map +0 -1
  194. package/dist/formatters.js +0 -87
  195. package/dist/formatters.js.map +0 -1
  196. package/dist/index.d.ts +0 -26
  197. package/dist/index.d.ts.map +0 -1
  198. package/dist/index.js +0 -26
  199. package/dist/index.js.map +0 -1
  200. package/dist/rules/ast.d.ts +0 -41
  201. package/dist/rules/ast.d.ts.map +0 -1
  202. package/dist/rules/ast.js +0 -161
  203. package/dist/rules/ast.js.map +0 -1
  204. package/dist/rules/context-no-surface-types.d.ts +0 -12
  205. package/dist/rules/context-no-surface-types.d.ts.map +0 -1
  206. package/dist/rules/context-no-surface-types.js +0 -96
  207. package/dist/rules/context-no-surface-types.js.map +0 -1
  208. package/dist/rules/implementation-returns-result.d.ts +0 -13
  209. package/dist/rules/implementation-returns-result.d.ts.map +0 -1
  210. package/dist/rules/implementation-returns-result.js +0 -277
  211. package/dist/rules/implementation-returns-result.js.map +0 -1
  212. package/dist/rules/index.d.ts +0 -15
  213. package/dist/rules/index.d.ts.map +0 -1
  214. package/dist/rules/index.js +0 -34
  215. package/dist/rules/index.js.map +0 -1
  216. package/dist/rules/no-direct-impl-in-route.d.ts +0 -12
  217. package/dist/rules/no-direct-impl-in-route.d.ts.map +0 -1
  218. package/dist/rules/no-direct-impl-in-route.js +0 -47
  219. package/dist/rules/no-direct-impl-in-route.js.map +0 -1
  220. package/dist/rules/no-direct-implementation-call.d.ts +0 -12
  221. package/dist/rules/no-direct-implementation-call.d.ts.map +0 -1
  222. package/dist/rules/no-direct-implementation-call.js +0 -39
  223. package/dist/rules/no-direct-implementation-call.js.map +0 -1
  224. package/dist/rules/no-sync-result-assumption.d.ts +0 -6
  225. package/dist/rules/no-sync-result-assumption.d.ts.map +0 -1
  226. package/dist/rules/no-sync-result-assumption.js +0 -98
  227. package/dist/rules/no-sync-result-assumption.js.map +0 -1
  228. package/dist/rules/no-throw-in-detour-target.d.ts +0 -12
  229. package/dist/rules/no-throw-in-detour-target.d.ts.map +0 -1
  230. package/dist/rules/no-throw-in-detour-target.js +0 -87
  231. package/dist/rules/no-throw-in-detour-target.js.map +0 -1
  232. package/dist/rules/no-throw-in-implementation.d.ts +0 -9
  233. package/dist/rules/no-throw-in-implementation.d.ts.map +0 -1
  234. package/dist/rules/no-throw-in-implementation.js +0 -34
  235. package/dist/rules/no-throw-in-implementation.js.map +0 -1
  236. package/dist/rules/prefer-schema-inference.d.ts +0 -7
  237. package/dist/rules/prefer-schema-inference.d.ts.map +0 -1
  238. package/dist/rules/prefer-schema-inference.js +0 -86
  239. package/dist/rules/prefer-schema-inference.js.map +0 -1
  240. package/dist/rules/scan.d.ts +0 -8
  241. package/dist/rules/scan.d.ts.map +0 -1
  242. package/dist/rules/scan.js +0 -32
  243. package/dist/rules/scan.js.map +0 -1
  244. package/dist/rules/specs.d.ts +0 -29
  245. package/dist/rules/specs.d.ts.map +0 -1
  246. package/dist/rules/specs.js +0 -192
  247. package/dist/rules/specs.js.map +0 -1
  248. package/dist/rules/structure.d.ts +0 -13
  249. package/dist/rules/structure.d.ts.map +0 -1
  250. package/dist/rules/structure.js +0 -142
  251. package/dist/rules/structure.js.map +0 -1
  252. package/dist/rules/types.d.ts +0 -52
  253. package/dist/rules/types.d.ts.map +0 -1
  254. package/dist/rules/types.js +0 -2
  255. package/dist/rules/types.js.map +0 -1
  256. package/dist/rules/valid-describe-refs.d.ts +0 -7
  257. package/dist/rules/valid-describe-refs.d.ts.map +0 -1
  258. package/dist/rules/valid-describe-refs.js +0 -51
  259. package/dist/rules/valid-describe-refs.js.map +0 -1
  260. package/dist/rules/valid-detour-refs.d.ts +0 -6
  261. package/dist/rules/valid-detour-refs.d.ts.map +0 -1
  262. package/dist/rules/valid-detour-refs.js +0 -116
  263. package/dist/rules/valid-detour-refs.js.map +0 -1
  264. package/src/__tests__/cli.test.ts +0 -198
  265. package/src/__tests__/drift.test.ts +0 -74
  266. package/src/__tests__/formatters.test.ts +0 -157
  267. package/src/__tests__/implementation-returns-result.test.ts +0 -129
  268. package/src/__tests__/no-direct-implementation-call.test.ts +0 -83
  269. package/src/__tests__/no-sync-result-assumption.test.ts +0 -85
  270. package/src/__tests__/no-throw-in-detour-target.test.ts +0 -78
  271. package/src/__tests__/prefer-schema-inference.test.ts +0 -84
  272. package/src/__tests__/rules.test.ts +0 -227
  273. package/src/__tests__/valid-describe-refs.test.ts +0 -60
  274. package/src/rules/ast.ts +0 -213
  275. package/src/rules/no-direct-impl-in-route.ts +0 -81
  276. package/src/rules/no-throw-in-detour-target.ts +0 -150
  277. package/src/rules/valid-detour-refs.ts +0 -187
  278. package/tsconfig.json +0 -9
  279. package/tsconfig.tsbuildinfo +0 -1
@@ -0,0 +1,740 @@
1
+ /**
2
+ * Validates that `ctx.fire()` calls match the declared `fires` array.
3
+ *
4
+ * Statically analyzes trail `implementation` functions to find `ctx.fire(signal, ...)`
5
+ * calls and compares locally-resolved `Signal` values against the `fires: [...]`
6
+ * declaration in the trail config. Reports errors for undeclared fires, string
7
+ * fire calls that no longer match the public runtime API, and warnings for
8
+ * unused declarations.
9
+ *
10
+ * Mirrors `composes-declarations` structurally — same extraction, same reporting
11
+ * shape, same const-identifier resolution, same context-parameter handling.
12
+ */
13
+
14
+ import { buildSignalIdentifierResolver } from './source/signals.js';
15
+ import {
16
+ deriveConstString,
17
+ extractStringLiteral,
18
+ findConfigProperty,
19
+ findImplementationBodies,
20
+ findTrailDefinitions,
21
+ getNodeBodyNode,
22
+ getNodeBodyStatements,
23
+ getNodeDeclarations,
24
+ getNodeId,
25
+ getNodeInit,
26
+ getNodeKey,
27
+ getNodeKind,
28
+ getNodeLeft,
29
+ getNodeObject,
30
+ getNodeProperties,
31
+ getNodeProperty,
32
+ getNodeValueNode,
33
+ identifierName,
34
+ offsetToLine,
35
+ parse,
36
+ walkScope,
37
+ } from '@ontrails/source';
38
+ import type { AstNode } from '@ontrails/source';
39
+ import type { SignalIdentifierResolver } from './source/signals.js';
40
+ import { isTestFile } from './scan.js';
41
+ import type { WardenDiagnostic, WardenRule } from './types.js';
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // Const identifier resolution
45
+ // ---------------------------------------------------------------------------
46
+
47
+ /**
48
+ * Resolve an array element to a static signal ID when possible.
49
+ *
50
+ * Returns null for entries the rule can't statically resolve. Unresolved entries
51
+ * are not proof of missingness, but they also do not suppress diagnostics for
52
+ * statically resolved `ctx.fire()` calls that target different signals.
53
+ */
54
+ const resolveFireElementId = (
55
+ element: AstNode,
56
+ sourceCode: string,
57
+ signalIds: SignalIdentifierResolver
58
+ ): string | null => {
59
+ const literalValue = extractStringLiteral(element);
60
+ if (literalValue !== null) {
61
+ return literalValue;
62
+ }
63
+
64
+ if (element.type === 'Identifier') {
65
+ const name = identifierName(element);
66
+ if (name) {
67
+ const resolved = signalIds.resolve(element);
68
+ if (resolved.kind === 'signal' || resolved.kind === 'string') {
69
+ return resolved.id;
70
+ }
71
+ if (resolved.kind === 'shadowed') {
72
+ return null;
73
+ }
74
+ return deriveConstString(name, sourceCode);
75
+ }
76
+ }
77
+
78
+ return null;
79
+ };
80
+
81
+ // ---------------------------------------------------------------------------
82
+ // Declared fires extraction
83
+ // ---------------------------------------------------------------------------
84
+
85
+ /** Extract the ArrayExpression elements from a config's `fires` property. */
86
+ const getFiresElements = (config: AstNode): readonly AstNode[] | null => {
87
+ const firesProp = findConfigProperty(config, 'fires');
88
+ if (!firesProp) {
89
+ return null;
90
+ }
91
+
92
+ const arrayNode = firesProp.value;
93
+ if (!arrayNode || (arrayNode as AstNode).type !== 'ArrayExpression') {
94
+ return null;
95
+ }
96
+
97
+ const elements = (arrayNode as AstNode)['elements'] as
98
+ | readonly AstNode[]
99
+ | undefined;
100
+ return elements ?? null;
101
+ };
102
+
103
+ interface DeclaredFires {
104
+ /** Statically resolved signal ids from string literals / const identifiers. */
105
+ readonly ids: ReadonlySet<string>;
106
+ /** True if any element could not be statically resolved (e.g. Signal value). */
107
+ readonly hasUnresolved: boolean;
108
+ }
109
+
110
+ /**
111
+ * Extract declared fires from a `fires: [...]` array.
112
+ *
113
+ * Object-form entries (`fires: [someSignal]`) can be unresolved at lint time
114
+ * when they come from imports the rule cannot prove. Those unresolved entries
115
+ * do not soften concrete undeclared `ctx.fire()` calls; this mirrors
116
+ * `composes-declarations` and keeps real drift hard-failing.
117
+ */
118
+ const resolveDeclaredFiresElements = (
119
+ elements: readonly AstNode[],
120
+ sourceCode: string,
121
+ signalIds: SignalIdentifierResolver
122
+ ): DeclaredFires => {
123
+ const ids = new Set<string>();
124
+ let hasUnresolved = false;
125
+ for (const element of elements) {
126
+ const resolved = resolveFireElementId(element, sourceCode, signalIds);
127
+ if (resolved) {
128
+ ids.add(resolved);
129
+ } else {
130
+ hasUnresolved = true;
131
+ }
132
+ }
133
+ return { hasUnresolved, ids };
134
+ };
135
+
136
+ const extractDeclaredFires = (
137
+ config: AstNode,
138
+ sourceCode: string,
139
+ signalIds: SignalIdentifierResolver
140
+ ): DeclaredFires => {
141
+ const elements = getFiresElements(config);
142
+ return elements
143
+ ? resolveDeclaredFiresElements(elements, sourceCode, signalIds)
144
+ : { hasUnresolved: false, ids: new Set() };
145
+ };
146
+
147
+ // ---------------------------------------------------------------------------
148
+ // Called fires extraction — member expression helpers
149
+ // ---------------------------------------------------------------------------
150
+
151
+ const MEMBER_TYPES = new Set(['StaticMemberExpression', 'MemberExpression']);
152
+
153
+ /** Extract object and property Identifier names from a MemberExpression. */
154
+ const extractMemberPair = (
155
+ callee: AstNode
156
+ ): { objName: string; propName: string } | null => {
157
+ if (!MEMBER_TYPES.has(callee.type)) {
158
+ return null;
159
+ }
160
+
161
+ const objName = identifierName(getNodeObject(callee));
162
+ const propName = identifierName(getNodeProperty(callee));
163
+
164
+ return objName && propName ? { objName, propName } : null;
165
+ };
166
+
167
+ /**
168
+ * Extract the second parameter node from a implementation function node.
169
+ *
170
+ * Handles `(input, ctx) => ...`, `async (input, context) => ...`,
171
+ * `function(input, ctx) { ... }`, and parameter-level destructuring
172
+ * like `(input, { fire }) => ...`.
173
+ */
174
+ const extractContextParamNode = (
175
+ implementationBody: AstNode
176
+ ): AstNode | null => {
177
+ const params = implementationBody['params'] as readonly AstNode[] | undefined;
178
+ if (!params || params.length < 2) {
179
+ return null;
180
+ }
181
+ return params[1] ?? null;
182
+ };
183
+
184
+ /** Extract the local name bound to `fire` inside an ObjectPattern Property. */
185
+ const extractFireLocalName = (prop: AstNode): string | null => {
186
+ if (prop.type !== 'Property') {
187
+ return null;
188
+ }
189
+ const key = getNodeKey(prop);
190
+ const value = getNodeValueNode(prop);
191
+ const keyName = identifierName(key);
192
+ if (keyName !== 'fire') {
193
+ return null;
194
+ }
195
+ // `{ fire }` → key and value are the same Identifier (shorthand).
196
+ // `{ fire: emit }` → value is a distinct Identifier.
197
+ return identifierName(value) ?? keyName;
198
+ };
199
+
200
+ /** Collect `fire` local names from an ObjectPattern's properties into `names`. */
201
+ const collectFireNamesFromPattern = (
202
+ pattern: AstNode,
203
+ names: Set<string>
204
+ ): void => {
205
+ const properties = getNodeProperties(pattern);
206
+ if (!properties) {
207
+ return;
208
+ }
209
+ for (const prop of properties) {
210
+ const localName = extractFireLocalName(prop);
211
+ if (localName) {
212
+ names.add(localName);
213
+ }
214
+ }
215
+ };
216
+
217
+ /**
218
+ * Extract the second parameter name from a implementation function node.
219
+ *
220
+ * Returns null when the parameter is not a plain Identifier (e.g. when the
221
+ * author destructures `{ fire }` in the parameter list). Parameter-level
222
+ * destructuring is handled separately by `collectParamFireNames`.
223
+ *
224
+ * Also handles defaulted parameters like `(input, ctx = fallback) => ...`
225
+ * (AssignmentPattern whose `.left` is the Identifier). Without this, valid
226
+ * signatures would silently drop out of ctx-access analysis.
227
+ */
228
+ const extractContextParamName = (
229
+ implementationBody: AstNode
230
+ ): string | null => {
231
+ const param = extractContextParamNode(implementationBody);
232
+ if (!param) {
233
+ return null;
234
+ }
235
+ if (param.type === 'AssignmentPattern') {
236
+ const left = getNodeLeft(param);
237
+ return identifierName(left);
238
+ }
239
+ return identifierName(param);
240
+ };
241
+
242
+ /**
243
+ * Collect `fire` local names bound via parameter-level destructuring.
244
+ *
245
+ * Recognizes `(input, { fire }) => ...` and `(input, { fire: emit }) => ...`.
246
+ * When the implementation author destructures in the parameter list, there is no
247
+ * enclosing `ctx` identifier to track — we seed the fire local set directly
248
+ * from the ObjectPattern in `params[1]`.
249
+ */
250
+ const collectParamFireNames = (body: AstNode): ReadonlySet<string> => {
251
+ const param = extractContextParamNode(body);
252
+ if (!param || param.type !== 'ObjectPattern') {
253
+ return new Set();
254
+ }
255
+ const names = new Set<string>();
256
+ collectFireNamesFromPattern(param, names);
257
+ return names;
258
+ };
259
+
260
+ /** Check if a callee is a member-style fire call: <ctxName>.fire(...). */
261
+ const isMemberFireCall = (
262
+ callee: AstNode,
263
+ ctxNames: ReadonlySet<string>
264
+ ): boolean => {
265
+ const pair = extractMemberPair(callee);
266
+ return !!pair && ctxNames.has(pair.objName) && pair.propName === 'fire';
267
+ };
268
+
269
+ /**
270
+ * Check if a node is a `<ctxName>.fire(...)` call.
271
+ *
272
+ * Also matches bare `<fireLocalName>(...)` calls, but only when the local name
273
+ * was verifiably destructured from the trail context (e.g. `const { fire } = ctx`
274
+ * or `const { fire: emit } = ctx`). Unrelated local `fire()` helpers are
275
+ * ignored — see `collectDestructuredFireNames`.
276
+ */
277
+ const isTrackedFireCallee = (
278
+ callee: AstNode,
279
+ ctxNames: ReadonlySet<string>,
280
+ fireLocalNames: ReadonlySet<string>
281
+ ): boolean => {
282
+ if (isMemberFireCall(callee, ctxNames)) {
283
+ return true;
284
+ }
285
+ const calleeName = identifierName(callee);
286
+ return !!calleeName && fireLocalNames.has(calleeName);
287
+ };
288
+
289
+ interface FireCallArg {
290
+ readonly id: string | null;
291
+ readonly stringId: string | null;
292
+ readonly unresolved: boolean;
293
+ }
294
+
295
+ const firstCallArg = (node: AstNode): AstNode | null => {
296
+ const args = node['arguments'] as readonly AstNode[] | undefined;
297
+ return args?.[0] ?? null;
298
+ };
299
+
300
+ const resolveFireCallArg = (
301
+ arg: AstNode | null,
302
+ sourceCode: string,
303
+ signalIds: SignalIdentifierResolver
304
+ ): FireCallArg => {
305
+ if (!arg) {
306
+ return { id: null, stringId: null, unresolved: true };
307
+ }
308
+
309
+ const stringId = extractStringLiteral(arg);
310
+ if (stringId !== null) {
311
+ return { id: stringId, stringId, unresolved: false };
312
+ }
313
+
314
+ if (arg.type === 'Identifier') {
315
+ const name = identifierName(arg);
316
+ if (!name) {
317
+ return { id: null, stringId: null, unresolved: true };
318
+ }
319
+ const resolved = signalIds.resolve(arg);
320
+ if (resolved.kind === 'signal') {
321
+ return { id: resolved.id, stringId: null, unresolved: false };
322
+ }
323
+ if (resolved.kind === 'string') {
324
+ return { id: resolved.id, stringId: resolved.id, unresolved: false };
325
+ }
326
+ if (resolved.kind === 'shadowed') {
327
+ return { id: null, stringId: null, unresolved: true };
328
+ }
329
+ const constStringId = deriveConstString(name, sourceCode);
330
+ if (constStringId) {
331
+ return { id: constStringId, stringId: constStringId, unresolved: false };
332
+ }
333
+ }
334
+
335
+ return { id: null, stringId: null, unresolved: true };
336
+ };
337
+
338
+ const extractFireCallId = (
339
+ node: AstNode,
340
+ ctxNames: ReadonlySet<string>,
341
+ fireLocalNames: ReadonlySet<string>,
342
+ sourceCode: string,
343
+ signalIds: SignalIdentifierResolver
344
+ ): FireCallArg | null => {
345
+ if (node.type !== 'CallExpression') {
346
+ return null;
347
+ }
348
+ const callee = node['callee'] as AstNode | undefined;
349
+ if (!callee) {
350
+ return null;
351
+ }
352
+ return isTrackedFireCallee(callee, ctxNames, fireLocalNames)
353
+ ? resolveFireCallArg(firstCallArg(node), sourceCode, signalIds)
354
+ : null;
355
+ };
356
+
357
+ /**
358
+ * Walk a implementation body and collect local names bound to `ctx.fire` via destructure.
359
+ *
360
+ * Recognizes:
361
+ * - `const { fire } = ctx;` → adds `fire`
362
+ * - `const { fire: emit } = context;` → adds `emit`
363
+ *
364
+ * Only destructures whose init is one of the tracked ctx parameter names are
365
+ * accepted. This prevents unrelated local `fire` helpers from being treated as
366
+ * calls into the trail context.
367
+ */
368
+ /** Check if a VariableDeclarator destructures from a known ctx identifier. */
369
+ const getCtxDestructurePattern = (
370
+ node: AstNode,
371
+ ctxNames: ReadonlySet<string>
372
+ ): AstNode | null => {
373
+ if (node.type !== 'VariableDeclarator') {
374
+ return null;
375
+ }
376
+ const id = getNodeId(node);
377
+ const init = getNodeInit(node);
378
+ if (!id || id.type !== 'ObjectPattern' || !init) {
379
+ return null;
380
+ }
381
+ const initName = identifierName(init);
382
+ if (!initName || !ctxNames.has(initName)) {
383
+ return null;
384
+ }
385
+ return id;
386
+ };
387
+
388
+ /**
389
+ * Collect `fire` local names destructured from ctx at the TOP LEVEL of the
390
+ * implementation body. Destructures inside nested functions are intentionally ignored
391
+ * to avoid leaking nested-scope bindings into the outer implementation scope — a
392
+ * `const { fire } = ctx` inside a nested helper should not cause an outer
393
+ * bare `fire('x')` to be treated as a ctx-bound call.
394
+ *
395
+ * Tradeoff: nested-scope destructures lose tracking entirely. Calls inside
396
+ * nested functions that rely on their own destructure will not be analyzed.
397
+ * This is a conservative precision loss; a full scope walker is a follow-up.
398
+ *
399
+ * Tradeoff: only `const` destructures are tracked. `let` and `var` bindings
400
+ * allow reassignment (`let { fire } = ctx; fire = other; fire('x')`) which
401
+ * this flow-insensitive walker cannot follow. Skipping them trades a small
402
+ * amount of precision — `let { fire } = ctx` is rare — for eliminating a
403
+ * class of false positives. The runtime + signal-id compose-check still
404
+ * validate real undeclared fires.
405
+ */
406
+ /** Get the top-level statements of a implementation function's BlockStatement body. */
407
+ const getTopLevelStatements = (body: AstNode): readonly AstNode[] => {
408
+ const blockBody = getNodeBodyNode(body);
409
+ if (!blockBody || blockBody.type !== 'BlockStatement') {
410
+ return [];
411
+ }
412
+ return getNodeBodyStatements(blockBody);
413
+ };
414
+
415
+ /** Collect fire-local names from a single top-level VariableDeclaration. */
416
+ const collectFireNamesFromDeclaration = (
417
+ stmt: AstNode,
418
+ ctxNames: ReadonlySet<string>,
419
+ names: Set<string>
420
+ ): void => {
421
+ if (stmt.type !== 'VariableDeclaration') {
422
+ return;
423
+ }
424
+ // Only track `const` destructures. `let` and `var` allow reassignment that
425
+ // a single-pass walker cannot track, so `let { fire } = ctx; fire = other;
426
+ // fire('x')` would otherwise be a false positive. Skipping non-const is a
427
+ // small precision loss (see TSDoc on `collectDestructuredFireNames`) in
428
+ // exchange for eliminating that class of false positives.
429
+ const kind = getNodeKind(stmt);
430
+ if (kind !== 'const') {
431
+ return;
432
+ }
433
+ const declarations = getNodeDeclarations(stmt) ?? [];
434
+ for (const decl of declarations) {
435
+ const pattern = getCtxDestructurePattern(decl, ctxNames);
436
+ if (pattern) {
437
+ collectFireNamesFromPattern(pattern, names);
438
+ }
439
+ }
440
+ };
441
+
442
+ const collectDestructuredFireNames = (
443
+ body: AstNode,
444
+ ctxNames: ReadonlySet<string>
445
+ ): ReadonlySet<string> => {
446
+ const names = new Set<string>();
447
+ for (const stmt of getTopLevelStatements(body)) {
448
+ collectFireNamesFromDeclaration(stmt, ctxNames, names);
449
+ }
450
+ return names;
451
+ };
452
+
453
+ /**
454
+ * Build the set of context parameter names to match against.
455
+ *
456
+ * Returns ONLY the actual second-parameter name from the implementation signature.
457
+ * No seeded defaults: if the implementation has no second parameter, the returned set
458
+ * is empty and no `ctx.fire(...)` / `context.fire(...)` calls are tracked
459
+ * for that implementation. An unrelated closure-scoped `ctx` identifier is not the
460
+ * trail context and must not be treated as one.
461
+ */
462
+ const buildCtxNames = (body: AstNode): ReadonlySet<string> => {
463
+ const ctxNames = new Set<string>();
464
+ const paramName = extractContextParamName(body);
465
+ if (paramName) {
466
+ ctxNames.add(paramName);
467
+ }
468
+ return ctxNames;
469
+ };
470
+
471
+ /**
472
+ * Walk implementation bodies and collect all statically resolvable ctx.fire() signal IDs.
473
+ *
474
+ * Traversal uses `walkScope`, which stops at nested function boundaries
475
+ * (FunctionDeclaration, FunctionExpression, ArrowFunctionExpression). This
476
+ * mirrors the top-level-only behavior of `collectDestructuredFireNames` and
477
+ * avoids false positives when a nested function parameter shadows `ctx` or a
478
+ * destructured `fire` local:
479
+ *
480
+ * ```ts
481
+ * implementation: async (_, ctx) => {
482
+ * const { fire } = ctx;
483
+ * function nested(fire) { fire(orderPlaced); } // ignored — shadowed
484
+ * function other(ctx) { ctx.fire(orderPlaced); } // ignored — shadowed
485
+ * return Result.ok({});
486
+ * }
487
+ * ```
488
+ *
489
+ * Tradeoff: legitimate helper-scoped fire calls are not statically analyzed
490
+ * today. This includes both direct `ctx.fire(...)` inside a nested helper and
491
+ * helper-local destructures like `const { fire } = ctx` inside that helper.
492
+ * The runtime + signal-id compose-check still validate them; the warden just
493
+ * can't prove them at lint time. A fuller helper-aware scope walker remains
494
+ * follow-up work if this precision loss becomes meaningful in practice.
495
+ */
496
+ interface CalledFires {
497
+ readonly hasUnresolved: boolean;
498
+ readonly ids: ReadonlySet<string>;
499
+ readonly stringIds: ReadonlySet<string>;
500
+ }
501
+
502
+ const mergeCalledFires = (
503
+ target: {
504
+ hasUnresolved: boolean;
505
+ ids: Set<string>;
506
+ stringIds: Set<string>;
507
+ },
508
+ source: CalledFires
509
+ ): void => {
510
+ for (const id of source.ids) {
511
+ target.ids.add(id);
512
+ }
513
+ for (const id of source.stringIds) {
514
+ target.stringIds.add(id);
515
+ }
516
+ target.hasUnresolved = target.hasUnresolved || source.hasUnresolved;
517
+ };
518
+
519
+ const extractCalledFiresFromBody = (
520
+ body: AstNode,
521
+ sourceCode: string,
522
+ signalIds: SignalIdentifierResolver
523
+ ): CalledFires => {
524
+ const ids = new Set<string>();
525
+ const stringIds = new Set<string>();
526
+ let hasUnresolved = false;
527
+ const ctxNames = buildCtxNames(body);
528
+ const bodyFireNames = collectDestructuredFireNames(body, ctxNames);
529
+ const paramFireNames = collectParamFireNames(body);
530
+ const fireLocalNames = new Set<string>([...bodyFireNames, ...paramFireNames]);
531
+
532
+ walkScope(body, (node) => {
533
+ const call = extractFireCallId(
534
+ node,
535
+ ctxNames,
536
+ fireLocalNames,
537
+ sourceCode,
538
+ signalIds
539
+ );
540
+ if (!call) {
541
+ return;
542
+ }
543
+ if (call.id) {
544
+ ids.add(call.id);
545
+ }
546
+ if (call.stringId) {
547
+ stringIds.add(call.stringId);
548
+ }
549
+ if (call.unresolved) {
550
+ hasUnresolved = true;
551
+ }
552
+ });
553
+
554
+ return { hasUnresolved, ids, stringIds };
555
+ };
556
+
557
+ const extractCalledFires = (
558
+ config: AstNode,
559
+ sourceCode: string,
560
+ signalIds: SignalIdentifierResolver
561
+ ): CalledFires => {
562
+ const ids = new Set<string>();
563
+ const stringIds = new Set<string>();
564
+ const merged = { hasUnresolved: false, ids, stringIds };
565
+
566
+ for (const body of findImplementationBodies(config)) {
567
+ mergeCalledFires(
568
+ merged,
569
+ extractCalledFiresFromBody(body, sourceCode, signalIds)
570
+ );
571
+ }
572
+
573
+ return { hasUnresolved: merged.hasUnresolved, ids, stringIds };
574
+ };
575
+
576
+ // ---------------------------------------------------------------------------
577
+ // Diagnostic builders
578
+ // ---------------------------------------------------------------------------
579
+
580
+ const buildUndeclaredDiagnostic = (
581
+ trailId: string,
582
+ signalId: string,
583
+ filePath: string,
584
+ line: number
585
+ ): WardenDiagnostic => ({
586
+ filePath,
587
+ line,
588
+ message: `Trail "${trailId}": ctx.fire('${signalId}') called but '${signalId}' is not declared in fires`,
589
+ rule: 'fires-declarations',
590
+ severity: 'error',
591
+ });
592
+
593
+ const buildStringFireDiagnostic = (
594
+ trailId: string,
595
+ signalId: string,
596
+ filePath: string,
597
+ line: number
598
+ ): WardenDiagnostic => ({
599
+ filePath,
600
+ line,
601
+ message: `Trail "${trailId}": ctx.fire('${signalId}') uses a string signal id; pass the Signal value to ctx.fire(signal, payload)`,
602
+ rule: 'fires-declarations',
603
+ severity: 'error',
604
+ });
605
+
606
+ const buildUnusedDiagnostic = (
607
+ trailId: string,
608
+ signalId: string,
609
+ filePath: string,
610
+ line: number
611
+ ): WardenDiagnostic => ({
612
+ filePath,
613
+ line,
614
+ message: `Trail "${trailId}": '${signalId}' declared in fires but ctx.fire('${signalId}') never called`,
615
+ rule: 'fires-declarations',
616
+ severity: 'warn',
617
+ });
618
+
619
+ // ---------------------------------------------------------------------------
620
+ // Comparison
621
+ // ---------------------------------------------------------------------------
622
+
623
+ /** Emit error for each called ID not present in declared set. */
624
+ const reportUndeclared = (
625
+ called: ReadonlySet<string>,
626
+ declared: ReadonlySet<string>,
627
+ ctx: {
628
+ trailId: string;
629
+ filePath: string;
630
+ line: number;
631
+ },
632
+ diagnostics: WardenDiagnostic[]
633
+ ): void => {
634
+ for (const id of called) {
635
+ if (!declared.has(id)) {
636
+ diagnostics.push(
637
+ buildUndeclaredDiagnostic(ctx.trailId, id, ctx.filePath, ctx.line)
638
+ );
639
+ }
640
+ }
641
+ };
642
+
643
+ /**
644
+ * Emit warning for each declared ID not present in called set.
645
+ *
646
+ * Unused string-literal declarations remain warnings: a declared string that is
647
+ * never called is visible cleanup debt even when other object-form declarations
648
+ * are unresolved.
649
+ */
650
+ const reportUnused = (
651
+ declared: ReadonlySet<string>,
652
+ called: ReadonlySet<string>,
653
+ ctx: { trailId: string; filePath: string; line: number },
654
+ diagnostics: WardenDiagnostic[]
655
+ ): void => {
656
+ for (const id of declared) {
657
+ if (!called.has(id)) {
658
+ diagnostics.push(
659
+ buildUnusedDiagnostic(ctx.trailId, id, ctx.filePath, ctx.line)
660
+ );
661
+ }
662
+ }
663
+ };
664
+
665
+ const reportStringFireCalls = (
666
+ stringIds: ReadonlySet<string>,
667
+ ctx: { trailId: string; filePath: string; line: number },
668
+ diagnostics: WardenDiagnostic[]
669
+ ): void => {
670
+ for (const id of stringIds) {
671
+ diagnostics.push(
672
+ buildStringFireDiagnostic(ctx.trailId, id, ctx.filePath, ctx.line)
673
+ );
674
+ }
675
+ };
676
+
677
+ const checkTrailDefinition = (
678
+ def: { id: string; config: AstNode; start: number },
679
+ filePath: string,
680
+ sourceCode: string,
681
+ signalIds: SignalIdentifierResolver,
682
+ diagnostics: WardenDiagnostic[]
683
+ ): void => {
684
+ const declared = extractDeclaredFires(def.config, sourceCode, signalIds);
685
+ const called = extractCalledFires(def.config, sourceCode, signalIds);
686
+
687
+ if (
688
+ declared.ids.size === 0 &&
689
+ !declared.hasUnresolved &&
690
+ called.ids.size === 0 &&
691
+ !called.hasUnresolved
692
+ ) {
693
+ return;
694
+ }
695
+
696
+ const line = offsetToLine(sourceCode, def.start);
697
+ const ctx = { filePath, line, trailId: def.id };
698
+ const signalValueCalledIds = new Set(
699
+ [...called.ids].filter((id) => !called.stringIds.has(id))
700
+ );
701
+
702
+ reportStringFireCalls(called.stringIds, ctx, diagnostics);
703
+ reportUndeclared(signalValueCalledIds, declared.ids, ctx, diagnostics);
704
+ reportUnused(declared.ids, called.ids, ctx, diagnostics);
705
+ };
706
+
707
+ // ---------------------------------------------------------------------------
708
+ // Rule
709
+ // ---------------------------------------------------------------------------
710
+
711
+ /**
712
+ * Validates that `ctx.fire()` calls align with declared `fires` arrays.
713
+ */
714
+ export const firesDeclarations: WardenRule = {
715
+ check(sourceCode: string, filePath: string): readonly WardenDiagnostic[] {
716
+ if (isTestFile(filePath)) {
717
+ return [];
718
+ }
719
+
720
+ const ast = parse(filePath, sourceCode);
721
+ if (!ast) {
722
+ return [];
723
+ }
724
+ const signalIds = buildSignalIdentifierResolver(ast);
725
+
726
+ const diagnostics: WardenDiagnostic[] = [];
727
+
728
+ for (const def of findTrailDefinitions(ast)) {
729
+ if (def.kind === 'trail') {
730
+ checkTrailDefinition(def, filePath, sourceCode, signalIds, diagnostics);
731
+ }
732
+ }
733
+
734
+ return diagnostics;
735
+ },
736
+ description:
737
+ 'Ensure ctx.fire() calls match the declared fires array in trail definitions.',
738
+ name: 'fires-declarations',
739
+ severity: 'error',
740
+ };