@ontrails/warden 1.0.0-beta.2 → 1.0.0-beta.22

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 (249) hide show
  1. package/CHANGELOG.md +508 -6
  2. package/README.md +77 -26
  3. package/bin/warden.ts +50 -0
  4. package/package.json +27 -5
  5. package/src/adapter-check.ts +136 -0
  6. package/src/ast.ts +28 -0
  7. package/src/cli.ts +1374 -103
  8. package/src/command.ts +953 -0
  9. package/src/config.ts +184 -0
  10. package/src/draft.ts +22 -0
  11. package/src/drift.ts +106 -22
  12. package/src/fix.ts +120 -0
  13. package/src/formatters.ts +79 -9
  14. package/src/guide.ts +245 -0
  15. package/src/index.ts +206 -14
  16. package/src/project-context.ts +163 -0
  17. package/src/resolve.ts +530 -0
  18. package/src/rules/activation-orphan.ts +97 -0
  19. package/src/rules/ast.ts +3176 -85
  20. package/src/rules/circular-refs.ts +154 -0
  21. package/src/rules/composes-declarations.ts +704 -0
  22. package/src/rules/context-no-surface-types.ts +68 -8
  23. package/src/rules/contour-exists.ts +251 -0
  24. package/src/rules/contour-ids.ts +15 -0
  25. package/src/rules/dead-internal-trail.ts +154 -0
  26. package/src/rules/draft-file-marking.ts +160 -0
  27. package/src/rules/draft-visible-debt.ts +87 -0
  28. package/src/rules/error-mapping-completeness.ts +288 -0
  29. package/src/rules/example-valid.ts +401 -0
  30. package/src/rules/fires-declarations.ts +758 -0
  31. package/src/rules/implementation-returns-result.ts +1265 -95
  32. package/src/rules/incomplete-accessor-for-standard-op.ts +272 -0
  33. package/src/rules/incomplete-crud.ts +580 -0
  34. package/src/rules/index.ts +219 -18
  35. package/src/rules/intent-propagation.ts +127 -0
  36. package/src/rules/layer-field-name-drift.ts +96 -0
  37. package/src/rules/metadata.ts +654 -0
  38. package/src/rules/missing-reconcile.ts +98 -0
  39. package/src/rules/missing-visibility.ts +110 -0
  40. package/src/rules/no-destructured-compose.ts +192 -0
  41. package/src/rules/no-dev-permit-in-source.ts +99 -0
  42. package/src/rules/no-direct-implementation-call.ts +7 -7
  43. package/src/rules/no-legacy-layer-imports.ts +211 -0
  44. package/src/rules/no-native-error-result.ts +111 -0
  45. package/src/rules/no-redundant-result-error-wrap.ts +331 -0
  46. package/src/rules/no-retired-cross-vocabulary.ts +194 -0
  47. package/src/rules/no-sync-result-assumption.ts +1134 -99
  48. package/src/rules/no-throw-in-detour-recover.ts +225 -0
  49. package/src/rules/no-throw-in-implementation.ts +10 -9
  50. package/src/rules/no-top-level-surface.ts +389 -0
  51. package/src/rules/on-references-exist.ts +194 -0
  52. package/src/rules/orphaned-signal.ts +150 -0
  53. package/src/rules/owner-projection-parity.ts +146 -0
  54. package/src/rules/permit-governance.ts +25 -0
  55. package/src/rules/public-export-example-coverage.ts +553 -0
  56. package/src/rules/public-internal-deep-imports.ts +517 -0
  57. package/src/rules/public-output-schema.ts +29 -0
  58. package/src/rules/public-union-output-discriminants.ts +150 -0
  59. package/src/rules/read-intent-fires.ts +187 -0
  60. package/src/rules/reference-exists.ts +98 -0
  61. package/src/rules/registry-names.ts +145 -0
  62. package/src/rules/resolved-import-boundary.ts +146 -0
  63. package/src/rules/resource-declarations.ts +704 -0
  64. package/src/rules/resource-exists.ts +179 -0
  65. package/src/rules/resource-id-grammar.ts +65 -0
  66. package/src/rules/resource-mock-coverage.ts +115 -0
  67. package/src/rules/scan.ts +38 -25
  68. package/src/rules/scheduled-destroy-intent.ts +44 -0
  69. package/src/rules/signal-graph-coaching.ts +191 -0
  70. package/src/rules/specs.ts +9 -5
  71. package/src/rules/static-resource-accessor-preference.ts +657 -0
  72. package/src/rules/surface-facet-coherence.ts +370 -0
  73. package/src/rules/trail-versioning-source.ts +1094 -0
  74. package/src/rules/trail-versioning-topo.ts +172 -0
  75. package/src/rules/types.ts +270 -6
  76. package/src/rules/unmaterialized-activation-source.ts +84 -0
  77. package/src/rules/unreachable-detour-shadowing.ts +344 -0
  78. package/src/rules/valid-describe-refs.ts +160 -32
  79. package/src/rules/valid-detour-contract.ts +78 -0
  80. package/src/rules/warden-export-symmetry.ts +533 -0
  81. package/src/rules/warden-rules-use-ast.ts +996 -0
  82. package/src/rules/webhook-route-collision.ts +243 -0
  83. package/src/trails/activation-orphan.trail.ts +84 -0
  84. package/src/trails/circular-refs.trail.ts +29 -0
  85. package/src/trails/composes-declarations.trail.ts +22 -0
  86. package/src/trails/context-no-surface-types.trail.ts +21 -0
  87. package/src/trails/contour-exists.trail.ts +21 -0
  88. package/src/trails/dead-internal-trail.trail.ts +26 -0
  89. package/src/trails/deprecation-without-guidance.trail.ts +21 -0
  90. package/src/trails/draft-file-marking.trail.ts +16 -0
  91. package/src/trails/draft-visible-debt.trail.ts +16 -0
  92. package/src/trails/error-mapping-completeness.trail.ts +29 -0
  93. package/src/trails/example-valid.trail.ts +25 -0
  94. package/src/trails/fires-declarations.trail.ts +23 -0
  95. package/src/trails/fork-without-preserved-blaze.trail.ts +31 -0
  96. package/src/trails/implementation-returns-result.trail.ts +20 -0
  97. package/src/trails/incomplete-accessor-for-standard-op.trail.ts +76 -0
  98. package/src/trails/incomplete-crud.trail.ts +39 -0
  99. package/src/trails/index.ts +78 -0
  100. package/src/trails/intent-propagation.trail.ts +30 -0
  101. package/src/trails/layer-field-name-drift.trail.ts +39 -0
  102. package/src/trails/marker-schema-unsupported.trail.ts +23 -0
  103. package/src/trails/missing-reconcile.trail.ts +33 -0
  104. package/src/trails/missing-visibility.trail.ts +22 -0
  105. package/src/trails/no-destructured-compose.trail.ts +44 -0
  106. package/src/trails/no-dev-permit-in-source.trail.ts +16 -0
  107. package/src/trails/no-direct-implementation-call.trail.ts +16 -0
  108. package/src/trails/no-legacy-layer-imports.trail.ts +41 -0
  109. package/src/trails/no-native-error-result.trail.ts +18 -0
  110. package/src/trails/no-redundant-result-error-wrap.trail.ts +55 -0
  111. package/src/trails/no-retired-cross-vocabulary.trail.ts +42 -0
  112. package/src/trails/no-sync-result-assumption.trail.ts +19 -0
  113. package/src/trails/no-throw-in-detour-recover.trail.ts +24 -0
  114. package/src/trails/no-throw-in-implementation.trail.ts +20 -0
  115. package/src/trails/no-top-level-surface.trail.ts +43 -0
  116. package/src/trails/on-references-exist.trail.ts +21 -0
  117. package/src/trails/orphaned-signal.trail.ts +36 -0
  118. package/src/trails/owner-projection-parity.trail.ts +26 -0
  119. package/src/trails/pending-force.trail.ts +21 -0
  120. package/src/trails/permit-governance.trail.ts +51 -0
  121. package/src/trails/prefer-schema-inference.trail.ts +21 -0
  122. package/src/trails/public-export-example-coverage.trail.ts +16 -0
  123. package/src/trails/public-internal-deep-imports.trail.ts +94 -0
  124. package/src/trails/public-output-schema.trail.ts +55 -0
  125. package/src/trails/public-union-output-discriminants.trail.ts +33 -0
  126. package/src/trails/read-intent-fires.trail.ts +20 -0
  127. package/src/trails/reference-exists.trail.ts +25 -0
  128. package/src/trails/resolved-import-boundary.trail.ts +109 -0
  129. package/src/trails/resource-declarations.trail.ts +25 -0
  130. package/src/trails/resource-exists.trail.ts +27 -0
  131. package/src/trails/resource-id-grammar.trail.ts +39 -0
  132. package/src/trails/resource-mock-coverage.trail.ts +40 -0
  133. package/src/trails/run.ts +162 -0
  134. package/src/trails/scheduled-destroy-intent.trail.ts +56 -0
  135. package/src/trails/schema.ts +194 -0
  136. package/src/trails/signal-graph-coaching.trail.ts +77 -0
  137. package/src/trails/static-resource-accessor-preference.trail.ts +25 -0
  138. package/src/trails/surface-facet-coherence.trail.ts +25 -0
  139. package/src/trails/topo.ts +6 -0
  140. package/src/trails/unmaterialized-activation-source.trail.ts +72 -0
  141. package/src/trails/unreachable-detour-shadowing.trail.ts +45 -0
  142. package/src/trails/valid-describe-refs.trail.ts +18 -0
  143. package/src/trails/valid-detour-contract.trail.ts +71 -0
  144. package/src/trails/version-gap.trail.ts +35 -0
  145. package/src/trails/version-pinned-compose.trail.ts +23 -0
  146. package/src/trails/version-without-examples.trail.ts +38 -0
  147. package/src/trails/warden-export-symmetry.trail.ts +16 -0
  148. package/src/trails/warden-rules-use-ast.trail.ts +45 -0
  149. package/src/trails/webhook-route-collision.trail.ts +50 -0
  150. package/src/trails/wrap-rule.ts +213 -0
  151. package/src/workspaces.ts +238 -0
  152. package/.turbo/turbo-build.log +0 -1
  153. package/.turbo/turbo-lint.log +0 -3
  154. package/.turbo/turbo-typecheck.log +0 -1
  155. package/dist/cli.d.ts +0 -46
  156. package/dist/cli.d.ts.map +0 -1
  157. package/dist/cli.js +0 -221
  158. package/dist/cli.js.map +0 -1
  159. package/dist/drift.d.ts +0 -26
  160. package/dist/drift.d.ts.map +0 -1
  161. package/dist/drift.js +0 -27
  162. package/dist/drift.js.map +0 -1
  163. package/dist/formatters.d.ts +0 -29
  164. package/dist/formatters.d.ts.map +0 -1
  165. package/dist/formatters.js +0 -87
  166. package/dist/formatters.js.map +0 -1
  167. package/dist/index.d.ts +0 -26
  168. package/dist/index.d.ts.map +0 -1
  169. package/dist/index.js +0 -26
  170. package/dist/index.js.map +0 -1
  171. package/dist/rules/ast.d.ts +0 -41
  172. package/dist/rules/ast.d.ts.map +0 -1
  173. package/dist/rules/ast.js +0 -163
  174. package/dist/rules/ast.js.map +0 -1
  175. package/dist/rules/context-no-surface-types.d.ts +0 -12
  176. package/dist/rules/context-no-surface-types.d.ts.map +0 -1
  177. package/dist/rules/context-no-surface-types.js +0 -96
  178. package/dist/rules/context-no-surface-types.js.map +0 -1
  179. package/dist/rules/implementation-returns-result.d.ts +0 -13
  180. package/dist/rules/implementation-returns-result.d.ts.map +0 -1
  181. package/dist/rules/implementation-returns-result.js +0 -231
  182. package/dist/rules/implementation-returns-result.js.map +0 -1
  183. package/dist/rules/index.d.ts +0 -22
  184. package/dist/rules/index.d.ts.map +0 -1
  185. package/dist/rules/index.js +0 -41
  186. package/dist/rules/index.js.map +0 -1
  187. package/dist/rules/no-direct-impl-in-route.d.ts +0 -12
  188. package/dist/rules/no-direct-impl-in-route.d.ts.map +0 -1
  189. package/dist/rules/no-direct-impl-in-route.js +0 -46
  190. package/dist/rules/no-direct-impl-in-route.js.map +0 -1
  191. package/dist/rules/no-direct-implementation-call.d.ts +0 -12
  192. package/dist/rules/no-direct-implementation-call.d.ts.map +0 -1
  193. package/dist/rules/no-direct-implementation-call.js +0 -39
  194. package/dist/rules/no-direct-implementation-call.js.map +0 -1
  195. package/dist/rules/no-sync-result-assumption.d.ts +0 -6
  196. package/dist/rules/no-sync-result-assumption.d.ts.map +0 -1
  197. package/dist/rules/no-sync-result-assumption.js +0 -98
  198. package/dist/rules/no-sync-result-assumption.js.map +0 -1
  199. package/dist/rules/no-throw-in-detour-target.d.ts +0 -12
  200. package/dist/rules/no-throw-in-detour-target.d.ts.map +0 -1
  201. package/dist/rules/no-throw-in-detour-target.js +0 -87
  202. package/dist/rules/no-throw-in-detour-target.js.map +0 -1
  203. package/dist/rules/no-throw-in-implementation.d.ts +0 -9
  204. package/dist/rules/no-throw-in-implementation.d.ts.map +0 -1
  205. package/dist/rules/no-throw-in-implementation.js +0 -34
  206. package/dist/rules/no-throw-in-implementation.js.map +0 -1
  207. package/dist/rules/prefer-schema-inference.d.ts +0 -7
  208. package/dist/rules/prefer-schema-inference.d.ts.map +0 -1
  209. package/dist/rules/prefer-schema-inference.js +0 -86
  210. package/dist/rules/prefer-schema-inference.js.map +0 -1
  211. package/dist/rules/scan.d.ts +0 -8
  212. package/dist/rules/scan.d.ts.map +0 -1
  213. package/dist/rules/scan.js +0 -32
  214. package/dist/rules/scan.js.map +0 -1
  215. package/dist/rules/specs.d.ts +0 -29
  216. package/dist/rules/specs.d.ts.map +0 -1
  217. package/dist/rules/specs.js +0 -192
  218. package/dist/rules/specs.js.map +0 -1
  219. package/dist/rules/structure.d.ts +0 -13
  220. package/dist/rules/structure.d.ts.map +0 -1
  221. package/dist/rules/structure.js +0 -142
  222. package/dist/rules/structure.js.map +0 -1
  223. package/dist/rules/types.d.ts +0 -52
  224. package/dist/rules/types.d.ts.map +0 -1
  225. package/dist/rules/types.js +0 -2
  226. package/dist/rules/types.js.map +0 -1
  227. package/dist/rules/valid-describe-refs.d.ts +0 -7
  228. package/dist/rules/valid-describe-refs.d.ts.map +0 -1
  229. package/dist/rules/valid-describe-refs.js +0 -51
  230. package/dist/rules/valid-describe-refs.js.map +0 -1
  231. package/dist/rules/valid-detour-refs.d.ts +0 -6
  232. package/dist/rules/valid-detour-refs.d.ts.map +0 -1
  233. package/dist/rules/valid-detour-refs.js +0 -116
  234. package/dist/rules/valid-detour-refs.js.map +0 -1
  235. package/src/__tests__/cli.test.ts +0 -198
  236. package/src/__tests__/drift.test.ts +0 -74
  237. package/src/__tests__/formatters.test.ts +0 -157
  238. package/src/__tests__/implementation-returns-result.test.ts +0 -75
  239. package/src/__tests__/no-direct-implementation-call.test.ts +0 -83
  240. package/src/__tests__/no-sync-result-assumption.test.ts +0 -85
  241. package/src/__tests__/no-throw-in-detour-target.test.ts +0 -78
  242. package/src/__tests__/prefer-schema-inference.test.ts +0 -84
  243. package/src/__tests__/rules.test.ts +0 -188
  244. package/src/__tests__/valid-describe-refs.test.ts +0 -60
  245. package/src/rules/no-direct-impl-in-route.ts +0 -77
  246. package/src/rules/no-throw-in-detour-target.ts +0 -150
  247. package/src/rules/valid-detour-refs.ts +0 -187
  248. package/tsconfig.json +0 -9
  249. package/tsconfig.tsbuildinfo +0 -1
