@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,839 @@
1
+ /**
2
+ * Validates that `ctx.compose()` calls match the declared `composes` array.
3
+ *
4
+ * Statically analyzes trail `implementation` 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
+ findImplementationBodies,
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 '@ontrails/source';
36
+ import type { AstNode } from '@ontrails/source';
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 implementation 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 = (
231
+ implementationBody: AstNode
232
+ ): string | null => {
233
+ const params = implementationBody['params'] as readonly AstNode[] | undefined;
234
+ if (!params || params.length < 2) {
235
+ return null;
236
+ }
237
+ const [, param] = params;
238
+ if (param?.type === 'AssignmentPattern') {
239
+ const left = getNodeLeft(param);
240
+ return identifierName(left);
241
+ }
242
+ return identifierName(param);
243
+ };
244
+
245
+ /** Extract the local name bound to `compose` inside an ObjectPattern Property. */
246
+ const extractComposeLocalName = (prop: AstNode): string | null => {
247
+ if (prop.type !== 'Property') {
248
+ return null;
249
+ }
250
+ const key = getNodeKey(prop);
251
+ const value = getNodeValueNode(prop);
252
+ const keyName = identifierName(key);
253
+ if (keyName !== 'compose') {
254
+ return null;
255
+ }
256
+ return identifierName(value) ?? keyName;
257
+ };
258
+
259
+ /** Collect `compose` local names from an ObjectPattern's properties. */
260
+ const collectComposeNamesFromPattern = (
261
+ pattern: AstNode,
262
+ names: Set<string>
263
+ ): void => {
264
+ const properties = getNodeProperties(pattern);
265
+ for (const prop of properties ?? []) {
266
+ const localName = extractComposeLocalName(prop);
267
+ if (localName) {
268
+ names.add(localName);
269
+ }
270
+ }
271
+ };
272
+
273
+ /** Check if a callee is a member-style compose call: <ctxName>.compose(...). */
274
+ const isMemberComposeCall = (
275
+ callee: AstNode,
276
+ ctxNames: ReadonlySet<string>
277
+ ): boolean => {
278
+ const pair = extractMemberPair(callee);
279
+ return !!pair && ctxNames.has(pair.objName) && pair.propName === 'compose';
280
+ };
281
+
282
+ interface ExtractedComposeCall {
283
+ readonly ids: readonly string[];
284
+ readonly hasUnresolved: boolean;
285
+ }
286
+
287
+ const unresolvedCompose = (): ExtractedComposeCall => ({
288
+ hasUnresolved: true,
289
+ ids: [],
290
+ });
291
+
292
+ const resolveBatchComposeTupleTarget = (
293
+ element: AstNode,
294
+ sourceCode: string
295
+ ): string | null => {
296
+ if (element.type !== 'ArrayExpression') {
297
+ return null;
298
+ }
299
+
300
+ const tupleElements = element['elements'] as readonly AstNode[] | undefined;
301
+ const [target] = tupleElements ?? [];
302
+ return target ? deriveComposeElementId(target, sourceCode) : null;
303
+ };
304
+
305
+ const collectBatchComposeId = (
306
+ element: AstNode,
307
+ sourceCode: string,
308
+ ids: string[]
309
+ ): boolean => {
310
+ const resolved = resolveBatchComposeTupleTarget(element, sourceCode);
311
+ if (!resolved) {
312
+ return true;
313
+ }
314
+ ids.push(resolved);
315
+ return false;
316
+ };
317
+
318
+ /** Extract statically-resolved trail IDs from `ctx.compose([[trail, input], ...])`. */
319
+ const extractBatchComposeIds = (
320
+ firstArg: AstNode | undefined,
321
+ sourceCode: string
322
+ ): ExtractedComposeCall | null => {
323
+ if (firstArg?.type !== 'ArrayExpression') {
324
+ return null;
325
+ }
326
+
327
+ const elements = firstArg['elements'] as readonly AstNode[] | undefined;
328
+ const ids: string[] = [];
329
+ let hasUnresolved = false;
330
+
331
+ for (const element of elements ?? []) {
332
+ if (collectBatchComposeId(element, sourceCode, ids)) {
333
+ hasUnresolved = true;
334
+ }
335
+ }
336
+
337
+ return { hasUnresolved, ids };
338
+ };
339
+
340
+ const extractDirectComposeIds = (
341
+ firstArg: AstNode | undefined
342
+ ): ExtractedComposeCall | null => {
343
+ if (!firstArg || !isStringLiteral(firstArg)) {
344
+ return null;
345
+ }
346
+
347
+ const value = getStringValue(firstArg);
348
+ return value ? { hasUnresolved: false, ids: [value] } : unresolvedCompose();
349
+ };
350
+
351
+ const isComposeCallExpression = (
352
+ callee: AstNode,
353
+ ctxNames: ReadonlySet<string>,
354
+ composeLocalNames: ReadonlySet<string>
355
+ ): boolean =>
356
+ isMemberComposeCall(callee, ctxNames) ||
357
+ composeLocalNames.has(identifierName(callee) ?? '');
358
+
359
+ const extractComposeFirstArg = (node: AstNode): AstNode | undefined => {
360
+ const args = node['arguments'] as readonly AstNode[] | undefined;
361
+ return args?.[0];
362
+ };
363
+
364
+ const resolveComposeCallNode = (
365
+ node: AstNode,
366
+ ctxNames: ReadonlySet<string>,
367
+ composeLocalNames: ReadonlySet<string>
368
+ ): AstNode | null => {
369
+ if (node.type !== 'CallExpression') {
370
+ return null;
371
+ }
372
+
373
+ const callee = node['callee'] as AstNode | undefined;
374
+ if (
375
+ !callee ||
376
+ !isComposeCallExpression(callee, ctxNames, composeLocalNames)
377
+ ) {
378
+ return null;
379
+ }
380
+
381
+ return node;
382
+ };
383
+
384
+ const resolveComposeCallTargets = (
385
+ firstArg: AstNode | undefined,
386
+ sourceCode: string
387
+ ): ExtractedComposeCall => {
388
+ const direct = extractDirectComposeIds(firstArg);
389
+ if (direct) {
390
+ return direct;
391
+ }
392
+
393
+ const batch = extractBatchComposeIds(firstArg, sourceCode);
394
+ return batch ?? unresolvedCompose();
395
+ };
396
+
397
+ /**
398
+ * Check if a node is a `<ctxName>.compose(...)` call and return any statically
399
+ * resolvable target IDs.
400
+ *
401
+ * Also matches bare `compose(...)` calls only when `compose` was verifiably
402
+ * destructured from the trail context. When the first argument is a non-string
403
+ * expression (e.g. a trail object identifier like `ctx.compose(showGist,
404
+ * input)`), marks the call as unresolved so callers can track that a compose
405
+ * call exists but its target cannot be statically resolved.
406
+ */
407
+ const extractComposeCall = (
408
+ node: AstNode,
409
+ ctxNames: ReadonlySet<string>,
410
+ composeLocalNames: ReadonlySet<string>,
411
+ sourceCode: string
412
+ ): ExtractedComposeCall | null => {
413
+ const composeCall = resolveComposeCallNode(node, ctxNames, composeLocalNames);
414
+ if (!composeCall) {
415
+ return null;
416
+ }
417
+
418
+ return resolveComposeCallTargets(
419
+ extractComposeFirstArg(composeCall),
420
+ sourceCode
421
+ );
422
+ };
423
+
424
+ /**
425
+ * Build the set of context parameter names to match against.
426
+ *
427
+ * Returns ONLY the actual second-parameter name from the implementation signature.
428
+ * No seeded defaults: if the implementation has no second parameter, the returned set
429
+ * is empty and no `ctx.compose(...)` / `context.compose(...)` calls are tracked
430
+ * for that implementation. An unrelated closure-scoped `ctx` identifier is not the
431
+ * trail context and must not be treated as one.
432
+ *
433
+ * Mirrors `fires-declarations.ts` and `resource-declarations.ts` for the same
434
+ * reason.
435
+ */
436
+ const buildCtxNames = (body: AstNode): ReadonlySet<string> => {
437
+ const ctxNames = new Set<string>();
438
+ const paramName = extractContextParamName(body);
439
+ if (paramName) {
440
+ ctxNames.add(paramName);
441
+ }
442
+ return ctxNames;
443
+ };
444
+
445
+ const getCtxDestructurePattern = (
446
+ node: AstNode,
447
+ ctxNames: ReadonlySet<string>
448
+ ): AstNode | null => {
449
+ if (node.type !== 'VariableDeclarator') {
450
+ return null;
451
+ }
452
+ const id = getNodeId(node);
453
+ const init = getNodeInit(node);
454
+ if (!id || id.type !== 'ObjectPattern' || !init) {
455
+ return null;
456
+ }
457
+ const initName = identifierName(init);
458
+ return initName && ctxNames.has(initName) ? id : null;
459
+ };
460
+
461
+ const getTopLevelStatements = (body: AstNode): readonly AstNode[] => {
462
+ if (body.type === 'BlockStatement') {
463
+ return getNodeBodyStatements(body);
464
+ }
465
+ const blockBody = getNodeBodyNode(body);
466
+ if (!blockBody || blockBody.type !== 'BlockStatement') {
467
+ return [];
468
+ }
469
+ return getNodeBodyStatements(blockBody);
470
+ };
471
+
472
+ const collectComposeNamesFromDeclaration = (
473
+ stmt: AstNode,
474
+ ctxNames: ReadonlySet<string>,
475
+ names: Set<string>
476
+ ): void => {
477
+ if (stmt.type !== 'VariableDeclaration') {
478
+ return;
479
+ }
480
+ const kind = getNodeKind(stmt);
481
+ if (kind !== 'const') {
482
+ return;
483
+ }
484
+ const declarations = getNodeDeclarations(stmt);
485
+ for (const decl of declarations) {
486
+ const pattern = getCtxDestructurePattern(decl, ctxNames);
487
+ if (pattern) {
488
+ collectComposeNamesFromPattern(pattern, names);
489
+ }
490
+ }
491
+ };
492
+
493
+ const collectDestructuredComposeNames = (
494
+ body: AstNode,
495
+ ctxNames: ReadonlySet<string>
496
+ ): ReadonlySet<string> => {
497
+ const names = new Set<string>();
498
+ for (const stmt of getTopLevelStatements(body)) {
499
+ collectComposeNamesFromDeclaration(stmt, ctxNames, names);
500
+ }
501
+ return names;
502
+ };
503
+
504
+ interface ComposeHelper {
505
+ readonly body: AstNode;
506
+ readonly params: readonly AstNode[];
507
+ }
508
+
509
+ const isFunctionLikeNode = (
510
+ node: AstNode | null | undefined
511
+ ): node is AstNode =>
512
+ node !== null &&
513
+ node !== undefined &&
514
+ (node.type === 'ArrowFunctionExpression' ||
515
+ node.type === 'FunctionDeclaration' ||
516
+ node.type === 'FunctionExpression');
517
+
518
+ const helperFromFunctionNode = (
519
+ node: AstNode | null | undefined
520
+ ): ComposeHelper | null => {
521
+ if (!isFunctionLikeNode(node)) {
522
+ return null;
523
+ }
524
+ const body = getNodeBodyNode(node);
525
+ return body ? { body, params: getNodeParams(node) } : null;
526
+ };
527
+
528
+ const addFunctionHelper = (
529
+ node: AstNode,
530
+ helpers: Map<string, ComposeHelper>
531
+ ): void => {
532
+ if (node.type !== 'FunctionDeclaration') {
533
+ return;
534
+ }
535
+ const name = identifierName(getNodeId(node));
536
+ const helper = helperFromFunctionNode(node);
537
+ if (name && helper) {
538
+ helpers.set(name, helper);
539
+ }
540
+ };
541
+
542
+ const addVariableHelpers = (
543
+ node: AstNode,
544
+ helpers: Map<string, ComposeHelper>
545
+ ): void => {
546
+ if (node.type !== 'VariableDeclaration' || getNodeKind(node) !== 'const') {
547
+ return;
548
+ }
549
+ for (const declaration of getNodeDeclarations(node)) {
550
+ const name = identifierName(getNodeId(declaration));
551
+ const helper = helperFromFunctionNode(getNodeInit(declaration));
552
+ if (name && helper) {
553
+ helpers.set(name, helper);
554
+ }
555
+ }
556
+ };
557
+
558
+ const unwrapTopLevelDeclaration = (node: AstNode): AstNode =>
559
+ node.type === 'ExportNamedDeclaration'
560
+ ? (getNodeDeclaration(node) ?? node)
561
+ : node;
562
+
563
+ const collectComposeHelpers = (
564
+ ast: AstNode
565
+ ): ReadonlyMap<string, ComposeHelper> => {
566
+ const helpers = new Map<string, ComposeHelper>();
567
+ for (const statement of getNodeBodyStatements(ast)) {
568
+ const declaration = unwrapTopLevelDeclaration(statement);
569
+ addFunctionHelper(declaration, helpers);
570
+ addVariableHelpers(declaration, helpers);
571
+ }
572
+ return helpers;
573
+ };
574
+
575
+ const collectHelperCtxNames = (
576
+ call: AstNode,
577
+ helper: ComposeHelper,
578
+ ctxNames: ReadonlySet<string>
579
+ ): ReadonlySet<string> => {
580
+ const names = new Set<string>();
581
+ const args = getNodeArguments(call);
582
+ for (const [index, param] of helper.params.entries()) {
583
+ const argName = identifierName(args[index]);
584
+ const paramName = identifierName(param);
585
+ if (argName && paramName && ctxNames.has(argName)) {
586
+ names.add(paramName);
587
+ }
588
+ }
589
+ return names;
590
+ };
591
+
592
+ const findHelperCall = (
593
+ node: AstNode | null | undefined,
594
+ helpers: ReadonlyMap<string, ComposeHelper>
595
+ ): { readonly helper: ComposeHelper; readonly name: string } | null => {
596
+ if (node?.type !== 'CallExpression') {
597
+ return null;
598
+ }
599
+ const calleeName = identifierName(getNodeCallee(node));
600
+ if (!calleeName) {
601
+ return null;
602
+ }
603
+ const helper = helpers.get(calleeName);
604
+ return helper ? { helper, name: calleeName } : null;
605
+ };
606
+
607
+ interface CalledComposes {
608
+ /** Statically resolved trail IDs from string literal arguments. */
609
+ readonly ids: ReadonlySet<string>;
610
+ /**
611
+ * True if any `ctx.compose()` call used a non-string first argument (e.g.
612
+ * `ctx.compose(showGist, input)`). When true, "unused declaration"
613
+ * diagnostics are softened since the call may target a declared entry.
614
+ */
615
+ readonly hasUnresolved: boolean;
616
+ }
617
+
618
+ /** Collect compose call results from a single implementation body. */
619
+ const collectComposeCallsFromBody = (
620
+ body: AstNode,
621
+ ids: Set<string>,
622
+ sourceCode: string,
623
+ helpers: ReadonlyMap<string, ComposeHelper>,
624
+ inheritedCtxNames?: ReadonlySet<string>,
625
+ visitedHelpers = new Set<string>()
626
+ ): boolean => {
627
+ const ctxNames = inheritedCtxNames ?? buildCtxNames(body);
628
+ const composeLocalNames = collectDestructuredComposeNames(body, ctxNames);
629
+ const helperNames = new Set(helpers.keys());
630
+ let foundUnresolved = false;
631
+
632
+ walkWithScopes(
633
+ body,
634
+ (node, scopeStack) => {
635
+ const extracted = extractComposeCall(
636
+ node,
637
+ ctxNames,
638
+ composeLocalNames,
639
+ sourceCode
640
+ );
641
+ if (extracted) {
642
+ if (extracted.hasUnresolved) {
643
+ foundUnresolved = true;
644
+ }
645
+
646
+ for (const id of extracted.ids) {
647
+ ids.add(id);
648
+ }
649
+ }
650
+
651
+ const helperCall = findHelperCall(node, helpers);
652
+ if (
653
+ !helperCall ||
654
+ visitedHelpers.has(helperCall.name) ||
655
+ isShadowed(helperCall.name, scopeStack)
656
+ ) {
657
+ return;
658
+ }
659
+ const helperCtxNames = collectHelperCtxNames(
660
+ node,
661
+ helperCall.helper,
662
+ ctxNames
663
+ );
664
+ if (helperCtxNames.size === 0) {
665
+ return;
666
+ }
667
+ visitedHelpers.add(helperCall.name);
668
+ if (
669
+ collectComposeCallsFromBody(
670
+ helperCall.helper.body,
671
+ ids,
672
+ sourceCode,
673
+ helpers,
674
+ helperCtxNames,
675
+ visitedHelpers
676
+ )
677
+ ) {
678
+ foundUnresolved = true;
679
+ }
680
+ },
681
+ { initialScopes: [helperNames] }
682
+ );
683
+
684
+ return foundUnresolved;
685
+ };
686
+
687
+ /** Walk implementation bodies and collect all statically resolvable ctx.compose() trail IDs. */
688
+ const extractCalledComposes = (
689
+ config: AstNode,
690
+ ast: AstNode,
691
+ sourceCode: string
692
+ ): CalledComposes => {
693
+ const ids = new Set<string>();
694
+ let hasUnresolved = false;
695
+ const helpers = collectComposeHelpers(ast);
696
+
697
+ for (const body of findImplementationBodies(config)) {
698
+ if (collectComposeCallsFromBody(body, ids, sourceCode, helpers)) {
699
+ hasUnresolved = true;
700
+ }
701
+ }
702
+
703
+ return { hasUnresolved, ids };
704
+ };
705
+
706
+ // ---------------------------------------------------------------------------
707
+ // Diagnostic builders
708
+ // ---------------------------------------------------------------------------
709
+
710
+ const buildUndeclaredDiagnostic = (
711
+ trailId: string,
712
+ composedId: string,
713
+ filePath: string,
714
+ line: number
715
+ ): WardenDiagnostic => ({
716
+ filePath,
717
+ line,
718
+ message: `Trail "${trailId}": ctx.compose('${composedId}') called but '${composedId}' is not declared in composes. Add it to the trail composes array: composes: ['${composedId}', ...].`,
719
+ rule: 'composes-declarations',
720
+ severity: 'error',
721
+ });
722
+
723
+ const buildUnusedDiagnostic = (
724
+ trailId: string,
725
+ composedId: string,
726
+ filePath: string,
727
+ line: number
728
+ ): WardenDiagnostic => ({
729
+ filePath,
730
+ line,
731
+ message: `Trail "${trailId}": '${composedId}' declared in composes but ctx.compose('${composedId}') never called`,
732
+ rule: 'composes-declarations',
733
+ severity: 'warn',
734
+ });
735
+
736
+ // ---------------------------------------------------------------------------
737
+ // Comparison
738
+ // ---------------------------------------------------------------------------
739
+
740
+ /** Emit error for each called ID not present in declared set. */
741
+ const reportUndeclared = (
742
+ called: ReadonlySet<string>,
743
+ declared: ReadonlySet<string>,
744
+ ctx: {
745
+ trailId: string;
746
+ filePath: string;
747
+ line: number;
748
+ },
749
+ diagnostics: WardenDiagnostic[]
750
+ ): void => {
751
+ for (const id of called) {
752
+ if (!declared.has(id)) {
753
+ diagnostics.push(
754
+ buildUndeclaredDiagnostic(ctx.trailId, id, ctx.filePath, ctx.line)
755
+ );
756
+ }
757
+ }
758
+ };
759
+
760
+ /** Emit warning for each declared ID not present in called set. */
761
+ const reportUnused = (
762
+ declared: ReadonlySet<string>,
763
+ called: ReadonlySet<string>,
764
+ ctx: { trailId: string; filePath: string; line: number },
765
+ diagnostics: WardenDiagnostic[]
766
+ ): void => {
767
+ for (const id of declared) {
768
+ if (!called.has(id)) {
769
+ diagnostics.push(
770
+ buildUnusedDiagnostic(ctx.trailId, id, ctx.filePath, ctx.line)
771
+ );
772
+ }
773
+ }
774
+ };
775
+
776
+ const checkTrailDefinition = (
777
+ def: { id: string; config: AstNode; start: number },
778
+ ast: AstNode,
779
+ filePath: string,
780
+ sourceCode: string,
781
+ diagnostics: WardenDiagnostic[]
782
+ ): void => {
783
+ const declared = extractDeclaredComposes(def.config, sourceCode);
784
+ const called = extractCalledComposes(def.config, ast, sourceCode);
785
+
786
+ if (
787
+ declared.ids.size === 0 &&
788
+ !declared.hasUnresolved &&
789
+ called.ids.size === 0 &&
790
+ !called.hasUnresolved
791
+ ) {
792
+ return;
793
+ }
794
+
795
+ const line = offsetToLine(sourceCode, def.start);
796
+ const ctx = { filePath, line, trailId: def.id };
797
+
798
+ reportUndeclared(called.ids, declared.ids, ctx, diagnostics);
799
+
800
+ // When all ctx.compose() calls are statically resolved, report unused
801
+ // declarations. When some calls use trail object references (unresolved),
802
+ // skip — a declared string like 'gist.show' might be the target of an
803
+ // unresolved `ctx.compose(showGist)` call, producing false positives.
804
+ if (!called.hasUnresolved) {
805
+ reportUnused(declared.ids, called.ids, ctx, diagnostics);
806
+ }
807
+ };
808
+
809
+ // ---------------------------------------------------------------------------
810
+ // Rule
811
+ // ---------------------------------------------------------------------------
812
+
813
+ /**
814
+ * Validates that `ctx.compose()` calls align with declared `composes` arrays.
815
+ */
816
+ export const composesDeclarations: WardenRule = {
817
+ check(sourceCode: string, filePath: string): readonly WardenDiagnostic[] {
818
+ if (isTestFile(filePath)) {
819
+ return [];
820
+ }
821
+
822
+ const ast = parse(filePath, sourceCode);
823
+ if (!ast) {
824
+ return [];
825
+ }
826
+
827
+ const diagnostics: WardenDiagnostic[] = [];
828
+
829
+ for (const def of findTrailDefinitions(ast)) {
830
+ checkTrailDefinition(def, ast, filePath, sourceCode, diagnostics);
831
+ }
832
+
833
+ return diagnostics;
834
+ },
835
+ description:
836
+ 'Ensure ctx.compose() calls match the declared composes array in trail definitions.',
837
+ name: 'composes-declarations',
838
+ severity: 'error',
839
+ };