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

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 +497 -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 resource access matches the declared `resources` array.
3
+ *
4
+ * Statically analyzes trail `blaze` functions to find `db.from(ctx)` and
5
+ * `ctx.resource('db.main')` calls and compares them against the declared
6
+ * `resources: [...]` array in the trail config. Reports errors for undeclared
7
+ * access and warnings for unused declarations.
8
+ */
9
+
10
+ import {
11
+ collectNamedResourceIds,
12
+ extractFirstStringArg,
13
+ findConfigProperty,
14
+ findBlazeBodies,
15
+ findTrailDefinitions,
16
+ getStringValue,
17
+ identifierName,
18
+ isStringLiteral,
19
+ offsetToLine,
20
+ parse,
21
+ walkScope,
22
+ } from './ast.js';
23
+ import type { AstNode } from './ast.js';
24
+ import { isTestFile } from './scan.js';
25
+ import type { WardenDiagnostic, WardenRule } from './types.js';
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Resource declaration extraction
29
+ // ---------------------------------------------------------------------------
30
+
31
+ interface DeclaredResource {
32
+ readonly id: string | null;
33
+ readonly name: string | null;
34
+ }
35
+
36
+ interface CalledResources {
37
+ readonly fromNames: ReadonlySet<string>;
38
+ readonly lookupIds: ReadonlySet<string>;
39
+ readonly lookupNames: ReadonlySet<string>;
40
+ }
41
+
42
+ const MEMBER_TYPES = new Set(['StaticMemberExpression', 'MemberExpression']);
43
+
44
+ /** Extract object and property Identifier names from a MemberExpression. */
45
+ const extractMemberPair = (
46
+ callee: AstNode
47
+ ): { objName: string; propName: string } | null => {
48
+ if (!MEMBER_TYPES.has(callee.type)) {
49
+ return null;
50
+ }
51
+
52
+ const objName = identifierName(
53
+ (callee as unknown as { object?: AstNode }).object
54
+ );
55
+ const propName = identifierName(
56
+ (callee as unknown as { property?: AstNode }).property
57
+ );
58
+
59
+ return objName && propName ? { objName, propName } : null;
60
+ };
61
+
62
+ /** Check if a node is an inline `resource('id', ...)` call. */
63
+ const isInlineResourceCall = (node: AstNode): boolean => {
64
+ if (node.type !== 'CallExpression') {
65
+ return false;
66
+ }
67
+ return (
68
+ identifierName((node as unknown as { callee?: AstNode }).callee) ===
69
+ 'resource'
70
+ );
71
+ };
72
+
73
+ /** Get `resources` array elements from a trail config. */
74
+ const getResourceElements = (config: AstNode): readonly AstNode[] => {
75
+ const resourcesProp = findConfigProperty(config, 'resources');
76
+ if (!resourcesProp) {
77
+ return [];
78
+ }
79
+
80
+ const arrayNode = resourcesProp.value;
81
+ if (!arrayNode || (arrayNode as AstNode).type !== 'ArrayExpression') {
82
+ return [];
83
+ }
84
+
85
+ const elements = (arrayNode as AstNode)['elements'] as
86
+ | readonly AstNode[]
87
+ | undefined;
88
+ return elements ?? [];
89
+ };
90
+
91
+ /** Extract one declared resource from a `resources` array element. */
92
+ const extractDeclaredResource = (
93
+ element: AstNode,
94
+ resourceIdsByName: ReadonlyMap<string, string>
95
+ ): DeclaredResource | null => {
96
+ if (element.type === 'Identifier') {
97
+ const name = identifierName(element);
98
+ return {
99
+ id: name ? (resourceIdsByName.get(name) ?? null) : null,
100
+ name,
101
+ };
102
+ }
103
+
104
+ if (isStringLiteral(element)) {
105
+ return { id: getStringValue(element), name: null };
106
+ }
107
+
108
+ if (isInlineResourceCall(element)) {
109
+ return { id: extractFirstStringArg(element), name: null };
110
+ }
111
+
112
+ return null;
113
+ };
114
+
115
+ /** Extract declared resources from a trail config's `resources` array. */
116
+ const extractDeclaredResources = (
117
+ config: AstNode,
118
+ resourceIdsByName: ReadonlyMap<string, string>
119
+ ): readonly DeclaredResource[] =>
120
+ getResourceElements(config).flatMap((element) => {
121
+ const resource = extractDeclaredResource(element, resourceIdsByName);
122
+ return resource ? [resource] : [];
123
+ });
124
+
125
+ // ---------------------------------------------------------------------------
126
+ // Called resource extraction
127
+ // ---------------------------------------------------------------------------
128
+
129
+ /** Extract the raw second parameter node from a blaze function. */
130
+ const extractContextParamNode = (blazeBody: AstNode): AstNode | null => {
131
+ const params = blazeBody['params'] as readonly AstNode[] | undefined;
132
+ if (!params || params.length < 2) {
133
+ return null;
134
+ }
135
+ return params[1] ?? null;
136
+ };
137
+
138
+ /**
139
+ * Extract the second parameter name from a blaze function node.
140
+ *
141
+ * Returns null when the parameter is not a plain Identifier (e.g. when the
142
+ * author destructures `{ resource }` in the parameter list). Parameter-level
143
+ * destructuring is handled separately by `collectParamResourceAliases`.
144
+ *
145
+ * Also handles defaulted parameters like `(input, ctx = fallback) => ...`
146
+ * (AssignmentPattern whose `.left` is the Identifier). Without this, valid
147
+ * signatures would silently drop out of ctx-access analysis.
148
+ */
149
+ const extractContextParamName = (blazeBody: AstNode): string | null => {
150
+ const param = extractContextParamNode(blazeBody);
151
+ if (!param) {
152
+ return null;
153
+ }
154
+ if (param.type === 'AssignmentPattern') {
155
+ const { left } = param as unknown as { left?: AstNode };
156
+ return identifierName(left);
157
+ }
158
+ return identifierName(param);
159
+ };
160
+
161
+ /** Extract the alias name from a Property node whose key is `resource`. */
162
+ const extractResourceAlias = (property: AstNode): string | null => {
163
+ if (property.type !== 'Property') {
164
+ return null;
165
+ }
166
+ const keyName = identifierName(
167
+ (property as unknown as { key?: AstNode }).key
168
+ );
169
+ if (keyName !== 'resource') {
170
+ return null;
171
+ }
172
+ return (
173
+ identifierName((property as unknown as { value?: AstNode }).value) ??
174
+ keyName
175
+ );
176
+ };
177
+
178
+ /**
179
+ * Collect `resource` aliases bound via parameter-level destructuring.
180
+ *
181
+ * Recognizes `(input, { resource }) => ...` and `(input, { resource: r }) => ...`.
182
+ * When the blaze author destructures in the parameter list, there is no
183
+ * enclosing `ctx` identifier to track — we seed the resource alias set directly
184
+ * from the ObjectPattern in `params[1]`.
185
+ */
186
+ const collectParamResourceAliases = (body: AstNode): ReadonlySet<string> => {
187
+ const param = extractContextParamNode(body);
188
+ if (!param || param.type !== 'ObjectPattern') {
189
+ return new Set();
190
+ }
191
+ const aliases = new Set<string>();
192
+ const properties = param['properties'] as readonly AstNode[] | undefined;
193
+ for (const property of properties ?? []) {
194
+ const alias = extractResourceAlias(property);
195
+ if (alias) {
196
+ aliases.add(alias);
197
+ }
198
+ }
199
+ return aliases;
200
+ };
201
+
202
+ /**
203
+ * Build the set of context parameter names to match against.
204
+ *
205
+ * Returns ONLY the actual second-parameter name from the blaze signature.
206
+ * No seeded defaults: if the blaze has no second parameter, the returned set
207
+ * is empty and no `ctx.resource(...)` / `context.resource(...)` calls are
208
+ * tracked for that blaze. An unrelated closure-scoped `ctx` identifier is not
209
+ * the trail context and must not be treated as one.
210
+ *
211
+ * Mirrors `fires-declarations.ts` `buildCtxNames` for the same reason.
212
+ */
213
+ const buildCtxNames = (body: AstNode): ReadonlySet<string> => {
214
+ const ctxNames = new Set<string>();
215
+ const paramName = extractContextParamName(body);
216
+ if (paramName) {
217
+ ctxNames.add(paramName);
218
+ }
219
+ return ctxNames;
220
+ };
221
+
222
+ /** Extract a CallExpression callee, or null. */
223
+ const extractCallCallee = (node: AstNode): AstNode | null => {
224
+ if (node.type !== 'CallExpression') {
225
+ return null;
226
+ }
227
+ return ((node as unknown as { callee?: AstNode }).callee ??
228
+ null) as AstNode | null;
229
+ };
230
+
231
+ /** Extract the first identifier argument from a CallExpression. */
232
+ const extractFirstIdentifierArg = (node: AstNode): string | null => {
233
+ const args = node['arguments'] as readonly AstNode[] | undefined;
234
+ const [firstArg] = args ?? [];
235
+ return identifierName(firstArg);
236
+ };
237
+
238
+ const extractCallInfo = (
239
+ node: AstNode
240
+ ): { callee: AstNode; firstArgName: string | null } | null => {
241
+ const callee = extractCallCallee(node);
242
+ return callee
243
+ ? {
244
+ callee,
245
+ firstArgName: extractFirstIdentifierArg(node),
246
+ }
247
+ : null;
248
+ };
249
+
250
+ /** Extract `db.from(ctx)` object names. */
251
+ const extractFromCallName = (
252
+ node: AstNode,
253
+ ctxNames: ReadonlySet<string>
254
+ ): string | null => {
255
+ const call = extractCallInfo(node);
256
+ const pair = call ? extractMemberPair(call.callee) : null;
257
+
258
+ return pair &&
259
+ pair.propName === 'from' &&
260
+ call?.firstArgName &&
261
+ ctxNames.has(call.firstArgName)
262
+ ? pair.objName
263
+ : null;
264
+ };
265
+
266
+ /** Check if a callee is a member-style `ctx.resource(...)` call. */
267
+ const isMemberResourceCall = (
268
+ callee: AstNode,
269
+ ctxNames: ReadonlySet<string>
270
+ ): boolean => {
271
+ const pair = extractMemberPair(callee);
272
+ return !!pair && ctxNames.has(pair.objName) && pair.propName === 'resource';
273
+ };
274
+
275
+ /** Extract `ctx.resource(db)` and destructured `resource(db)` lookup names. */
276
+ const extractLookupResourceName = (
277
+ node: AstNode,
278
+ ctxNames: ReadonlySet<string>,
279
+ resourceAliases: ReadonlySet<string>
280
+ ): string | null => {
281
+ const callee = extractCallCallee(node);
282
+ if (!callee) {
283
+ return null;
284
+ }
285
+
286
+ if (isMemberResourceCall(callee, ctxNames)) {
287
+ return extractFirstIdentifierArg(node);
288
+ }
289
+
290
+ if (resourceAliases.has(identifierName(callee) ?? '')) {
291
+ return extractFirstIdentifierArg(node);
292
+ }
293
+
294
+ return null;
295
+ };
296
+
297
+ /** Extract `ctx.resource('id')` and destructured `resource('id')` lookup IDs. */
298
+ const extractLookupResourceId = (
299
+ node: AstNode,
300
+ ctxNames: ReadonlySet<string>,
301
+ resourceAliases: ReadonlySet<string>
302
+ ): string | null => {
303
+ const callee = extractCallCallee(node);
304
+ if (!callee) {
305
+ return null;
306
+ }
307
+
308
+ if (isMemberResourceCall(callee, ctxNames)) {
309
+ return extractFirstStringArg(node);
310
+ }
311
+
312
+ const calleeName = identifierName(callee);
313
+ const args = node['arguments'] as readonly AstNode[] | undefined;
314
+ if (calleeName && resourceAliases.has(calleeName) && args?.length === 1) {
315
+ return extractFirstStringArg(node);
316
+ }
317
+
318
+ return null;
319
+ };
320
+
321
+ /** Collect local aliases for the resource accessor (e.g. `const { resource } = ctx`). */
322
+ const collectResourceAliases = (
323
+ body: AstNode,
324
+ ctxNames: ReadonlySet<string>
325
+ ): ReadonlySet<string> => {
326
+ const aliases = new Set<string>();
327
+
328
+ const extractAliasNames = (
329
+ pattern: AstNode | undefined
330
+ ): readonly string[] => {
331
+ if (pattern?.type !== 'ObjectPattern') {
332
+ return [];
333
+ }
334
+
335
+ const properties = pattern['properties'] as readonly AstNode[] | undefined;
336
+ return (properties ?? []).flatMap((property) => {
337
+ if (property.type !== 'Property') {
338
+ return [];
339
+ }
340
+
341
+ const keyName = identifierName(
342
+ (property as unknown as { key?: AstNode }).key
343
+ );
344
+ if (keyName !== 'resource') {
345
+ return [];
346
+ }
347
+
348
+ const alias =
349
+ identifierName((property as unknown as { value?: AstNode }).value) ??
350
+ keyName;
351
+ return [alias];
352
+ });
353
+ };
354
+
355
+ walkScope(body, (node) => {
356
+ if (node.type !== 'VariableDeclarator') {
357
+ return;
358
+ }
359
+
360
+ const { id, init } = node as unknown as {
361
+ readonly id?: AstNode;
362
+ readonly init?: AstNode;
363
+ };
364
+ const initName = identifierName(init);
365
+ if (!initName || !ctxNames.has(initName)) {
366
+ return;
367
+ }
368
+
369
+ for (const alias of extractAliasNames(id)) {
370
+ aliases.add(alias);
371
+ }
372
+ });
373
+
374
+ return aliases;
375
+ };
376
+
377
+ /** Walk blaze bodies and collect resource access that can be resolved statically. */
378
+ const extractCalledResources = (config: AstNode): CalledResources => {
379
+ const fromNames = new Set<string>();
380
+ const lookupIds = new Set<string>();
381
+ const lookupNames = new Set<string>();
382
+
383
+ for (const body of findBlazeBodies(config)) {
384
+ const ctxNames = buildCtxNames(body);
385
+ const paramAliases = collectParamResourceAliases(body);
386
+ const bodyAliases = collectResourceAliases(body, ctxNames);
387
+ const resourceAliases = new Set([...paramAliases, ...bodyAliases]);
388
+
389
+ walkScope(body, (node) => {
390
+ const fromName = extractFromCallName(node, ctxNames);
391
+ if (fromName) {
392
+ fromNames.add(fromName);
393
+ }
394
+
395
+ const lookupId = extractLookupResourceId(node, ctxNames, resourceAliases);
396
+ if (lookupId) {
397
+ lookupIds.add(lookupId);
398
+ }
399
+
400
+ const lookupName = extractLookupResourceName(
401
+ node,
402
+ ctxNames,
403
+ resourceAliases
404
+ );
405
+ if (lookupName) {
406
+ lookupNames.add(lookupName);
407
+ }
408
+ });
409
+ }
410
+
411
+ return { fromNames, lookupIds, lookupNames };
412
+ };
413
+
414
+ // ---------------------------------------------------------------------------
415
+ // Diagnostics
416
+ // ---------------------------------------------------------------------------
417
+
418
+ const renderDeclaredResource = (resource: DeclaredResource): string =>
419
+ resource.name ?? resource.id ?? '<unknown>';
420
+
421
+ const buildUndeclaredFromDiagnostic = (
422
+ trailId: string,
423
+ resourceName: string,
424
+ filePath: string,
425
+ line: number
426
+ ): WardenDiagnostic => ({
427
+ filePath,
428
+ line,
429
+ message: `Trail "${trailId}": ${resourceName}.from(ctx) called but '${resourceName}' is not declared in resources. Add it to the trail resources array: resources: [${resourceName}].`,
430
+ rule: 'resource-declarations',
431
+ severity: 'error',
432
+ });
433
+
434
+ const buildUndeclaredLookupDiagnostic = (
435
+ trailId: string,
436
+ resourceId: string,
437
+ filePath: string,
438
+ line: number
439
+ ): WardenDiagnostic => ({
440
+ filePath,
441
+ line,
442
+ message: `Trail "${trailId}": ctx.resource('${resourceId}') called but '${resourceId}' is not declared in resources. Add it to the trail resources array: resources: ['${resourceId}'], or prefer the resource definition's .from(ctx) helper when it is statically in scope.`,
443
+ rule: 'resource-declarations',
444
+ severity: 'error',
445
+ });
446
+
447
+ const buildUndeclaredLookupNameDiagnostic = (
448
+ trailId: string,
449
+ resourceName: string,
450
+ filePath: string,
451
+ line: number
452
+ ): WardenDiagnostic => ({
453
+ filePath,
454
+ line,
455
+ message: `Trail "${trailId}": ctx.resource(${resourceName}) called but '${resourceName}' is not declared in resources. Add it to the trail resources array: resources: [${resourceName}].`,
456
+ rule: 'resource-declarations',
457
+ severity: 'error',
458
+ });
459
+
460
+ const buildUnusedDiagnostic = (
461
+ trailId: string,
462
+ declaredResource: DeclaredResource,
463
+ filePath: string,
464
+ line: number
465
+ ): WardenDiagnostic => ({
466
+ filePath,
467
+ line,
468
+ message: `Trail "${trailId}": '${renderDeclaredResource(declaredResource)}' declared in resources but never used. Remove it from resources, or access it through the resource's static .from(ctx) helper if the trail really depends on it.`,
469
+ rule: 'resource-declarations',
470
+ severity: 'warn',
471
+ });
472
+
473
+ // ---------------------------------------------------------------------------
474
+ // Comparison
475
+ // ---------------------------------------------------------------------------
476
+
477
+ const resourceWasUsed = (
478
+ declaredResource: DeclaredResource,
479
+ calledResources: CalledResources
480
+ ): boolean => {
481
+ if (
482
+ declaredResource.name &&
483
+ (calledResources.fromNames.has(declaredResource.name) ||
484
+ calledResources.lookupNames.has(declaredResource.name))
485
+ ) {
486
+ return true;
487
+ }
488
+
489
+ if (
490
+ declaredResource.id &&
491
+ calledResources.lookupIds.has(declaredResource.id)
492
+ ) {
493
+ return true;
494
+ }
495
+
496
+ return false;
497
+ };
498
+
499
+ const buildDeclaredNames = (
500
+ declaredResources: readonly DeclaredResource[]
501
+ ): ReadonlySet<string> =>
502
+ new Set(
503
+ declaredResources.flatMap((resource) =>
504
+ resource.name ? [resource.name] : []
505
+ )
506
+ );
507
+
508
+ const buildDeclaredIds = (
509
+ declaredResources: readonly DeclaredResource[]
510
+ ): ReadonlySet<string> =>
511
+ new Set(
512
+ declaredResources.flatMap((resource) => (resource.id ? [resource.id] : []))
513
+ );
514
+
515
+ const reportUndeclaredFromCalls = (
516
+ trailId: string,
517
+ filePath: string,
518
+ line: number,
519
+ calledResources: CalledResources,
520
+ declaredNames: ReadonlySet<string>,
521
+ diagnostics: WardenDiagnostic[]
522
+ ): void => {
523
+ for (const resourceName of calledResources.fromNames) {
524
+ if (!declaredNames.has(resourceName)) {
525
+ diagnostics.push(
526
+ buildUndeclaredFromDiagnostic(trailId, resourceName, filePath, line)
527
+ );
528
+ }
529
+ }
530
+ };
531
+
532
+ const reportUndeclaredLookupCalls = (
533
+ trailId: string,
534
+ filePath: string,
535
+ line: number,
536
+ calledResources: CalledResources,
537
+ declaredIds: ReadonlySet<string>,
538
+ declaredNames: ReadonlySet<string>,
539
+ diagnostics: WardenDiagnostic[]
540
+ ): void => {
541
+ for (const resourceName of calledResources.lookupNames) {
542
+ // Name-based lookup checks remain reliable even when an imported resource ID
543
+ // cannot be resolved locally.
544
+ if (!declaredNames.has(resourceName)) {
545
+ diagnostics.push(
546
+ buildUndeclaredLookupNameDiagnostic(
547
+ trailId,
548
+ resourceName,
549
+ filePath,
550
+ line
551
+ )
552
+ );
553
+ }
554
+ }
555
+
556
+ for (const resourceId of calledResources.lookupIds) {
557
+ if (!declaredIds.has(resourceId)) {
558
+ diagnostics.push(
559
+ buildUndeclaredLookupDiagnostic(trailId, resourceId, filePath, line)
560
+ );
561
+ }
562
+ }
563
+ };
564
+
565
+ const reportUnusedDeclarations = (
566
+ trailId: string,
567
+ filePath: string,
568
+ line: number,
569
+ declaredResources: readonly DeclaredResource[],
570
+ calledResources: CalledResources,
571
+ diagnostics: WardenDiagnostic[]
572
+ ): void => {
573
+ for (const declaredResource of declaredResources) {
574
+ if (resourceWasUsed(declaredResource, calledResources)) {
575
+ continue;
576
+ }
577
+
578
+ if (declaredResource.name && declaredResource.id === null) {
579
+ continue;
580
+ }
581
+
582
+ diagnostics.push(
583
+ buildUnusedDiagnostic(trailId, declaredResource, filePath, line)
584
+ );
585
+ }
586
+ };
587
+
588
+ const hasNoResourceActivity = (
589
+ declaredResources: readonly DeclaredResource[],
590
+ calledResources: CalledResources
591
+ ): boolean =>
592
+ declaredResources.length === 0 &&
593
+ calledResources.fromNames.size === 0 &&
594
+ calledResources.lookupIds.size === 0 &&
595
+ calledResources.lookupNames.size === 0;
596
+
597
+ const analyzeTrailServices = (
598
+ def: { config: AstNode; start: number },
599
+ sourceCode: string,
600
+ resourceIdsByName: ReadonlyMap<string, string>
601
+ ): {
602
+ readonly calledResources: CalledResources;
603
+ readonly declaredIds: ReadonlySet<string>;
604
+ readonly declaredNames: ReadonlySet<string>;
605
+ readonly declaredResources: readonly DeclaredResource[];
606
+ readonly line: number;
607
+ } => {
608
+ const declaredResources = extractDeclaredResources(
609
+ def.config,
610
+ resourceIdsByName
611
+ );
612
+ return {
613
+ calledResources: extractCalledResources(def.config),
614
+ declaredIds: buildDeclaredIds(declaredResources),
615
+ declaredNames: buildDeclaredNames(declaredResources),
616
+ declaredResources,
617
+ line: offsetToLine(sourceCode, def.start),
618
+ };
619
+ };
620
+
621
+ const checkTrailDefinition = (
622
+ def: { id: string; config: AstNode; start: number },
623
+ filePath: string,
624
+ sourceCode: string,
625
+ resourceIdsByName: ReadonlyMap<string, string>,
626
+ diagnostics: WardenDiagnostic[]
627
+ ): void => {
628
+ const {
629
+ calledResources,
630
+ declaredIds,
631
+ declaredNames,
632
+ declaredResources,
633
+ line,
634
+ } = analyzeTrailServices(def, sourceCode, resourceIdsByName);
635
+
636
+ if (hasNoResourceActivity(declaredResources, calledResources)) {
637
+ return;
638
+ }
639
+
640
+ reportUndeclaredFromCalls(
641
+ def.id,
642
+ filePath,
643
+ line,
644
+ calledResources,
645
+ declaredNames,
646
+ diagnostics
647
+ );
648
+ reportUndeclaredLookupCalls(
649
+ def.id,
650
+ filePath,
651
+ line,
652
+ calledResources,
653
+ declaredIds,
654
+ declaredNames,
655
+ diagnostics
656
+ );
657
+ reportUnusedDeclarations(
658
+ def.id,
659
+ filePath,
660
+ line,
661
+ declaredResources,
662
+ calledResources,
663
+ diagnostics
664
+ );
665
+ };
666
+
667
+ // ---------------------------------------------------------------------------
668
+ // Rule
669
+ // ---------------------------------------------------------------------------
670
+
671
+ /**
672
+ * Validates that resource access aligns with declared `resources` arrays.
673
+ */
674
+ export const resourceDeclarations: WardenRule = {
675
+ check(sourceCode: string, filePath: string): readonly WardenDiagnostic[] {
676
+ if (isTestFile(filePath)) {
677
+ return [];
678
+ }
679
+
680
+ const ast = parse(filePath, sourceCode);
681
+ if (!ast) {
682
+ return [];
683
+ }
684
+
685
+ const diagnostics: WardenDiagnostic[] = [];
686
+ const resourceIdsByName = collectNamedResourceIds(ast);
687
+
688
+ for (const def of findTrailDefinitions(ast)) {
689
+ checkTrailDefinition(
690
+ def,
691
+ filePath,
692
+ sourceCode,
693
+ resourceIdsByName,
694
+ diagnostics
695
+ );
696
+ }
697
+
698
+ return diagnostics;
699
+ },
700
+ description:
701
+ 'Ensure resource.from(ctx) and ctx.resource() calls match the declared resources array in trail definitions.',
702
+ name: 'resource-declarations',
703
+ severity: 'error',
704
+ };