@@ -0,0 +1,704 @@
1
+ /**
2
+ * Validates that `ctx.compose()` calls match the declared `composes` array.
3
+ *
4
+ * Statically analyzes trail `blaze` functions to find `ctx.compose('trailId', ...)`
5
+ * calls and compares them against the `composes: [...]` declaration in the trail
6
+ * config. Reports errors for undeclared compositions and warnings for unused ones.
7
+ */
8
+
9
+ import {
10
+ findConfigProperty,
11
+ findBlazeBodies,
12
+ findTrailDefinitions,
13
+ offsetToLine,
14
+ parse,
15
+ walk,
16
+ } from './ast.js';
17
+ import type { AstNode } from './ast.js';
18
+ import { isTestFile } from './scan.js';
19
+ import type { WardenDiagnostic, WardenRule } from './types.js';
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Shared identifier helpers
23
+ // ---------------------------------------------------------------------------
24
+
25
+ /** Get the name of an Identifier node, or null. */
26
+ const identifierName = (node: AstNode | undefined): string | null => {
27
+ if (node?.type !== 'Identifier') {
28
+ return null;
29
+ }
30
+ return (node as unknown as { name?: string }).name ?? null;
31
+ };
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // String literal helpers
35
+ // ---------------------------------------------------------------------------
36
+
37
+ /** Check if a node is a string literal (covers `StringLiteral` and `Literal` with string value). */
38
+ const isStringLiteral = (node: AstNode): boolean => {
39
+ if (node.type === 'StringLiteral') {
40
+ return true;
41
+ }
42
+ if (node.type === 'Literal') {
43
+ return typeof (node as unknown as { value?: unknown }).value === 'string';
44
+ }
45
+ return false;
46
+ };
47
+
48
+ /** Extract the string value from a string literal node. */
49
+ const getStringValue = (node: AstNode): string | null => {
50
+ const val = (node as unknown as { value?: unknown }).value;
51
+ return typeof val === 'string' ? val : null;
52
+ };
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // Const identifier resolution
56
+ // ---------------------------------------------------------------------------
57
+
58
+ /**
59
+ * Best-effort resolution of `const NAME = 'value'` declarations via regex.
60
+ *
61
+ * Returns the string value if a simple `const <name> = '...'` or `"..."` is
62
+ * found in the source. Returns null for anything more complex.
63
+ */
64
+ const deriveConstString = (name: string, sourceCode: string): string | null => {
65
+ const pattern = new RegExp(
66
+ `const\\s+${name}\\s*=\\s*(?:'([^']*)'|"([^"]*)")`
67
+ );
68
+ const match = pattern.exec(sourceCode);
69
+ if (!match) {
70
+ return null;
71
+ }
72
+ return match[1] ?? match[2] ?? null;
73
+ };
74
+
75
+ /** Try to resolve an Identifier element to a string via const declaration. */
76
+ const resolveIdentifierElement = (
77
+ el: AstNode,
78
+ sourceCode: string
79
+ ): string | null => {
80
+ const name = identifierName(el);
81
+ if (!name) {
82
+ return null;
83
+ }
84
+ return deriveConstString(name, sourceCode);
85
+ };
86
+
87
+ /** Resolve an array element to a static trail ID when possible. */
88
+ const deriveComposeElementId = (
89
+ element: AstNode,
90
+ sourceCode: string
91
+ ): string | null => {
92
+ if (isStringLiteral(element)) {
93
+ return getStringValue(element);
94
+ }
95
+
96
+ if (element.type === 'Identifier') {
97
+ return resolveIdentifierElement(element, sourceCode);
98
+ }
99
+
100
+ return null;
101
+ };
102
+
103
+ // ---------------------------------------------------------------------------
104
+ // Declared composing extraction
105
+ // ---------------------------------------------------------------------------
106
+
107
+ /** Extract the ArrayExpression elements from a config's `composes` property. */
108
+ const getComposeElements = (config: AstNode): readonly AstNode[] | null => {
109
+ const composesProp = findConfigProperty(config, 'composes');
110
+ if (!composesProp) {
111
+ return null;
112
+ }
113
+
114
+ const arrayNode = composesProp.value;
115
+ if (!arrayNode || (arrayNode as AstNode).type !== 'ArrayExpression') {
116
+ return null;
117
+ }
118
+
119
+ const elements = (arrayNode as AstNode)['elements'] as
120
+ | readonly AstNode[]
121
+ | undefined;
122
+ return elements ?? null;
123
+ };
124
+
125
+ interface DeclaredComposes {
126
+ /** Statically resolved trail IDs from string literals / const identifiers. */
127
+ readonly ids: ReadonlySet<string>;
128
+ /**
129
+ * True if any element could not be statically resolved (e.g. trail object
130
+ * reference like `composes: [showGist]`). When true, "undeclared" diagnostics
131
+ * are softened from error to warn since the declared set is incomplete.
132
+ */
133
+ readonly hasUnresolved: boolean;
134
+ }
135
+
136
+ /**
137
+ * Collect string IDs from array elements, resolving identifiers when possible.
138
+ *
139
+ * Trail-object references (`composes: [showGist]`) cannot be resolved at lint
140
+ * time; they're normalized at runtime by `trail()`. When any entry is
141
+ * unresolved, `hasUnresolved` is set so callers can soften diagnostics.
142
+ */
143
+ /** Classify a single element and accumulate into the id set. */
144
+ const classifyComposeElement = (
145
+ element: AstNode,
146
+ sourceCode: string,
147
+ ids: Set<string>
148
+ ): boolean => {
149
+ const resolved = deriveComposeElementId(element, sourceCode);
150
+ if (!resolved) {
151
+ // Element could not be statically resolved
152
+ return true;
153
+ }
154
+ ids.add(resolved);
155
+ return false;
156
+ };
157
+
158
+ const resolveDeclaredComposeElements = (
159
+ elements: readonly AstNode[],
160
+ sourceCode: string
161
+ ): DeclaredComposes => {
162
+ const ids = new Set<string>();
163
+ let hasUnresolved = false;
164
+ for (const element of elements) {
165
+ if (classifyComposeElement(element, sourceCode, ids)) {
166
+ hasUnresolved = true;
167
+ }
168
+ }
169
+ return { hasUnresolved, ids };
170
+ };
171
+
172
+ /** Extract declared composes from a `composes: [...]` array. */
173
+ const extractDeclaredComposes = (
174
+ config: AstNode,
175
+ sourceCode: string
176
+ ): DeclaredComposes => {
177
+ const elements = getComposeElements(config);
178
+ return elements
179
+ ? resolveDeclaredComposeElements(elements, sourceCode)
180
+ : { hasUnresolved: false, ids: new Set() };
181
+ };
182
+
183
+ // ---------------------------------------------------------------------------
184
+ // Called composing extraction — member expression helpers
185
+ // ---------------------------------------------------------------------------
186
+
187
+ const MEMBER_TYPES = new Set(['StaticMemberExpression', 'MemberExpression']);
188
+
189
+ /** Extract object and property Identifier names from a MemberExpression. */
190
+ const extractMemberPair = (
191
+ callee: AstNode
192
+ ): { objName: string; propName: string } | null => {
193
+ if (!MEMBER_TYPES.has(callee.type)) {
194
+ return null;
195
+ }
196
+
197
+ const objName = identifierName(
198
+ (callee as unknown as { object?: AstNode }).object
199
+ );
200
+ const propName = identifierName(
201
+ (callee as unknown as { property?: AstNode }).property
202
+ );
203
+
204
+ return objName && propName ? { objName, propName } : null;
205
+ };
206
+
207
+ /**
208
+ * Extract the second parameter name from a blaze function node.
209
+ *
210
+ * Handles `(input, ctx) => ...`, `async (input, context) => ...`,
211
+ * `function(input, ctx) { ... }`, and defaulted params like
212
+ * `(input, ctx = fallback) => ...` (AssignmentPattern whose `.left` is the
213
+ * Identifier).
214
+ */
215
+ const extractContextParamName = (blazeBody: AstNode): string | null => {
216
+ const params = blazeBody['params'] as readonly AstNode[] | undefined;
217
+ if (!params || params.length < 2) {
218
+ return null;
219
+ }
220
+ const [, param] = params;
221
+ if (param?.type === 'AssignmentPattern') {
222
+ const { left } = param as unknown as { left?: AstNode };
223
+ return identifierName(left);
224
+ }
225
+ return identifierName(param);
226
+ };
227
+
228
+ /** Extract the local name bound to `compose` inside an ObjectPattern Property. */
229
+ const extractComposeLocalName = (prop: AstNode): string | null => {
230
+ if (prop.type !== 'Property') {
231
+ return null;
232
+ }
233
+ const { key, value } = prop as unknown as {
234
+ readonly key?: AstNode;
235
+ readonly value?: AstNode;
236
+ };
237
+ const keyName = identifierName(key);
238
+ if (keyName !== 'compose') {
239
+ return null;
240
+ }
241
+ return identifierName(value) ?? keyName;
242
+ };
243
+
244
+ /** Collect `compose` local names from an ObjectPattern's properties. */
245
+ const collectComposeNamesFromPattern = (
246
+ pattern: AstNode,
247
+ names: Set<string>
248
+ ): void => {
249
+ const { properties } = pattern as unknown as {
250
+ readonly properties?: readonly AstNode[];
251
+ };
252
+ for (const prop of properties ?? []) {
253
+ const localName = extractComposeLocalName(prop);
254
+ if (localName) {
255
+ names.add(localName);
256
+ }
257
+ }
258
+ };
259
+
260
+ /** Check if a callee is a member-style compose call: <ctxName>.compose(...). */
261
+ const isMemberComposeCall = (
262
+ callee: AstNode,
263
+ ctxNames: ReadonlySet<string>
264
+ ): boolean => {
265
+ const pair = extractMemberPair(callee);
266
+ return !!pair && ctxNames.has(pair.objName) && pair.propName === 'compose';
267
+ };
268
+
269
+ interface ExtractedComposeCall {
270
+ readonly ids: readonly string[];
271
+ readonly hasUnresolved: boolean;
272
+ }
273
+
274
+ const unresolvedCompose = (): ExtractedComposeCall => ({
275
+ hasUnresolved: true,
276
+ ids: [],
277
+ });
278
+
279
+ const resolveBatchComposeTupleTarget = (
280
+ element: AstNode,
281
+ sourceCode: string
282
+ ): string | null => {
283
+ if (element.type !== 'ArrayExpression') {
284
+ return null;
285
+ }
286
+
287
+ const tupleElements = element['elements'] as readonly AstNode[] | undefined;
288
+ const [target] = tupleElements ?? [];
289
+ return target ? deriveComposeElementId(target, sourceCode) : null;
290
+ };
291
+
292
+ const collectBatchComposeId = (
293
+ element: AstNode,
294
+ sourceCode: string,
295
+ ids: string[]
296
+ ): boolean => {
297
+ const resolved = resolveBatchComposeTupleTarget(element, sourceCode);
298
+ if (!resolved) {
299
+ return true;
300
+ }
301
+ ids.push(resolved);
302
+ return false;
303
+ };
304
+
305
+ /** Extract statically-resolved trail IDs from `ctx.compose([[trail, input], ...])`. */
306
+ const extractBatchComposeIds = (
307
+ firstArg: AstNode | undefined,
308
+ sourceCode: string
309
+ ): ExtractedComposeCall | null => {
310
+ if (firstArg?.type !== 'ArrayExpression') {
311
+ return null;
312
+ }
313
+
314
+ const elements = firstArg['elements'] as readonly AstNode[] | undefined;
315
+ const ids: string[] = [];
316
+ let hasUnresolved = false;
317
+
318
+ for (const element of elements ?? []) {
319
+ if (collectBatchComposeId(element, sourceCode, ids)) {
320
+ hasUnresolved = true;
321
+ }
322
+ }
323
+
324
+ return { hasUnresolved, ids };
325
+ };
326
+
327
+ const extractDirectComposeIds = (
328
+ firstArg: AstNode | undefined
329
+ ): ExtractedComposeCall | null => {
330
+ if (!firstArg || !isStringLiteral(firstArg)) {
331
+ return null;
332
+ }
333
+
334
+ const value = getStringValue(firstArg);
335
+ return value ? { hasUnresolved: false, ids: [value] } : unresolvedCompose();
336
+ };
337
+
338
+ const isComposeCallExpression = (
339
+ callee: AstNode,
340
+ ctxNames: ReadonlySet<string>,
341
+ composeLocalNames: ReadonlySet<string>
342
+ ): boolean =>
343
+ isMemberComposeCall(callee, ctxNames) ||
344
+ composeLocalNames.has(identifierName(callee) ?? '');
345
+
346
+ const extractComposeFirstArg = (node: AstNode): AstNode | undefined => {
347
+ const args = node['arguments'] as readonly AstNode[] | undefined;
348
+ return args?.[0];
349
+ };
350
+
351
+ const resolveComposeCallNode = (
352
+ node: AstNode,
353
+ ctxNames: ReadonlySet<string>,
354
+ composeLocalNames: ReadonlySet<string>
355
+ ): AstNode | null => {
356
+ if (node.type !== 'CallExpression') {
357
+ return null;
358
+ }
359
+
360
+ const callee = node['callee'] as AstNode | undefined;
361
+ if (
362
+ !callee ||
363
+ !isComposeCallExpression(callee, ctxNames, composeLocalNames)
364
+ ) {
365
+ return null;
366
+ }
367
+
368
+ return node;
369
+ };
370
+
371
+ const resolveComposeCallTargets = (
372
+ firstArg: AstNode | undefined,
373
+ sourceCode: string
374
+ ): ExtractedComposeCall => {
375
+ const direct = extractDirectComposeIds(firstArg);
376
+ if (direct) {
377
+ return direct;
378
+ }
379
+
380
+ const batch = extractBatchComposeIds(firstArg, sourceCode);
381
+ return batch ?? unresolvedCompose();
382
+ };
383
+
384
+ /**
385
+ * Check if a node is a `<ctxName>.compose(...)` call and return any statically
386
+ * resolvable target IDs.
387
+ *
388
+ * Also matches bare `compose(...)` calls only when `compose` was verifiably
389
+ * destructured from the trail context. When the first argument is a non-string
390
+ * expression (e.g. a trail object identifier like `ctx.compose(showGist,
391
+ * input)`), marks the call as unresolved so callers can track that a compose
392
+ * call exists but its target cannot be statically resolved.
393
+ */
394
+ const extractComposeCall = (
395
+ node: AstNode,
396
+ ctxNames: ReadonlySet<string>,
397
+ composeLocalNames: ReadonlySet<string>,
398
+ sourceCode: string
399
+ ): ExtractedComposeCall | null => {
400
+ const composeCall = resolveComposeCallNode(node, ctxNames, composeLocalNames);
401
+ if (!composeCall) {
402
+ return null;
403
+ }
404
+
405
+ return resolveComposeCallTargets(
406
+ extractComposeFirstArg(composeCall),
407
+ sourceCode
408
+ );
409
+ };
410
+
411
+ /**
412
+ * Build the set of context parameter names to match against.
413
+ *
414
+ * Returns ONLY the actual second-parameter name from the blaze signature.
415
+ * No seeded defaults: if the blaze has no second parameter, the returned set
416
+ * is empty and no `ctx.compose(...)` / `context.compose(...)` calls are tracked
417
+ * for that blaze. An unrelated closure-scoped `ctx` identifier is not the
418
+ * trail context and must not be treated as one.
419
+ *
420
+ * Mirrors `fires-declarations.ts` and `resource-declarations.ts` for the same
421
+ * reason.
422
+ */
423
+ const buildCtxNames = (body: AstNode): ReadonlySet<string> => {
424
+ const ctxNames = new Set<string>();
425
+ const paramName = extractContextParamName(body);
426
+ if (paramName) {
427
+ ctxNames.add(paramName);
428
+ }
429
+ return ctxNames;
430
+ };
431
+
432
+ const getCtxDestructurePattern = (
433
+ node: AstNode,
434
+ ctxNames: ReadonlySet<string>
435
+ ): AstNode | null => {
436
+ if (node.type !== 'VariableDeclarator') {
437
+ return null;
438
+ }
439
+ const { id, init } = node as unknown as {
440
+ readonly id?: AstNode;
441
+ readonly init?: AstNode;
442
+ };
443
+ if (!id || id.type !== 'ObjectPattern' || !init) {
444
+ return null;
445
+ }
446
+ const initName = identifierName(init);
447
+ return initName && ctxNames.has(initName) ? id : null;
448
+ };
449
+
450
+ const getTopLevelStatements = (body: AstNode): readonly AstNode[] => {
451
+ const blockBody = (body as unknown as { body?: AstNode }).body;
452
+ if (!blockBody || blockBody.type !== 'BlockStatement') {
453
+ return [];
454
+ }
455
+ return (blockBody as unknown as { body?: readonly AstNode[] }).body ?? [];
456
+ };
457
+
458
+ const collectComposeNamesFromDeclaration = (
459
+ stmt: AstNode,
460
+ ctxNames: ReadonlySet<string>,
461
+ names: Set<string>
462
+ ): void => {
463
+ if (stmt.type !== 'VariableDeclaration') {
464
+ return;
465
+ }
466
+ const { kind } = stmt as unknown as { readonly kind?: string };
467
+ if (kind !== 'const') {
468
+ return;
469
+ }
470
+ const declarations =
471
+ (stmt as unknown as { readonly declarations?: readonly AstNode[] })
472
+ .declarations ?? [];
473
+ for (const decl of declarations) {
474
+ const pattern = getCtxDestructurePattern(decl, ctxNames);
475
+ if (pattern) {
476
+ collectComposeNamesFromPattern(pattern, names);
477
+ }
478
+ }
479
+ };
480
+
481
+ const collectDestructuredComposeNames = (
482
+ body: AstNode,
483
+ ctxNames: ReadonlySet<string>
484
+ ): ReadonlySet<string> => {
485
+ const names = new Set<string>();
486
+ for (const stmt of getTopLevelStatements(body)) {
487
+ collectComposeNamesFromDeclaration(stmt, ctxNames, names);
488
+ }
489
+ return names;
490
+ };
491
+
492
+ interface CalledComposes {
493
+ /** Statically resolved trail IDs from string literal arguments. */
494
+ readonly ids: ReadonlySet<string>;
495
+ /**
496
+ * True if any `ctx.compose()` call used a non-string first argument (e.g.
497
+ * `ctx.compose(showGist, input)`). When true, "unused declaration"
498
+ * diagnostics are softened since the call may target a declared entry.
499
+ */
500
+ readonly hasUnresolved: boolean;
501
+ }
502
+
503
+ /** Collect compose call results from a single blaze body. */
504
+ const collectComposeCallsFromBody = (
505
+ body: AstNode,
506
+ ids: Set<string>,
507
+ sourceCode: string
508
+ ): boolean => {
509
+ const ctxNames = buildCtxNames(body);
510
+ const composeLocalNames = collectDestructuredComposeNames(body, ctxNames);
511
+ let foundUnresolved = false;
512
+
513
+ walk(body, (node) => {
514
+ const extracted = extractComposeCall(
515
+ node,
516
+ ctxNames,
517
+ composeLocalNames,
518
+ sourceCode
519
+ );
520
+ if (!extracted) {
521
+ return;
522
+ }
523
+
524
+ if (extracted.hasUnresolved) {
525
+ foundUnresolved = true;
526
+ }
527
+
528
+ for (const id of extracted.ids) {
529
+ ids.add(id);
530
+ }
531
+ });
532
+
533
+ return foundUnresolved;
534
+ };
535
+
536
+ /** Walk blaze bodies and collect all statically resolvable ctx.compose() trail IDs. */
537
+ const extractCalledComposes = (
538
+ config: AstNode,
539
+ sourceCode: string
540
+ ): CalledComposes => {
541
+ const ids = new Set<string>();
542
+ let hasUnresolved = false;
543
+
544
+ for (const body of findBlazeBodies(config)) {
545
+ if (collectComposeCallsFromBody(body, ids, sourceCode)) {
546
+ hasUnresolved = true;
547
+ }
548
+ }
549
+
550
+ return { hasUnresolved, ids };
551
+ };
552
+
553
+ // ---------------------------------------------------------------------------
554
+ // Diagnostic builders
555
+ // ---------------------------------------------------------------------------
556
+
557
+ const buildUndeclaredDiagnostic = (
558
+ trailId: string,
559
+ composedId: string,
560
+ filePath: string,
561
+ line: number,
562
+ softened = false
563
+ ): WardenDiagnostic => ({
564
+ filePath,
565
+ line,
566
+ message: softened
567
+ ? `Trail "${trailId}": ctx.compose('${composedId}') called but '${composedId}' is not declared in composes (may be declared via trail object references). Add the string id to composes, or use the same trail object form in both composes and ctx.compose(...).`
568
+ : `Trail "${trailId}": ctx.compose('${composedId}') called but '${composedId}' is not declared in composes. Add it to the trail composes array: composes: ['${composedId}', ...].`,
569
+ rule: 'composes-declarations',
570
+ severity: softened ? 'warn' : 'error',
571
+ });
572
+
573
+ const buildUnusedDiagnostic = (
574
+ trailId: string,
575
+ composedId: string,
576
+ filePath: string,
577
+ line: number
578
+ ): WardenDiagnostic => ({
579
+ filePath,
580
+ line,
581
+ message: `Trail "${trailId}": '${composedId}' declared in composes but ctx.compose('${composedId}') never called`,
582
+ rule: 'composes-declarations',
583
+ severity: 'warn',
584
+ });
585
+
586
+ // ---------------------------------------------------------------------------
587
+ // Comparison
588
+ // ---------------------------------------------------------------------------
589
+
590
+ /** Emit error for each called ID not present in declared set. */
591
+ const reportUndeclared = (
592
+ called: ReadonlySet<string>,
593
+ declared: ReadonlySet<string>,
594
+ ctx: {
595
+ trailId: string;
596
+ filePath: string;
597
+ line: number;
598
+ softened?: boolean;
599
+ },
600
+ diagnostics: WardenDiagnostic[]
601
+ ): void => {
602
+ for (const id of called) {
603
+ if (!declared.has(id)) {
604
+ diagnostics.push(
605
+ buildUndeclaredDiagnostic(
606
+ ctx.trailId,
607
+ id,
608
+ ctx.filePath,
609
+ ctx.line,
610
+ ctx.softened
611
+ )
612
+ );
613
+ }
614
+ }
615
+ };
616
+
617
+ /** Emit warning for each declared ID not present in called set. */
618
+ const reportUnused = (
619
+ declared: ReadonlySet<string>,
620
+ called: ReadonlySet<string>,
621
+ ctx: { trailId: string; filePath: string; line: number },
622
+ diagnostics: WardenDiagnostic[]
623
+ ): void => {
624
+ for (const id of declared) {
625
+ if (!called.has(id)) {
626
+ diagnostics.push(
627
+ buildUnusedDiagnostic(ctx.trailId, id, ctx.filePath, ctx.line)
628
+ );
629
+ }
630
+ }
631
+ };
632
+
633
+ const checkTrailDefinition = (
634
+ def: { id: string; config: AstNode; start: number },
635
+ filePath: string,
636
+ sourceCode: string,
637
+ diagnostics: WardenDiagnostic[]
638
+ ): void => {
639
+ const declared = extractDeclaredComposes(def.config, sourceCode);
640
+ const called = extractCalledComposes(def.config, sourceCode);
641
+
642
+ if (
643
+ declared.ids.size === 0 &&
644
+ !declared.hasUnresolved &&
645
+ called.ids.size === 0 &&
646
+ !called.hasUnresolved
647
+ ) {
648
+ return;
649
+ }
650
+
651
+ const line = offsetToLine(sourceCode, def.start);
652
+ const ctx = { filePath, line, trailId: def.id };
653
+
654
+ // When the declared array contains trail object references we can't resolve,
655
+ // downgrade "undeclared" diagnostics from error to warn. The developer still
656
+ // sees genuinely undeclared calls, but we can't statically prove the call
657
+ // isn't covered by a trail object entry the runtime will normalize.
658
+ reportUndeclared(
659
+ called.ids,
660
+ declared.ids,
661
+ { ...ctx, softened: declared.hasUnresolved },
662
+ diagnostics
663
+ );
664
+
665
+ // When all ctx.compose() calls are statically resolved, report unused
666
+ // declarations. When some calls use trail object references (unresolved),
667
+ // skip — a declared string like 'gist.show' might be the target of an
668
+ // unresolved `ctx.compose(showGist)` call, producing false positives.
669
+ if (!called.hasUnresolved) {
670
+ reportUnused(declared.ids, called.ids, ctx, diagnostics);
671
+ }
672
+ };
673
+
674
+ // ---------------------------------------------------------------------------
675
+ // Rule
676
+ // ---------------------------------------------------------------------------
677
+
678
+ /**
679
+ * Validates that `ctx.compose()` calls align with declared `composes` arrays.
680
+ */
681
+ export const composesDeclarations: WardenRule = {
682
+ check(sourceCode: string, filePath: string): readonly WardenDiagnostic[] {
683
+ if (isTestFile(filePath)) {
684
+ return [];
685
+ }
686
+
687
+ const ast = parse(filePath, sourceCode);
688
+ if (!ast) {
689
+ return [];
690
+ }
691
+
692
+ const diagnostics: WardenDiagnostic[] = [];
693
+
694
+ for (const def of findTrailDefinitions(ast)) {
695
+ checkTrailDefinition(def, filePath, sourceCode, diagnostics);
696
+ }
697
+
698
+ return diagnostics;
699
+ },
700
+ description:
701
+ 'Ensure ctx.compose() calls match the declared composes array in trail definitions.',
702
+ name: 'composes-declarations',
703
+ severity: 'error',
704
+ };