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

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