@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,697 @@
1
+ /**
2
+ * Validates that resource access matches the declared `resources` array.
3
+ *
4
+ * Statically analyzes trail `implementation` 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 { collectNamedResourceIds } from './source/resources.js';
11
+ import {
12
+ extractFirstStringArg,
13
+ findConfigProperty,
14
+ findImplementationBodies,
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 '@ontrails/source';
31
+ import type { AstNode } from '@ontrails/source';
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 implementation function. */
131
+ const extractContextParamNode = (
132
+ implementationBody: AstNode
133
+ ): AstNode | null => {
134
+ const params = implementationBody['params'] as readonly AstNode[] | undefined;
135
+ if (!params || params.length < 2) {
136
+ return null;
137
+ }
138
+ return params[1] ?? null;
139
+ };
140
+
141
+ /**
142
+ * Extract the second parameter name from a implementation function node.
143
+ *
144
+ * Returns null when the parameter is not a plain Identifier (e.g. when the
145
+ * author destructures `{ resource }` in the parameter list). Parameter-level
146
+ * destructuring is handled separately by `collectParamResourceAliases`.
147
+ *
148
+ * Also handles defaulted parameters like `(input, ctx = fallback) => ...`
149
+ * (AssignmentPattern whose `.left` is the Identifier). Without this, valid
150
+ * signatures would silently drop out of ctx-access analysis.
151
+ */
152
+ const extractContextParamName = (
153
+ implementationBody: AstNode
154
+ ): string | null => {
155
+ const param = extractContextParamNode(implementationBody);
156
+ if (!param) {
157
+ return null;
158
+ }
159
+ if (param.type === 'AssignmentPattern') {
160
+ const left = getNodeLeft(param);
161
+ return identifierName(left);
162
+ }
163
+ return identifierName(param);
164
+ };
165
+
166
+ /** Extract the alias name from a Property node whose key is `resource`. */
167
+ const extractResourceAlias = (property: AstNode): string | null => {
168
+ if (property.type !== 'Property') {
169
+ return null;
170
+ }
171
+ const keyName = identifierName(getNodeKey(property));
172
+ if (keyName !== 'resource') {
173
+ return null;
174
+ }
175
+ return identifierName(getNodeValueNode(property)) ?? keyName;
176
+ };
177
+
178
+ /**
179
+ * Collect `resource` aliases bound via parameter-level destructuring.
180
+ *
181
+ * Recognizes `(input, { resource }) => ...` and `(input, { resource: r }) => ...`.
182
+ * When the implementation 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 implementation signature.
206
+ * No seeded defaults: if the implementation has no second parameter, the returned set
207
+ * is empty and no `ctx.resource(...)` / `context.resource(...)` calls are
208
+ * tracked for that implementation. 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 (getNodeCallee(node) ?? null) as AstNode | null;
228
+ };
229
+
230
+ /** Extract the first identifier argument from a CallExpression. */
231
+ const extractFirstIdentifierArg = (node: AstNode): string | null => {
232
+ const args = node['arguments'] as readonly AstNode[] | undefined;
233
+ const [firstArg] = args ?? [];
234
+ return identifierName(firstArg);
235
+ };
236
+
237
+ const extractCallInfo = (
238
+ node: AstNode
239
+ ): { callee: AstNode; firstArgName: string | null } | null => {
240
+ const callee = extractCallCallee(node);
241
+ return callee
242
+ ? {
243
+ callee,
244
+ firstArgName: extractFirstIdentifierArg(node),
245
+ }
246
+ : null;
247
+ };
248
+
249
+ /** Extract `db.from(ctx)` object names. */
250
+ const extractFromCallName = (
251
+ node: AstNode,
252
+ ctxNames: ReadonlySet<string>
253
+ ): string | null => {
254
+ const call = extractCallInfo(node);
255
+ const pair = call ? extractMemberPair(call.callee) : null;
256
+
257
+ return pair &&
258
+ pair.propName === 'from' &&
259
+ call?.firstArgName &&
260
+ ctxNames.has(call.firstArgName)
261
+ ? pair.objName
262
+ : null;
263
+ };
264
+
265
+ /** Check if a callee is a member-style `ctx.resource(...)` call. */
266
+ const isMemberResourceCall = (
267
+ callee: AstNode,
268
+ ctxNames: ReadonlySet<string>
269
+ ): boolean => {
270
+ const pair = extractMemberPair(callee);
271
+ return !!pair && ctxNames.has(pair.objName) && pair.propName === 'resource';
272
+ };
273
+
274
+ /** Extract `ctx.resource(db)` and destructured `resource(db)` lookup names. */
275
+ const extractLookupResourceName = (
276
+ node: AstNode,
277
+ ctxNames: ReadonlySet<string>,
278
+ resourceAliases: ReadonlySet<string>
279
+ ): string | null => {
280
+ const callee = extractCallCallee(node);
281
+ if (!callee) {
282
+ return null;
283
+ }
284
+
285
+ if (isMemberResourceCall(callee, ctxNames)) {
286
+ return extractFirstIdentifierArg(node);
287
+ }
288
+
289
+ if (resourceAliases.has(identifierName(callee) ?? '')) {
290
+ return extractFirstIdentifierArg(node);
291
+ }
292
+
293
+ return null;
294
+ };
295
+
296
+ /** Extract `ctx.resource('id')` and destructured `resource('id')` lookup IDs. */
297
+ const extractLookupResourceId = (
298
+ node: AstNode,
299
+ ctxNames: ReadonlySet<string>,
300
+ resourceAliases: ReadonlySet<string>
301
+ ): string | null => {
302
+ const callee = extractCallCallee(node);
303
+ if (!callee) {
304
+ return null;
305
+ }
306
+
307
+ if (isMemberResourceCall(callee, ctxNames)) {
308
+ return extractFirstStringArg(node);
309
+ }
310
+
311
+ const calleeName = identifierName(callee);
312
+ const args = node['arguments'] as readonly AstNode[] | undefined;
313
+ if (calleeName && resourceAliases.has(calleeName) && args?.length === 1) {
314
+ return extractFirstStringArg(node);
315
+ }
316
+
317
+ return null;
318
+ };
319
+
320
+ /** Collect local aliases for the resource accessor (e.g. `const { resource } = ctx`). */
321
+ const collectResourceAliases = (
322
+ body: AstNode,
323
+ ctxNames: ReadonlySet<string>
324
+ ): ReadonlySet<string> => {
325
+ const aliases = new Set<string>();
326
+
327
+ const extractAliasNames = (
328
+ pattern: AstNode | undefined
329
+ ): readonly string[] => {
330
+ if (pattern?.type !== 'ObjectPattern') {
331
+ return [];
332
+ }
333
+
334
+ const properties = pattern['properties'] as readonly AstNode[] | undefined;
335
+ return (properties ?? []).flatMap((property) => {
336
+ if (property.type !== 'Property') {
337
+ return [];
338
+ }
339
+
340
+ const keyName = identifierName(getNodeKey(property));
341
+ if (keyName !== 'resource') {
342
+ return [];
343
+ }
344
+
345
+ const alias = identifierName(getNodeValueNode(property)) ?? keyName;
346
+ return [alias];
347
+ });
348
+ };
349
+
350
+ walkScope(body, (node) => {
351
+ if (node.type !== 'VariableDeclarator') {
352
+ return;
353
+ }
354
+
355
+ const id = getNodeId(node);
356
+ const init = getNodeInit(node);
357
+ const initName = identifierName(init);
358
+ if (!initName || !ctxNames.has(initName)) {
359
+ return;
360
+ }
361
+
362
+ for (const alias of extractAliasNames(id)) {
363
+ aliases.add(alias);
364
+ }
365
+ });
366
+
367
+ return aliases;
368
+ };
369
+
370
+ /** Walk implementation bodies and collect resource access that can be resolved statically. */
371
+ const extractCalledResources = (config: AstNode): CalledResources => {
372
+ const fromNames = new Set<string>();
373
+ const lookupIds = new Set<string>();
374
+ const lookupNames = new Set<string>();
375
+
376
+ for (const body of findImplementationBodies(config)) {
377
+ const ctxNames = buildCtxNames(body);
378
+ const paramAliases = collectParamResourceAliases(body);
379
+ const bodyAliases = collectResourceAliases(body, ctxNames);
380
+ const resourceAliases = new Set([...paramAliases, ...bodyAliases]);
381
+
382
+ walkScope(body, (node) => {
383
+ const fromName = extractFromCallName(node, ctxNames);
384
+ if (fromName) {
385
+ fromNames.add(fromName);
386
+ }
387
+
388
+ const lookupId = extractLookupResourceId(node, ctxNames, resourceAliases);
389
+ if (lookupId) {
390
+ lookupIds.add(lookupId);
391
+ }
392
+
393
+ const lookupName = extractLookupResourceName(
394
+ node,
395
+ ctxNames,
396
+ resourceAliases
397
+ );
398
+ if (lookupName) {
399
+ lookupNames.add(lookupName);
400
+ }
401
+ });
402
+ }
403
+
404
+ return { fromNames, lookupIds, lookupNames };
405
+ };
406
+
407
+ // ---------------------------------------------------------------------------
408
+ // Diagnostics
409
+ // ---------------------------------------------------------------------------
410
+
411
+ const renderDeclaredResource = (resource: DeclaredResource): string =>
412
+ resource.name ?? resource.id ?? '<unknown>';
413
+
414
+ const buildUndeclaredFromDiagnostic = (
415
+ trailId: string,
416
+ resourceName: string,
417
+ filePath: string,
418
+ line: number
419
+ ): WardenDiagnostic => ({
420
+ filePath,
421
+ line,
422
+ message: `Trail "${trailId}": ${resourceName}.from(ctx) called but '${resourceName}' is not declared in resources. Add it to the trail resources array: resources: [${resourceName}].`,
423
+ rule: 'resource-declarations',
424
+ severity: 'error',
425
+ });
426
+
427
+ const buildUndeclaredLookupDiagnostic = (
428
+ trailId: string,
429
+ resourceId: string,
430
+ filePath: string,
431
+ line: number
432
+ ): WardenDiagnostic => ({
433
+ filePath,
434
+ line,
435
+ 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.`,
436
+ rule: 'resource-declarations',
437
+ severity: 'error',
438
+ });
439
+
440
+ const buildUndeclaredLookupNameDiagnostic = (
441
+ trailId: string,
442
+ resourceName: string,
443
+ filePath: string,
444
+ line: number
445
+ ): WardenDiagnostic => ({
446
+ filePath,
447
+ line,
448
+ message: `Trail "${trailId}": ctx.resource(${resourceName}) called but '${resourceName}' is not declared in resources. Add it to the trail resources array: resources: [${resourceName}].`,
449
+ rule: 'resource-declarations',
450
+ severity: 'error',
451
+ });
452
+
453
+ const buildUnusedDiagnostic = (
454
+ trailId: string,
455
+ declaredResource: DeclaredResource,
456
+ filePath: string,
457
+ line: number
458
+ ): WardenDiagnostic => ({
459
+ filePath,
460
+ line,
461
+ 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.`,
462
+ rule: 'resource-declarations',
463
+ severity: 'warn',
464
+ });
465
+
466
+ // ---------------------------------------------------------------------------
467
+ // Comparison
468
+ // ---------------------------------------------------------------------------
469
+
470
+ const resourceWasUsed = (
471
+ declaredResource: DeclaredResource,
472
+ calledResources: CalledResources
473
+ ): boolean => {
474
+ if (
475
+ declaredResource.name &&
476
+ (calledResources.fromNames.has(declaredResource.name) ||
477
+ calledResources.lookupNames.has(declaredResource.name))
478
+ ) {
479
+ return true;
480
+ }
481
+
482
+ if (
483
+ declaredResource.id &&
484
+ calledResources.lookupIds.has(declaredResource.id)
485
+ ) {
486
+ return true;
487
+ }
488
+
489
+ return false;
490
+ };
491
+
492
+ const buildDeclaredNames = (
493
+ declaredResources: readonly DeclaredResource[]
494
+ ): ReadonlySet<string> =>
495
+ new Set(
496
+ declaredResources.flatMap((resource) =>
497
+ resource.name ? [resource.name] : []
498
+ )
499
+ );
500
+
501
+ const buildDeclaredIds = (
502
+ declaredResources: readonly DeclaredResource[]
503
+ ): ReadonlySet<string> =>
504
+ new Set(
505
+ declaredResources.flatMap((resource) => (resource.id ? [resource.id] : []))
506
+ );
507
+
508
+ const reportUndeclaredFromCalls = (
509
+ trailId: string,
510
+ filePath: string,
511
+ line: number,
512
+ calledResources: CalledResources,
513
+ declaredNames: ReadonlySet<string>,
514
+ diagnostics: WardenDiagnostic[]
515
+ ): void => {
516
+ for (const resourceName of calledResources.fromNames) {
517
+ if (!declaredNames.has(resourceName)) {
518
+ diagnostics.push(
519
+ buildUndeclaredFromDiagnostic(trailId, resourceName, filePath, line)
520
+ );
521
+ }
522
+ }
523
+ };
524
+
525
+ const reportUndeclaredLookupCalls = (
526
+ trailId: string,
527
+ filePath: string,
528
+ line: number,
529
+ calledResources: CalledResources,
530
+ declaredIds: ReadonlySet<string>,
531
+ declaredNames: ReadonlySet<string>,
532
+ diagnostics: WardenDiagnostic[]
533
+ ): void => {
534
+ for (const resourceName of calledResources.lookupNames) {
535
+ // Name-based lookup checks remain reliable even when an imported resource ID
536
+ // cannot be resolved locally.
537
+ if (!declaredNames.has(resourceName)) {
538
+ diagnostics.push(
539
+ buildUndeclaredLookupNameDiagnostic(
540
+ trailId,
541
+ resourceName,
542
+ filePath,
543
+ line
544
+ )
545
+ );
546
+ }
547
+ }
548
+
549
+ for (const resourceId of calledResources.lookupIds) {
550
+ if (!declaredIds.has(resourceId)) {
551
+ diagnostics.push(
552
+ buildUndeclaredLookupDiagnostic(trailId, resourceId, filePath, line)
553
+ );
554
+ }
555
+ }
556
+ };
557
+
558
+ const reportUnusedDeclarations = (
559
+ trailId: string,
560
+ filePath: string,
561
+ line: number,
562
+ declaredResources: readonly DeclaredResource[],
563
+ calledResources: CalledResources,
564
+ diagnostics: WardenDiagnostic[]
565
+ ): void => {
566
+ for (const declaredResource of declaredResources) {
567
+ if (resourceWasUsed(declaredResource, calledResources)) {
568
+ continue;
569
+ }
570
+
571
+ if (declaredResource.name && declaredResource.id === null) {
572
+ continue;
573
+ }
574
+
575
+ diagnostics.push(
576
+ buildUnusedDiagnostic(trailId, declaredResource, filePath, line)
577
+ );
578
+ }
579
+ };
580
+
581
+ const hasNoResourceActivity = (
582
+ declaredResources: readonly DeclaredResource[],
583
+ calledResources: CalledResources
584
+ ): boolean =>
585
+ declaredResources.length === 0 &&
586
+ calledResources.fromNames.size === 0 &&
587
+ calledResources.lookupIds.size === 0 &&
588
+ calledResources.lookupNames.size === 0;
589
+
590
+ const analyzeTrailServices = (
591
+ def: { config: AstNode; start: number },
592
+ sourceCode: string,
593
+ resourceIdsByName: ReadonlyMap<string, string>
594
+ ): {
595
+ readonly calledResources: CalledResources;
596
+ readonly declaredIds: ReadonlySet<string>;
597
+ readonly declaredNames: ReadonlySet<string>;
598
+ readonly declaredResources: readonly DeclaredResource[];
599
+ readonly line: number;
600
+ } => {
601
+ const declaredResources = extractDeclaredResources(
602
+ def.config,
603
+ resourceIdsByName
604
+ );
605
+ return {
606
+ calledResources: extractCalledResources(def.config),
607
+ declaredIds: buildDeclaredIds(declaredResources),
608
+ declaredNames: buildDeclaredNames(declaredResources),
609
+ declaredResources,
610
+ line: offsetToLine(sourceCode, def.start),
611
+ };
612
+ };
613
+
614
+ const checkTrailDefinition = (
615
+ def: { id: string; config: AstNode; start: number },
616
+ filePath: string,
617
+ sourceCode: string,
618
+ resourceIdsByName: ReadonlyMap<string, string>,
619
+ diagnostics: WardenDiagnostic[]
620
+ ): void => {
621
+ const {
622
+ calledResources,
623
+ declaredIds,
624
+ declaredNames,
625
+ declaredResources,
626
+ line,
627
+ } = analyzeTrailServices(def, sourceCode, resourceIdsByName);
628
+
629
+ if (hasNoResourceActivity(declaredResources, calledResources)) {
630
+ return;
631
+ }
632
+
633
+ reportUndeclaredFromCalls(
634
+ def.id,
635
+ filePath,
636
+ line,
637
+ calledResources,
638
+ declaredNames,
639
+ diagnostics
640
+ );
641
+ reportUndeclaredLookupCalls(
642
+ def.id,
643
+ filePath,
644
+ line,
645
+ calledResources,
646
+ declaredIds,
647
+ declaredNames,
648
+ diagnostics
649
+ );
650
+ reportUnusedDeclarations(
651
+ def.id,
652
+ filePath,
653
+ line,
654
+ declaredResources,
655
+ calledResources,
656
+ diagnostics
657
+ );
658
+ };
659
+
660
+ // ---------------------------------------------------------------------------
661
+ // Rule
662
+ // ---------------------------------------------------------------------------
663
+
664
+ /**
665
+ * Validates that resource access aligns with declared `resources` arrays.
666
+ */
667
+ export const resourceDeclarations: WardenRule = {
668
+ check(sourceCode: string, filePath: string): readonly WardenDiagnostic[] {
669
+ if (isTestFile(filePath)) {
670
+ return [];
671
+ }
672
+
673
+ const ast = parse(filePath, sourceCode);
674
+ if (!ast) {
675
+ return [];
676
+ }
677
+
678
+ const diagnostics: WardenDiagnostic[] = [];
679
+ const resourceIdsByName = collectNamedResourceIds(ast);
680
+
681
+ for (const def of findTrailDefinitions(ast)) {
682
+ checkTrailDefinition(
683
+ def,
684
+ filePath,
685
+ sourceCode,
686
+ resourceIdsByName,
687
+ diagnostics
688
+ );
689
+ }
690
+
691
+ return diagnostics;
692
+ },
693
+ description:
694
+ 'Ensure resource.from(ctx) and ctx.resource() calls match the declared resources array in trail definitions.',
695
+ name: 'resource-declarations',
696
+ severity: 'error',
697
+ };