@ontrails/warden 1.0.0-beta.4 → 1.0.0-beta.42

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 +837 -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,653 @@
1
+ /**
2
+ * Prefers static resource definition helpers over dynamic context lookups.
3
+ *
4
+ * The rule intentionally stays advisory and narrow: it only warns when the
5
+ * trail already has a statically declared resource definition in `resources`.
6
+ * Dynamic IDs and generic framework internals remain outside its scope.
7
+ */
8
+
9
+ import { collectNamedResourceIds } from './source/resources.js';
10
+ import {
11
+ extractFirstStringArg,
12
+ findConfigProperty,
13
+ findImplementationBodies,
14
+ findTrailDefinitions,
15
+ getNodeCallee,
16
+ getNodeId,
17
+ getNodeImported,
18
+ getNodeInit,
19
+ getNodeKey,
20
+ getNodeLeft,
21
+ getNodeLocal,
22
+ getNodeObject,
23
+ getNodeProperty,
24
+ getNodeSource,
25
+ getNodeValue,
26
+ getNodeValueNode,
27
+ getStringValue,
28
+ identifierName,
29
+ isStringLiteral,
30
+ offsetToLine,
31
+ parse,
32
+ walk,
33
+ walkScope,
34
+ walkWithScopes,
35
+ } from '@ontrails/source';
36
+ import type { AstNode } from '@ontrails/source';
37
+ import { isFrameworkInternalFile, isTestFile } from './scan.js';
38
+ import type { WardenDiagnostic, WardenRule } from './types.js';
39
+
40
+ const RULE_NAME = 'static-resource-accessor-preference';
41
+
42
+ const MEMBER_TYPES = new Set(['StaticMemberExpression', 'MemberExpression']);
43
+
44
+ const NAMED_DEPENDENCY_CONSTRUCTORS = new Map<string, ReadonlySet<string>>([
45
+ ['@prisma/client', new Set(['PrismaClient'])],
46
+ ['pg', new Set(['Pool', 'Client'])],
47
+ ['mongodb', new Set(['MongoClient'])],
48
+ ['ioredis', new Set(['Redis'])],
49
+ ]);
50
+
51
+ interface DeclaredStaticResource {
52
+ readonly id: string | null;
53
+ readonly name: string;
54
+ }
55
+
56
+ interface ResourceLookup {
57
+ readonly id: string | null;
58
+ readonly name: string | null;
59
+ readonly rendered: string;
60
+ readonly shadowedDeclaredNames: ReadonlySet<string>;
61
+ readonly start: number;
62
+ }
63
+
64
+ interface InlineDependencyConstruction {
65
+ readonly name: string;
66
+ readonly rendered: string;
67
+ readonly start: number;
68
+ }
69
+
70
+ const isShadowedModuleBinding = (
71
+ name: string | null,
72
+ scopes: readonly ReadonlySet<string>[]
73
+ ): boolean => {
74
+ if (!name) {
75
+ return false;
76
+ }
77
+ for (let i = 0; i < scopes.length - 1; i += 1) {
78
+ const frame = scopes[i];
79
+ if (frame?.has(name)) {
80
+ return true;
81
+ }
82
+ }
83
+ return false;
84
+ };
85
+
86
+ const extractMemberPair = (
87
+ callee: AstNode
88
+ ): { readonly objName: string; readonly propName: string } | null => {
89
+ if (!MEMBER_TYPES.has(callee.type)) {
90
+ return null;
91
+ }
92
+
93
+ const objName = identifierName(getNodeObject(callee));
94
+ const propName = identifierName(getNodeProperty(callee));
95
+
96
+ return objName && propName ? { objName, propName } : null;
97
+ };
98
+
99
+ const getResourceElements = (config: AstNode): readonly AstNode[] => {
100
+ const resourcesProp = findConfigProperty(config, 'resources');
101
+ if (!resourcesProp) {
102
+ return [];
103
+ }
104
+
105
+ const arrayNode = resourcesProp.value;
106
+ if (!arrayNode || (arrayNode as AstNode).type !== 'ArrayExpression') {
107
+ return [];
108
+ }
109
+
110
+ return (
111
+ ((arrayNode as AstNode)['elements'] as readonly AstNode[] | undefined) ?? []
112
+ );
113
+ };
114
+
115
+ const extractDeclaredStaticResources = (
116
+ config: AstNode,
117
+ resourceIdsByName: ReadonlyMap<string, string>
118
+ ): readonly DeclaredStaticResource[] =>
119
+ getResourceElements(config).flatMap((element) => {
120
+ if (element.type !== 'Identifier') {
121
+ return [];
122
+ }
123
+
124
+ const name = identifierName(element);
125
+ return name ? [{ id: resourceIdsByName.get(name) ?? null, name }] : [];
126
+ });
127
+
128
+ const extractContextParamNode = (
129
+ implementationBody: AstNode
130
+ ): AstNode | null => {
131
+ const params = implementationBody['params'] as readonly AstNode[] | undefined;
132
+ if (!params || params.length < 2) {
133
+ return null;
134
+ }
135
+ return params[1] ?? null;
136
+ };
137
+
138
+ const extractContextParamName = (
139
+ implementationBody: AstNode
140
+ ): string | null => {
141
+ const param = extractContextParamNode(implementationBody);
142
+ if (!param) {
143
+ return null;
144
+ }
145
+ if (param.type === 'AssignmentPattern') {
146
+ return identifierName(getNodeLeft(param));
147
+ }
148
+ return identifierName(param);
149
+ };
150
+
151
+ const extractResourceAlias = (property: AstNode): string | null => {
152
+ if (property.type !== 'Property') {
153
+ return null;
154
+ }
155
+
156
+ const keyName = identifierName(getNodeKey(property));
157
+ if (keyName !== 'resource') {
158
+ return null;
159
+ }
160
+
161
+ return identifierName(getNodeValueNode(property)) ?? keyName;
162
+ };
163
+
164
+ const collectParamResourceAliases = (body: AstNode): ReadonlySet<string> => {
165
+ const param = extractContextParamNode(body);
166
+ if (!param || param.type !== 'ObjectPattern') {
167
+ return new Set();
168
+ }
169
+
170
+ const aliases = new Set<string>();
171
+ const properties = param['properties'] as readonly AstNode[] | undefined;
172
+ for (const property of properties ?? []) {
173
+ const alias = extractResourceAlias(property);
174
+ if (alias) {
175
+ aliases.add(alias);
176
+ }
177
+ }
178
+ return aliases;
179
+ };
180
+
181
+ const buildCtxNames = (body: AstNode): ReadonlySet<string> => {
182
+ const ctxNames = new Set<string>();
183
+ const paramName = extractContextParamName(body);
184
+ if (paramName) {
185
+ ctxNames.add(paramName);
186
+ }
187
+ return ctxNames;
188
+ };
189
+
190
+ const extractObjectPatternAliases = (
191
+ pattern: AstNode | undefined
192
+ ): readonly string[] => {
193
+ if (pattern?.type !== 'ObjectPattern') {
194
+ return [];
195
+ }
196
+
197
+ const properties = pattern['properties'] as readonly AstNode[] | undefined;
198
+ return (properties ?? []).flatMap((property) => {
199
+ const alias = extractResourceAlias(property);
200
+ return alias ? [alias] : [];
201
+ });
202
+ };
203
+
204
+ const collectResourceAliases = (
205
+ body: AstNode,
206
+ ctxNames: ReadonlySet<string>
207
+ ): ReadonlySet<string> => {
208
+ const aliases = new Set<string>();
209
+
210
+ walkScope(body, (node) => {
211
+ if (node.type !== 'VariableDeclarator') {
212
+ return;
213
+ }
214
+
215
+ const id = getNodeId(node);
216
+ const init = getNodeInit(node);
217
+ const initName = identifierName(init);
218
+ if (!initName || !ctxNames.has(initName)) {
219
+ return;
220
+ }
221
+
222
+ for (const alias of extractObjectPatternAliases(id)) {
223
+ aliases.add(alias);
224
+ }
225
+ });
226
+
227
+ return aliases;
228
+ };
229
+
230
+ const extractCallCallee = (node: AstNode): AstNode | null => {
231
+ if (node.type !== 'CallExpression') {
232
+ return null;
233
+ }
234
+ return (getNodeCallee(node) ?? null) as AstNode | null;
235
+ };
236
+
237
+ const extractFirstArg = (node: AstNode): AstNode | null => {
238
+ if (node.type !== 'CallExpression') {
239
+ return null;
240
+ }
241
+ const args = node['arguments'] as readonly AstNode[] | undefined;
242
+ return args?.[0] ?? null;
243
+ };
244
+
245
+ const renderStringArg = (value: string): string =>
246
+ `'${value.replaceAll("'", "\\'")}'`;
247
+
248
+ const renderResourceArg = (node: AstNode | null): string | null => {
249
+ if (!node) {
250
+ return null;
251
+ }
252
+ const name = identifierName(node);
253
+ if (name) {
254
+ return name;
255
+ }
256
+ return isStringLiteral(node)
257
+ ? renderStringArg(getStringValue(node) ?? '')
258
+ : null;
259
+ };
260
+
261
+ const extractFirstIdentifierArg = (node: AstNode): string | null =>
262
+ identifierName(extractFirstArg(node) ?? undefined);
263
+
264
+ const isMemberResourceCall = (
265
+ callee: AstNode,
266
+ ctxNames: ReadonlySet<string>
267
+ ): { readonly ctxName: string } | null => {
268
+ const pair = extractMemberPair(callee);
269
+ return pair && ctxNames.has(pair.objName) && pair.propName === 'resource'
270
+ ? { ctxName: pair.objName }
271
+ : null;
272
+ };
273
+
274
+ const extractResourceLookup = (
275
+ node: AstNode,
276
+ ctxNames: ReadonlySet<string>,
277
+ resourceAliases: ReadonlySet<string>
278
+ ): ResourceLookup | null => {
279
+ const callee = extractCallCallee(node);
280
+ if (!callee) {
281
+ return null;
282
+ }
283
+
284
+ const arg = extractFirstArg(node);
285
+ const renderedArg = renderResourceArg(arg);
286
+ if (!renderedArg) {
287
+ return null;
288
+ }
289
+
290
+ const memberCall = isMemberResourceCall(callee, ctxNames);
291
+ if (memberCall) {
292
+ return {
293
+ id: extractFirstStringArg(node),
294
+ name: extractFirstIdentifierArg(node),
295
+ rendered: `${memberCall.ctxName}.resource(${renderedArg})`,
296
+ shadowedDeclaredNames: new Set(),
297
+ start: node.start,
298
+ };
299
+ }
300
+
301
+ const calleeName = identifierName(callee);
302
+ if (calleeName && resourceAliases.has(calleeName)) {
303
+ return {
304
+ id: extractFirstStringArg(node),
305
+ name: extractFirstIdentifierArg(node),
306
+ rendered: `${calleeName}(${renderedArg})`,
307
+ shadowedDeclaredNames: new Set(),
308
+ start: node.start,
309
+ };
310
+ }
311
+
312
+ return null;
313
+ };
314
+
315
+ const buildDeclaredNameSet = (
316
+ resources: readonly DeclaredStaticResource[]
317
+ ): ReadonlySet<string> => new Set(resources.map((resource) => resource.name));
318
+
319
+ const collectShadowedNames = (
320
+ names: ReadonlySet<string>,
321
+ scopes: readonly ReadonlySet<string>[]
322
+ ): ReadonlySet<string> => {
323
+ const shadowed = new Set<string>();
324
+ for (const name of names) {
325
+ if (isShadowedModuleBinding(name, scopes)) {
326
+ shadowed.add(name);
327
+ }
328
+ }
329
+ return shadowed;
330
+ };
331
+
332
+ const buildDeclaredNameById = (
333
+ resources: readonly DeclaredStaticResource[]
334
+ ): ReadonlyMap<string, string> =>
335
+ new Map(
336
+ resources.flatMap((resource) =>
337
+ resource.id ? [[resource.id, resource.name] as const] : []
338
+ )
339
+ );
340
+
341
+ const collectResourceLookups = (
342
+ config: AstNode,
343
+ declaredNames: ReadonlySet<string>
344
+ ): readonly ResourceLookup[] => {
345
+ const lookups: ResourceLookup[] = [];
346
+
347
+ for (const body of findImplementationBodies(config)) {
348
+ const ctxNames = buildCtxNames(body);
349
+ const resourceAliases = new Set([
350
+ ...collectParamResourceAliases(body),
351
+ ...collectResourceAliases(body, ctxNames),
352
+ ]);
353
+
354
+ walkWithScopes(
355
+ body,
356
+ (node, scopes) => {
357
+ const lookup = extractResourceLookup(node, ctxNames, resourceAliases);
358
+ if (lookup && !isShadowedModuleBinding(lookup.name, scopes)) {
359
+ lookups.push({
360
+ ...lookup,
361
+ shadowedDeclaredNames: collectShadowedNames(declaredNames, scopes),
362
+ });
363
+ }
364
+ },
365
+ {
366
+ initialScopes: [declaredNames],
367
+ stopAtNestedFunctions: true,
368
+ }
369
+ );
370
+ }
371
+
372
+ return lookups;
373
+ };
374
+
375
+ const getImportSourceValue = (node: AstNode): string | null => {
376
+ const sourceNode = getNodeSource(node);
377
+ const value = sourceNode ? getNodeValue(sourceNode) : null;
378
+ return typeof value === 'string' ? value : null;
379
+ };
380
+
381
+ const addNamedDependencyConstructors = (
382
+ source: string,
383
+ specifier: AstNode,
384
+ constructors: Set<string>
385
+ ): void => {
386
+ if (specifier.type !== 'ImportSpecifier') {
387
+ return;
388
+ }
389
+
390
+ const imported = getNodeImported(specifier);
391
+ const local = getNodeLocal(specifier);
392
+ const importedName =
393
+ identifierName(imported) ??
394
+ (imported && isStringLiteral(imported) ? getStringValue(imported) : null);
395
+ const localName = identifierName(local);
396
+ if (!importedName || !localName) {
397
+ return;
398
+ }
399
+
400
+ if (
401
+ source.startsWith('@aws-sdk/client-') &&
402
+ importedName.endsWith('Client')
403
+ ) {
404
+ constructors.add(localName);
405
+ return;
406
+ }
407
+
408
+ const names = NAMED_DEPENDENCY_CONSTRUCTORS.get(source);
409
+ if (names?.has(importedName)) {
410
+ constructors.add(localName);
411
+ }
412
+ };
413
+
414
+ const addDefaultDependencyConstructors = (
415
+ source: string,
416
+ specifier: AstNode,
417
+ constructors: Set<string>
418
+ ): void => {
419
+ if (specifier.type !== 'ImportDefaultSpecifier' || source !== 'ioredis') {
420
+ return;
421
+ }
422
+
423
+ const localName = identifierName(getNodeLocal(specifier));
424
+ if (localName) {
425
+ constructors.add(localName);
426
+ }
427
+ };
428
+
429
+ const collectDependencyConstructors = (ast: AstNode): ReadonlySet<string> => {
430
+ const constructors = new Set<string>();
431
+
432
+ walk(ast, (node) => {
433
+ if (node.type !== 'ImportDeclaration') {
434
+ return;
435
+ }
436
+
437
+ const source = getImportSourceValue(node);
438
+ if (!source) {
439
+ return;
440
+ }
441
+
442
+ const specifiers = node['specifiers'] as readonly AstNode[] | undefined;
443
+ for (const specifier of specifiers ?? []) {
444
+ addNamedDependencyConstructors(source, specifier, constructors);
445
+ addDefaultDependencyConstructors(source, specifier, constructors);
446
+ }
447
+ });
448
+
449
+ return constructors;
450
+ };
451
+
452
+ const extractInlineDependencyConstruction = (
453
+ node: AstNode,
454
+ dependencyConstructors: ReadonlySet<string>
455
+ ): InlineDependencyConstruction | null => {
456
+ if (node.type !== 'NewExpression') {
457
+ return null;
458
+ }
459
+
460
+ const ctorName = identifierName(getNodeCallee(node));
461
+ return ctorName && dependencyConstructors.has(ctorName)
462
+ ? { name: ctorName, rendered: `new ${ctorName}(...)`, start: node.start }
463
+ : null;
464
+ };
465
+
466
+ const collectInlineDependencyConstructions = (
467
+ config: AstNode,
468
+ dependencyConstructors: ReadonlySet<string>
469
+ ): readonly InlineDependencyConstruction[] => {
470
+ const constructions: InlineDependencyConstruction[] = [];
471
+
472
+ for (const body of findImplementationBodies(config)) {
473
+ walkWithScopes(
474
+ body,
475
+ (node, scopes) => {
476
+ const construction = extractInlineDependencyConstruction(
477
+ node,
478
+ dependencyConstructors
479
+ );
480
+ if (
481
+ construction &&
482
+ !isShadowedModuleBinding(construction.name, scopes)
483
+ ) {
484
+ constructions.push(construction);
485
+ }
486
+ },
487
+ {
488
+ initialScopes: [dependencyConstructors],
489
+ stopAtNestedFunctions: true,
490
+ }
491
+ );
492
+ }
493
+
494
+ return constructions;
495
+ };
496
+
497
+ const buildAccessorDiagnostic = (
498
+ trailId: string,
499
+ lookup: ResourceLookup,
500
+ resourceName: string,
501
+ filePath: string,
502
+ sourceCode: string
503
+ ): WardenDiagnostic => ({
504
+ filePath,
505
+ line: offsetToLine(sourceCode, lookup.start),
506
+ message:
507
+ `Trail "${trailId}": ${lookup.rendered} uses a dynamic resource accessor ` +
508
+ `for statically declared resource '${resourceName}'. Prefer ${resourceName}.from(ctx) ` +
509
+ 'so the dependency stays type-directed.',
510
+ rule: RULE_NAME,
511
+ severity: 'warn',
512
+ });
513
+
514
+ const buildInlineDependencyDiagnostic = (
515
+ trailId: string,
516
+ construction: InlineDependencyConstruction,
517
+ filePath: string,
518
+ sourceCode: string
519
+ ): WardenDiagnostic => ({
520
+ filePath,
521
+ line: offsetToLine(sourceCode, construction.start),
522
+ message:
523
+ `Trail "${trailId}": ${construction.rendered} constructs an external dependency ` +
524
+ 'inside implementation logic. Move the client behind a resource definition and declare it in resources.',
525
+ rule: RULE_NAME,
526
+ severity: 'warn',
527
+ });
528
+
529
+ const reportAccessorLookups = (
530
+ trailId: string,
531
+ filePath: string,
532
+ sourceCode: string,
533
+ declaredResources: readonly DeclaredStaticResource[],
534
+ lookups: readonly ResourceLookup[],
535
+ diagnostics: WardenDiagnostic[]
536
+ ): void => {
537
+ const declaredNames = buildDeclaredNameSet(declaredResources);
538
+ const declaredNameById = buildDeclaredNameById(declaredResources);
539
+
540
+ for (const lookup of lookups) {
541
+ const resourceName =
542
+ (lookup.name && declaredNames.has(lookup.name) ? lookup.name : null) ??
543
+ (lookup.id ? (declaredNameById.get(lookup.id) ?? null) : null);
544
+
545
+ if (!resourceName) {
546
+ continue;
547
+ }
548
+ if (lookup.shadowedDeclaredNames.has(resourceName)) {
549
+ continue;
550
+ }
551
+
552
+ diagnostics.push(
553
+ buildAccessorDiagnostic(
554
+ trailId,
555
+ lookup,
556
+ resourceName,
557
+ filePath,
558
+ sourceCode
559
+ )
560
+ );
561
+ }
562
+ };
563
+
564
+ const reportInlineDependencyConstructions = (
565
+ trailId: string,
566
+ filePath: string,
567
+ sourceCode: string,
568
+ constructions: readonly InlineDependencyConstruction[],
569
+ diagnostics: WardenDiagnostic[]
570
+ ): void => {
571
+ for (const construction of constructions) {
572
+ diagnostics.push(
573
+ buildInlineDependencyDiagnostic(
574
+ trailId,
575
+ construction,
576
+ filePath,
577
+ sourceCode
578
+ )
579
+ );
580
+ }
581
+ };
582
+
583
+ const checkTrailDefinition = (
584
+ def: { readonly config: AstNode; readonly id: string },
585
+ filePath: string,
586
+ sourceCode: string,
587
+ resourceIdsByName: ReadonlyMap<string, string>,
588
+ dependencyConstructors: ReadonlySet<string>,
589
+ diagnostics: WardenDiagnostic[]
590
+ ): void => {
591
+ const declaredResources = extractDeclaredStaticResources(
592
+ def.config,
593
+ resourceIdsByName
594
+ );
595
+ const lookups = collectResourceLookups(
596
+ def.config,
597
+ buildDeclaredNameSet(declaredResources)
598
+ );
599
+ reportAccessorLookups(
600
+ def.id,
601
+ filePath,
602
+ sourceCode,
603
+ declaredResources,
604
+ lookups,
605
+ diagnostics
606
+ );
607
+
608
+ const constructions = collectInlineDependencyConstructions(
609
+ def.config,
610
+ dependencyConstructors
611
+ );
612
+ reportInlineDependencyConstructions(
613
+ def.id,
614
+ filePath,
615
+ sourceCode,
616
+ constructions,
617
+ diagnostics
618
+ );
619
+ };
620
+
621
+ export const staticResourceAccessorPreference: WardenRule = {
622
+ check(sourceCode: string, filePath: string): readonly WardenDiagnostic[] {
623
+ if (isTestFile(filePath) || isFrameworkInternalFile(filePath)) {
624
+ return [];
625
+ }
626
+
627
+ const ast = parse(filePath, sourceCode);
628
+ if (!ast) {
629
+ return [];
630
+ }
631
+
632
+ const diagnostics: WardenDiagnostic[] = [];
633
+ const resourceIdsByName = collectNamedResourceIds(ast);
634
+ const dependencyConstructors = collectDependencyConstructors(ast);
635
+
636
+ for (const def of findTrailDefinitions(ast)) {
637
+ checkTrailDefinition(
638
+ def,
639
+ filePath,
640
+ sourceCode,
641
+ resourceIdsByName,
642
+ dependencyConstructors,
643
+ diagnostics
644
+ );
645
+ }
646
+
647
+ return diagnostics;
648
+ },
649
+ description:
650
+ 'Prefer static resource.from(ctx) helpers over dynamic ctx.resource() lookups when the resource definition is already in scope.',
651
+ name: RULE_NAME,
652
+ severity: 'warn',
653
+ };