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

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