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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (249) hide show
  1. package/CHANGELOG.md +508 -6
  2. package/README.md +77 -26
  3. package/bin/warden.ts +50 -0
  4. package/package.json +27 -5
  5. package/src/adapter-check.ts +136 -0
  6. package/src/ast.ts +28 -0
  7. package/src/cli.ts +1374 -103
  8. package/src/command.ts +953 -0
  9. package/src/config.ts +184 -0
  10. package/src/draft.ts +22 -0
  11. package/src/drift.ts +106 -22
  12. package/src/fix.ts +120 -0
  13. package/src/formatters.ts +79 -9
  14. package/src/guide.ts +245 -0
  15. package/src/index.ts +206 -14
  16. package/src/project-context.ts +163 -0
  17. package/src/resolve.ts +530 -0
  18. package/src/rules/activation-orphan.ts +97 -0
  19. package/src/rules/ast.ts +3176 -85
  20. package/src/rules/circular-refs.ts +154 -0
  21. package/src/rules/composes-declarations.ts +704 -0
  22. package/src/rules/context-no-surface-types.ts +68 -8
  23. package/src/rules/contour-exists.ts +251 -0
  24. package/src/rules/contour-ids.ts +15 -0
  25. package/src/rules/dead-internal-trail.ts +154 -0
  26. package/src/rules/draft-file-marking.ts +160 -0
  27. package/src/rules/draft-visible-debt.ts +87 -0
  28. package/src/rules/error-mapping-completeness.ts +288 -0
  29. package/src/rules/example-valid.ts +401 -0
  30. package/src/rules/fires-declarations.ts +758 -0
  31. package/src/rules/implementation-returns-result.ts +1265 -95
  32. package/src/rules/incomplete-accessor-for-standard-op.ts +272 -0
  33. package/src/rules/incomplete-crud.ts +580 -0
  34. package/src/rules/index.ts +219 -18
  35. package/src/rules/intent-propagation.ts +127 -0
  36. package/src/rules/layer-field-name-drift.ts +96 -0
  37. package/src/rules/metadata.ts +654 -0
  38. package/src/rules/missing-reconcile.ts +98 -0
  39. package/src/rules/missing-visibility.ts +110 -0
  40. package/src/rules/no-destructured-compose.ts +192 -0
  41. package/src/rules/no-dev-permit-in-source.ts +99 -0
  42. package/src/rules/no-direct-implementation-call.ts +7 -7
  43. package/src/rules/no-legacy-layer-imports.ts +211 -0
  44. package/src/rules/no-native-error-result.ts +111 -0
  45. package/src/rules/no-redundant-result-error-wrap.ts +331 -0
  46. package/src/rules/no-retired-cross-vocabulary.ts +194 -0
  47. package/src/rules/no-sync-result-assumption.ts +1134 -99
  48. package/src/rules/no-throw-in-detour-recover.ts +225 -0
  49. package/src/rules/no-throw-in-implementation.ts +10 -9
  50. package/src/rules/no-top-level-surface.ts +389 -0
  51. package/src/rules/on-references-exist.ts +194 -0
  52. package/src/rules/orphaned-signal.ts +150 -0
  53. package/src/rules/owner-projection-parity.ts +146 -0
  54. package/src/rules/permit-governance.ts +25 -0
  55. package/src/rules/public-export-example-coverage.ts +553 -0
  56. package/src/rules/public-internal-deep-imports.ts +517 -0
  57. package/src/rules/public-output-schema.ts +29 -0
  58. package/src/rules/public-union-output-discriminants.ts +150 -0
  59. package/src/rules/read-intent-fires.ts +187 -0
  60. package/src/rules/reference-exists.ts +98 -0
  61. package/src/rules/registry-names.ts +145 -0
  62. package/src/rules/resolved-import-boundary.ts +146 -0
  63. package/src/rules/resource-declarations.ts +704 -0
  64. package/src/rules/resource-exists.ts +179 -0
  65. package/src/rules/resource-id-grammar.ts +65 -0
  66. package/src/rules/resource-mock-coverage.ts +115 -0
  67. package/src/rules/scan.ts +38 -25
  68. package/src/rules/scheduled-destroy-intent.ts +44 -0
  69. package/src/rules/signal-graph-coaching.ts +191 -0
  70. package/src/rules/specs.ts +9 -5
  71. package/src/rules/static-resource-accessor-preference.ts +657 -0
  72. package/src/rules/surface-facet-coherence.ts +370 -0
  73. package/src/rules/trail-versioning-source.ts +1094 -0
  74. package/src/rules/trail-versioning-topo.ts +172 -0
  75. package/src/rules/types.ts +270 -6
  76. package/src/rules/unmaterialized-activation-source.ts +84 -0
  77. package/src/rules/unreachable-detour-shadowing.ts +344 -0
  78. package/src/rules/valid-describe-refs.ts +160 -32
  79. package/src/rules/valid-detour-contract.ts +78 -0
  80. package/src/rules/warden-export-symmetry.ts +533 -0
  81. package/src/rules/warden-rules-use-ast.ts +996 -0
  82. package/src/rules/webhook-route-collision.ts +243 -0
  83. package/src/trails/activation-orphan.trail.ts +84 -0
  84. package/src/trails/circular-refs.trail.ts +29 -0
  85. package/src/trails/composes-declarations.trail.ts +22 -0
  86. package/src/trails/context-no-surface-types.trail.ts +21 -0
  87. package/src/trails/contour-exists.trail.ts +21 -0
  88. package/src/trails/dead-internal-trail.trail.ts +26 -0
  89. package/src/trails/deprecation-without-guidance.trail.ts +21 -0
  90. package/src/trails/draft-file-marking.trail.ts +16 -0
  91. package/src/trails/draft-visible-debt.trail.ts +16 -0
  92. package/src/trails/error-mapping-completeness.trail.ts +29 -0
  93. package/src/trails/example-valid.trail.ts +25 -0
  94. package/src/trails/fires-declarations.trail.ts +23 -0
  95. package/src/trails/fork-without-preserved-blaze.trail.ts +31 -0
  96. package/src/trails/implementation-returns-result.trail.ts +20 -0
  97. package/src/trails/incomplete-accessor-for-standard-op.trail.ts +76 -0
  98. package/src/trails/incomplete-crud.trail.ts +39 -0
  99. package/src/trails/index.ts +78 -0
  100. package/src/trails/intent-propagation.trail.ts +30 -0
  101. package/src/trails/layer-field-name-drift.trail.ts +39 -0
  102. package/src/trails/marker-schema-unsupported.trail.ts +23 -0
  103. package/src/trails/missing-reconcile.trail.ts +33 -0
  104. package/src/trails/missing-visibility.trail.ts +22 -0
  105. package/src/trails/no-destructured-compose.trail.ts +44 -0
  106. package/src/trails/no-dev-permit-in-source.trail.ts +16 -0
  107. package/src/trails/no-direct-implementation-call.trail.ts +16 -0
  108. package/src/trails/no-legacy-layer-imports.trail.ts +41 -0
  109. package/src/trails/no-native-error-result.trail.ts +18 -0
  110. package/src/trails/no-redundant-result-error-wrap.trail.ts +55 -0
  111. package/src/trails/no-retired-cross-vocabulary.trail.ts +42 -0
  112. package/src/trails/no-sync-result-assumption.trail.ts +19 -0
  113. package/src/trails/no-throw-in-detour-recover.trail.ts +24 -0
  114. package/src/trails/no-throw-in-implementation.trail.ts +20 -0
  115. package/src/trails/no-top-level-surface.trail.ts +43 -0
  116. package/src/trails/on-references-exist.trail.ts +21 -0
  117. package/src/trails/orphaned-signal.trail.ts +36 -0
  118. package/src/trails/owner-projection-parity.trail.ts +26 -0
  119. package/src/trails/pending-force.trail.ts +21 -0
  120. package/src/trails/permit-governance.trail.ts +51 -0
  121. package/src/trails/prefer-schema-inference.trail.ts +21 -0
  122. package/src/trails/public-export-example-coverage.trail.ts +16 -0
  123. package/src/trails/public-internal-deep-imports.trail.ts +94 -0
  124. package/src/trails/public-output-schema.trail.ts +55 -0
  125. package/src/trails/public-union-output-discriminants.trail.ts +33 -0
  126. package/src/trails/read-intent-fires.trail.ts +20 -0
  127. package/src/trails/reference-exists.trail.ts +25 -0
  128. package/src/trails/resolved-import-boundary.trail.ts +109 -0
  129. package/src/trails/resource-declarations.trail.ts +25 -0
  130. package/src/trails/resource-exists.trail.ts +27 -0
  131. package/src/trails/resource-id-grammar.trail.ts +39 -0
  132. package/src/trails/resource-mock-coverage.trail.ts +40 -0
  133. package/src/trails/run.ts +162 -0
  134. package/src/trails/scheduled-destroy-intent.trail.ts +56 -0
  135. package/src/trails/schema.ts +194 -0
  136. package/src/trails/signal-graph-coaching.trail.ts +77 -0
  137. package/src/trails/static-resource-accessor-preference.trail.ts +25 -0
  138. package/src/trails/surface-facet-coherence.trail.ts +25 -0
  139. package/src/trails/topo.ts +6 -0
  140. package/src/trails/unmaterialized-activation-source.trail.ts +72 -0
  141. package/src/trails/unreachable-detour-shadowing.trail.ts +45 -0
  142. package/src/trails/valid-describe-refs.trail.ts +18 -0
  143. package/src/trails/valid-detour-contract.trail.ts +71 -0
  144. package/src/trails/version-gap.trail.ts +35 -0
  145. package/src/trails/version-pinned-compose.trail.ts +23 -0
  146. package/src/trails/version-without-examples.trail.ts +38 -0
  147. package/src/trails/warden-export-symmetry.trail.ts +16 -0
  148. package/src/trails/warden-rules-use-ast.trail.ts +45 -0
  149. package/src/trails/webhook-route-collision.trail.ts +50 -0
  150. package/src/trails/wrap-rule.ts +213 -0
  151. package/src/workspaces.ts +238 -0
  152. package/.turbo/turbo-build.log +0 -1
  153. package/.turbo/turbo-lint.log +0 -3
  154. package/.turbo/turbo-typecheck.log +0 -1
  155. package/dist/cli.d.ts +0 -46
  156. package/dist/cli.d.ts.map +0 -1
  157. package/dist/cli.js +0 -221
  158. package/dist/cli.js.map +0 -1
  159. package/dist/drift.d.ts +0 -26
  160. package/dist/drift.d.ts.map +0 -1
  161. package/dist/drift.js +0 -27
  162. package/dist/drift.js.map +0 -1
  163. package/dist/formatters.d.ts +0 -29
  164. package/dist/formatters.d.ts.map +0 -1
  165. package/dist/formatters.js +0 -87
  166. package/dist/formatters.js.map +0 -1
  167. package/dist/index.d.ts +0 -26
  168. package/dist/index.d.ts.map +0 -1
  169. package/dist/index.js +0 -26
  170. package/dist/index.js.map +0 -1
  171. package/dist/rules/ast.d.ts +0 -41
  172. package/dist/rules/ast.d.ts.map +0 -1
  173. package/dist/rules/ast.js +0 -163
  174. package/dist/rules/ast.js.map +0 -1
  175. package/dist/rules/context-no-surface-types.d.ts +0 -12
  176. package/dist/rules/context-no-surface-types.d.ts.map +0 -1
  177. package/dist/rules/context-no-surface-types.js +0 -96
  178. package/dist/rules/context-no-surface-types.js.map +0 -1
  179. package/dist/rules/implementation-returns-result.d.ts +0 -13
  180. package/dist/rules/implementation-returns-result.d.ts.map +0 -1
  181. package/dist/rules/implementation-returns-result.js +0 -231
  182. package/dist/rules/implementation-returns-result.js.map +0 -1
  183. package/dist/rules/index.d.ts +0 -22
  184. package/dist/rules/index.d.ts.map +0 -1
  185. package/dist/rules/index.js +0 -41
  186. package/dist/rules/index.js.map +0 -1
  187. package/dist/rules/no-direct-impl-in-route.d.ts +0 -12
  188. package/dist/rules/no-direct-impl-in-route.d.ts.map +0 -1
  189. package/dist/rules/no-direct-impl-in-route.js +0 -46
  190. package/dist/rules/no-direct-impl-in-route.js.map +0 -1
  191. package/dist/rules/no-direct-implementation-call.d.ts +0 -12
  192. package/dist/rules/no-direct-implementation-call.d.ts.map +0 -1
  193. package/dist/rules/no-direct-implementation-call.js +0 -39
  194. package/dist/rules/no-direct-implementation-call.js.map +0 -1
  195. package/dist/rules/no-sync-result-assumption.d.ts +0 -6
  196. package/dist/rules/no-sync-result-assumption.d.ts.map +0 -1
  197. package/dist/rules/no-sync-result-assumption.js +0 -98
  198. package/dist/rules/no-sync-result-assumption.js.map +0 -1
  199. package/dist/rules/no-throw-in-detour-target.d.ts +0 -12
  200. package/dist/rules/no-throw-in-detour-target.d.ts.map +0 -1
  201. package/dist/rules/no-throw-in-detour-target.js +0 -87
  202. package/dist/rules/no-throw-in-detour-target.js.map +0 -1
  203. package/dist/rules/no-throw-in-implementation.d.ts +0 -9
  204. package/dist/rules/no-throw-in-implementation.d.ts.map +0 -1
  205. package/dist/rules/no-throw-in-implementation.js +0 -34
  206. package/dist/rules/no-throw-in-implementation.js.map +0 -1
  207. package/dist/rules/prefer-schema-inference.d.ts +0 -7
  208. package/dist/rules/prefer-schema-inference.d.ts.map +0 -1
  209. package/dist/rules/prefer-schema-inference.js +0 -86
  210. package/dist/rules/prefer-schema-inference.js.map +0 -1
  211. package/dist/rules/scan.d.ts +0 -8
  212. package/dist/rules/scan.d.ts.map +0 -1
  213. package/dist/rules/scan.js +0 -32
  214. package/dist/rules/scan.js.map +0 -1
  215. package/dist/rules/specs.d.ts +0 -29
  216. package/dist/rules/specs.d.ts.map +0 -1
  217. package/dist/rules/specs.js +0 -192
  218. package/dist/rules/specs.js.map +0 -1
  219. package/dist/rules/structure.d.ts +0 -13
  220. package/dist/rules/structure.d.ts.map +0 -1
  221. package/dist/rules/structure.js +0 -142
  222. package/dist/rules/structure.js.map +0 -1
  223. package/dist/rules/types.d.ts +0 -52
  224. package/dist/rules/types.d.ts.map +0 -1
  225. package/dist/rules/types.js +0 -2
  226. package/dist/rules/types.js.map +0 -1
  227. package/dist/rules/valid-describe-refs.d.ts +0 -7
  228. package/dist/rules/valid-describe-refs.d.ts.map +0 -1
  229. package/dist/rules/valid-describe-refs.js +0 -51
  230. package/dist/rules/valid-describe-refs.js.map +0 -1
  231. package/dist/rules/valid-detour-refs.d.ts +0 -6
  232. package/dist/rules/valid-detour-refs.d.ts.map +0 -1
  233. package/dist/rules/valid-detour-refs.js +0 -116
  234. package/dist/rules/valid-detour-refs.js.map +0 -1
  235. package/src/__tests__/cli.test.ts +0 -198
  236. package/src/__tests__/drift.test.ts +0 -74
  237. package/src/__tests__/formatters.test.ts +0 -157
  238. package/src/__tests__/implementation-returns-result.test.ts +0 -75
  239. package/src/__tests__/no-direct-implementation-call.test.ts +0 -83
  240. package/src/__tests__/no-sync-result-assumption.test.ts +0 -85
  241. package/src/__tests__/no-throw-in-detour-target.test.ts +0 -78
  242. package/src/__tests__/prefer-schema-inference.test.ts +0 -84
  243. package/src/__tests__/rules.test.ts +0 -188
  244. package/src/__tests__/valid-describe-refs.test.ts +0 -60
  245. package/src/rules/no-direct-impl-in-route.ts +0 -77
  246. package/src/rules/no-throw-in-detour-target.ts +0 -150
  247. package/src/rules/valid-detour-refs.ts +0 -187
  248. package/tsconfig.json +0 -9
  249. package/tsconfig.tsbuildinfo +0 -1
@@ -18,7 +18,7 @@ export interface ObjectProperty extends ParsedEntry {
18
18
 
19
19
  export interface TrailLikeSpec {
20
20
  readonly id: string;
21
- readonly kind: 'hike' | 'trail';
21
+ readonly kind: 'signal' | 'trail';
22
22
  readonly line: number;
23
23
  readonly properties: ReadonlyMap<string, ObjectProperty>;
24
24
  readonly specText: string;
@@ -31,7 +31,11 @@ export interface SchemaFieldInfo {
31
31
  readonly required: boolean;
32
32
  }
33
33
 
34
- const TRAIL_LIKE_PATTERN = /\b(trail|hike)\s*\(/g;
34
+ // Match `trail(...)` / `signal(...)` declaration sites, not method calls.
35
+ // The negative lookbehind excludes `foo.signal(...)`, `foo?.signal(...)`, and
36
+ // similar member-expression call sites (including optional chaining with
37
+ // whitespace) where `signal` / `trail` is a property name, not a factory.
38
+ const TRAIL_LIKE_PATTERN = /(?<![.?])\b(trail|signal)\s*\(/g;
35
39
 
36
40
  const PROPERTY_PATTERN =
37
41
  /^(?:readonly\s+)?(?:(["'`])([^"'`]+)\1|([A-Za-z_$][\w$]*))\s*:\s*([\s\S]+)$/;
@@ -214,7 +218,7 @@ const resolveSpecId = (
214
218
 
215
219
  const buildTrailLikeSpec = (
216
220
  sourceCode: string,
217
- kind: 'hike' | 'trail',
221
+ kind: 'signal' | 'trail',
218
222
  specArg: SplitEntry,
219
223
  specStart: number,
220
224
  id: string,
@@ -276,7 +280,7 @@ const resolveTrailLikeSpec = (
276
280
 
277
281
  const parseTrailLikeMatch = (
278
282
  sourceCode: string,
279
- kind: 'hike' | 'trail',
283
+ kind: 'signal' | 'trail',
280
284
  callStart: number
281
285
  ): TrailLikeSpec | null => {
282
286
  const resolved = resolveTrailLikeSpec(sourceCode, callStart);
@@ -352,7 +356,7 @@ export const findTrailLikeSpecs = (
352
356
  continue;
353
357
  }
354
358
 
355
- const kind = match[1] === 'hike' ? 'hike' : 'trail';
359
+ const kind = match[1] === 'signal' ? 'signal' : 'trail';
356
360
  const spec = parseTrailLikeMatch(sourceCode, kind, callStart);
357
361
  if (spec !== null) {
358
362
  specs.push(spec);
@@ -0,0 +1,657 @@
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 {
10
+ collectNamedResourceIds,
11
+ extractFirstStringArg,
12
+ findBlazeBodies,
13
+ findConfigProperty,
14
+ findTrailDefinitions,
15
+ getStringValue,
16
+ identifierName,
17
+ isStringLiteral,
18
+ offsetToLine,
19
+ parse,
20
+ walk,
21
+ walkScope,
22
+ walkWithScopes,
23
+ } from './ast.js';
24
+ import type { AstNode } from './ast.js';
25
+ import { isFrameworkInternalFile, isTestFile } from './scan.js';
26
+ import type { WardenDiagnostic, WardenRule } from './types.js';
27
+
28
+ const RULE_NAME = 'static-resource-accessor-preference';
29
+
30
+ const MEMBER_TYPES = new Set(['StaticMemberExpression', 'MemberExpression']);
31
+
32
+ const NAMED_DEPENDENCY_CONSTRUCTORS = new Map<string, ReadonlySet<string>>([
33
+ ['@prisma/client', new Set(['PrismaClient'])],
34
+ ['pg', new Set(['Pool', 'Client'])],
35
+ ['mongodb', new Set(['MongoClient'])],
36
+ ['ioredis', new Set(['Redis'])],
37
+ ]);
38
+
39
+ interface DeclaredStaticResource {
40
+ readonly id: string | null;
41
+ readonly name: string;
42
+ }
43
+
44
+ interface ResourceLookup {
45
+ readonly id: string | null;
46
+ readonly name: string | null;
47
+ readonly rendered: string;
48
+ readonly shadowedDeclaredNames: ReadonlySet<string>;
49
+ readonly start: number;
50
+ }
51
+
52
+ interface InlineDependencyConstruction {
53
+ readonly name: string;
54
+ readonly rendered: string;
55
+ readonly start: number;
56
+ }
57
+
58
+ const isShadowedModuleBinding = (
59
+ name: string | null,
60
+ scopes: readonly ReadonlySet<string>[]
61
+ ): boolean => {
62
+ if (!name) {
63
+ return false;
64
+ }
65
+ for (let i = 0; i < scopes.length - 1; i += 1) {
66
+ const frame = scopes[i];
67
+ if (frame?.has(name)) {
68
+ return true;
69
+ }
70
+ }
71
+ return false;
72
+ };
73
+
74
+ const extractMemberPair = (
75
+ callee: AstNode
76
+ ): { readonly objName: string; readonly propName: string } | null => {
77
+ if (!MEMBER_TYPES.has(callee.type)) {
78
+ return null;
79
+ }
80
+
81
+ const objName = identifierName(
82
+ (callee as unknown as { object?: AstNode }).object
83
+ );
84
+ const propName = identifierName(
85
+ (callee as unknown as { property?: AstNode }).property
86
+ );
87
+
88
+ return objName && propName ? { objName, propName } : null;
89
+ };
90
+
91
+ const getResourceElements = (config: AstNode): readonly AstNode[] => {
92
+ const resourcesProp = findConfigProperty(config, 'resources');
93
+ if (!resourcesProp) {
94
+ return [];
95
+ }
96
+
97
+ const arrayNode = resourcesProp.value;
98
+ if (!arrayNode || (arrayNode as AstNode).type !== 'ArrayExpression') {
99
+ return [];
100
+ }
101
+
102
+ return (
103
+ ((arrayNode as AstNode)['elements'] as readonly AstNode[] | undefined) ?? []
104
+ );
105
+ };
106
+
107
+ const extractDeclaredStaticResources = (
108
+ config: AstNode,
109
+ resourceIdsByName: ReadonlyMap<string, string>
110
+ ): readonly DeclaredStaticResource[] =>
111
+ getResourceElements(config).flatMap((element) => {
112
+ if (element.type !== 'Identifier') {
113
+ return [];
114
+ }
115
+
116
+ const name = identifierName(element);
117
+ return name ? [{ id: resourceIdsByName.get(name) ?? null, name }] : [];
118
+ });
119
+
120
+ const extractContextParamNode = (blazeBody: AstNode): AstNode | null => {
121
+ const params = blazeBody['params'] as readonly AstNode[] | undefined;
122
+ if (!params || params.length < 2) {
123
+ return null;
124
+ }
125
+ return params[1] ?? null;
126
+ };
127
+
128
+ const extractContextParamName = (blazeBody: AstNode): string | null => {
129
+ const param = extractContextParamNode(blazeBody);
130
+ if (!param) {
131
+ return null;
132
+ }
133
+ if (param.type === 'AssignmentPattern') {
134
+ return identifierName((param as unknown as { left?: AstNode }).left);
135
+ }
136
+ return identifierName(param);
137
+ };
138
+
139
+ const extractResourceAlias = (property: AstNode): string | null => {
140
+ if (property.type !== 'Property') {
141
+ return null;
142
+ }
143
+
144
+ const keyName = identifierName(
145
+ (property as unknown as { key?: AstNode }).key
146
+ );
147
+ if (keyName !== 'resource') {
148
+ return null;
149
+ }
150
+
151
+ return (
152
+ identifierName((property as unknown as { value?: AstNode }).value) ??
153
+ keyName
154
+ );
155
+ };
156
+
157
+ const collectParamResourceAliases = (body: AstNode): ReadonlySet<string> => {
158
+ const param = extractContextParamNode(body);
159
+ if (!param || param.type !== 'ObjectPattern') {
160
+ return new Set();
161
+ }
162
+
163
+ const aliases = new Set<string>();
164
+ const properties = param['properties'] as readonly AstNode[] | undefined;
165
+ for (const property of properties ?? []) {
166
+ const alias = extractResourceAlias(property);
167
+ if (alias) {
168
+ aliases.add(alias);
169
+ }
170
+ }
171
+ return aliases;
172
+ };
173
+
174
+ const buildCtxNames = (body: AstNode): ReadonlySet<string> => {
175
+ const ctxNames = new Set<string>();
176
+ const paramName = extractContextParamName(body);
177
+ if (paramName) {
178
+ ctxNames.add(paramName);
179
+ }
180
+ return ctxNames;
181
+ };
182
+
183
+ const extractObjectPatternAliases = (
184
+ pattern: AstNode | undefined
185
+ ): readonly string[] => {
186
+ if (pattern?.type !== 'ObjectPattern') {
187
+ return [];
188
+ }
189
+
190
+ const properties = pattern['properties'] as readonly AstNode[] | undefined;
191
+ return (properties ?? []).flatMap((property) => {
192
+ const alias = extractResourceAlias(property);
193
+ return alias ? [alias] : [];
194
+ });
195
+ };
196
+
197
+ const collectResourceAliases = (
198
+ body: AstNode,
199
+ ctxNames: ReadonlySet<string>
200
+ ): ReadonlySet<string> => {
201
+ const aliases = new Set<string>();
202
+
203
+ walkScope(body, (node) => {
204
+ if (node.type !== 'VariableDeclarator') {
205
+ return;
206
+ }
207
+
208
+ const { id, init } = node as unknown as {
209
+ readonly id?: AstNode;
210
+ readonly init?: AstNode;
211
+ };
212
+ const initName = identifierName(init);
213
+ if (!initName || !ctxNames.has(initName)) {
214
+ return;
215
+ }
216
+
217
+ for (const alias of extractObjectPatternAliases(id)) {
218
+ aliases.add(alias);
219
+ }
220
+ });
221
+
222
+ return aliases;
223
+ };
224
+
225
+ const extractCallCallee = (node: AstNode): AstNode | null => {
226
+ if (node.type !== 'CallExpression') {
227
+ return null;
228
+ }
229
+ return ((node as unknown as { callee?: AstNode }).callee ??
230
+ null) as AstNode | null;
231
+ };
232
+
233
+ const extractFirstArg = (node: AstNode): AstNode | null => {
234
+ if (node.type !== 'CallExpression') {
235
+ return null;
236
+ }
237
+ const args = node['arguments'] as readonly AstNode[] | undefined;
238
+ return args?.[0] ?? null;
239
+ };
240
+
241
+ const renderStringArg = (value: string): string =>
242
+ `'${value.replaceAll("'", "\\'")}'`;
243
+
244
+ const renderResourceArg = (node: AstNode | null): string | null => {
245
+ if (!node) {
246
+ return null;
247
+ }
248
+ const name = identifierName(node);
249
+ if (name) {
250
+ return name;
251
+ }
252
+ return isStringLiteral(node)
253
+ ? renderStringArg(getStringValue(node) ?? '')
254
+ : null;
255
+ };
256
+
257
+ const extractFirstIdentifierArg = (node: AstNode): string | null =>
258
+ identifierName(extractFirstArg(node) ?? undefined);
259
+
260
+ const isMemberResourceCall = (
261
+ callee: AstNode,
262
+ ctxNames: ReadonlySet<string>
263
+ ): { readonly ctxName: string } | null => {
264
+ const pair = extractMemberPair(callee);
265
+ return pair && ctxNames.has(pair.objName) && pair.propName === 'resource'
266
+ ? { ctxName: pair.objName }
267
+ : null;
268
+ };
269
+
270
+ const extractResourceLookup = (
271
+ node: AstNode,
272
+ ctxNames: ReadonlySet<string>,
273
+ resourceAliases: ReadonlySet<string>
274
+ ): ResourceLookup | null => {
275
+ const callee = extractCallCallee(node);
276
+ if (!callee) {
277
+ return null;
278
+ }
279
+
280
+ const arg = extractFirstArg(node);
281
+ const renderedArg = renderResourceArg(arg);
282
+ if (!renderedArg) {
283
+ return null;
284
+ }
285
+
286
+ const memberCall = isMemberResourceCall(callee, ctxNames);
287
+ if (memberCall) {
288
+ return {
289
+ id: extractFirstStringArg(node),
290
+ name: extractFirstIdentifierArg(node),
291
+ rendered: `${memberCall.ctxName}.resource(${renderedArg})`,
292
+ shadowedDeclaredNames: new Set(),
293
+ start: node.start,
294
+ };
295
+ }
296
+
297
+ const calleeName = identifierName(callee);
298
+ if (calleeName && resourceAliases.has(calleeName)) {
299
+ return {
300
+ id: extractFirstStringArg(node),
301
+ name: extractFirstIdentifierArg(node),
302
+ rendered: `${calleeName}(${renderedArg})`,
303
+ shadowedDeclaredNames: new Set(),
304
+ start: node.start,
305
+ };
306
+ }
307
+
308
+ return null;
309
+ };
310
+
311
+ const buildDeclaredNameSet = (
312
+ resources: readonly DeclaredStaticResource[]
313
+ ): ReadonlySet<string> => new Set(resources.map((resource) => resource.name));
314
+
315
+ const collectShadowedNames = (
316
+ names: ReadonlySet<string>,
317
+ scopes: readonly ReadonlySet<string>[]
318
+ ): ReadonlySet<string> => {
319
+ const shadowed = new Set<string>();
320
+ for (const name of names) {
321
+ if (isShadowedModuleBinding(name, scopes)) {
322
+ shadowed.add(name);
323
+ }
324
+ }
325
+ return shadowed;
326
+ };
327
+
328
+ const buildDeclaredNameById = (
329
+ resources: readonly DeclaredStaticResource[]
330
+ ): ReadonlyMap<string, string> =>
331
+ new Map(
332
+ resources.flatMap((resource) =>
333
+ resource.id ? [[resource.id, resource.name] as const] : []
334
+ )
335
+ );
336
+
337
+ const collectResourceLookups = (
338
+ config: AstNode,
339
+ declaredNames: ReadonlySet<string>
340
+ ): readonly ResourceLookup[] => {
341
+ const lookups: ResourceLookup[] = [];
342
+
343
+ for (const body of findBlazeBodies(config)) {
344
+ const ctxNames = buildCtxNames(body);
345
+ const resourceAliases = new Set([
346
+ ...collectParamResourceAliases(body),
347
+ ...collectResourceAliases(body, ctxNames),
348
+ ]);
349
+
350
+ walkWithScopes(
351
+ body,
352
+ (node, scopes) => {
353
+ const lookup = extractResourceLookup(node, ctxNames, resourceAliases);
354
+ if (lookup && !isShadowedModuleBinding(lookup.name, scopes)) {
355
+ lookups.push({
356
+ ...lookup,
357
+ shadowedDeclaredNames: collectShadowedNames(declaredNames, scopes),
358
+ });
359
+ }
360
+ },
361
+ {
362
+ initialScopes: [declaredNames],
363
+ stopAtNestedFunctions: true,
364
+ }
365
+ );
366
+ }
367
+
368
+ return lookups;
369
+ };
370
+
371
+ const getImportSourceValue = (node: AstNode): string | null => {
372
+ const sourceNode = (node as unknown as { source?: AstNode }).source;
373
+ const value = sourceNode
374
+ ? (sourceNode as unknown as { value?: unknown }).value
375
+ : null;
376
+ return typeof value === 'string' ? value : null;
377
+ };
378
+
379
+ const addNamedDependencyConstructors = (
380
+ source: string,
381
+ specifier: AstNode,
382
+ constructors: Set<string>
383
+ ): void => {
384
+ if (specifier.type !== 'ImportSpecifier') {
385
+ return;
386
+ }
387
+
388
+ const { imported, local } = specifier as unknown as {
389
+ readonly imported?: AstNode;
390
+ readonly local?: AstNode;
391
+ };
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(
424
+ (specifier as unknown as { local?: AstNode }).local
425
+ );
426
+ if (localName) {
427
+ constructors.add(localName);
428
+ }
429
+ };
430
+
431
+ const collectDependencyConstructors = (ast: AstNode): ReadonlySet<string> => {
432
+ const constructors = new Set<string>();
433
+
434
+ walk(ast, (node) => {
435
+ if (node.type !== 'ImportDeclaration') {
436
+ return;
437
+ }
438
+
439
+ const source = getImportSourceValue(node);
440
+ if (!source) {
441
+ return;
442
+ }
443
+
444
+ const specifiers = node['specifiers'] as readonly AstNode[] | undefined;
445
+ for (const specifier of specifiers ?? []) {
446
+ addNamedDependencyConstructors(source, specifier, constructors);
447
+ addDefaultDependencyConstructors(source, specifier, constructors);
448
+ }
449
+ });
450
+
451
+ return constructors;
452
+ };
453
+
454
+ const extractInlineDependencyConstruction = (
455
+ node: AstNode,
456
+ dependencyConstructors: ReadonlySet<string>
457
+ ): InlineDependencyConstruction | null => {
458
+ if (node.type !== 'NewExpression') {
459
+ return null;
460
+ }
461
+
462
+ const ctorName = identifierName(
463
+ (node as unknown as { callee?: AstNode }).callee
464
+ );
465
+ return ctorName && dependencyConstructors.has(ctorName)
466
+ ? { name: ctorName, rendered: `new ${ctorName}(...)`, start: node.start }
467
+ : null;
468
+ };
469
+
470
+ const collectInlineDependencyConstructions = (
471
+ config: AstNode,
472
+ dependencyConstructors: ReadonlySet<string>
473
+ ): readonly InlineDependencyConstruction[] => {
474
+ const constructions: InlineDependencyConstruction[] = [];
475
+
476
+ for (const body of findBlazeBodies(config)) {
477
+ walkWithScopes(
478
+ body,
479
+ (node, scopes) => {
480
+ const construction = extractInlineDependencyConstruction(
481
+ node,
482
+ dependencyConstructors
483
+ );
484
+ if (
485
+ construction &&
486
+ !isShadowedModuleBinding(construction.name, scopes)
487
+ ) {
488
+ constructions.push(construction);
489
+ }
490
+ },
491
+ {
492
+ initialScopes: [dependencyConstructors],
493
+ stopAtNestedFunctions: true,
494
+ }
495
+ );
496
+ }
497
+
498
+ return constructions;
499
+ };
500
+
501
+ const buildAccessorDiagnostic = (
502
+ trailId: string,
503
+ lookup: ResourceLookup,
504
+ resourceName: string,
505
+ filePath: string,
506
+ sourceCode: string
507
+ ): WardenDiagnostic => ({
508
+ filePath,
509
+ line: offsetToLine(sourceCode, lookup.start),
510
+ message:
511
+ `Trail "${trailId}": ${lookup.rendered} uses a dynamic resource accessor ` +
512
+ `for statically declared resource '${resourceName}'. Prefer ${resourceName}.from(ctx) ` +
513
+ 'so the dependency stays type-directed.',
514
+ rule: RULE_NAME,
515
+ severity: 'warn',
516
+ });
517
+
518
+ const buildInlineDependencyDiagnostic = (
519
+ trailId: string,
520
+ construction: InlineDependencyConstruction,
521
+ filePath: string,
522
+ sourceCode: string
523
+ ): WardenDiagnostic => ({
524
+ filePath,
525
+ line: offsetToLine(sourceCode, construction.start),
526
+ message:
527
+ `Trail "${trailId}": ${construction.rendered} constructs an external dependency ` +
528
+ 'inside blaze logic. Move the client behind a resource definition and declare it in resources.',
529
+ rule: RULE_NAME,
530
+ severity: 'warn',
531
+ });
532
+
533
+ const reportAccessorLookups = (
534
+ trailId: string,
535
+ filePath: string,
536
+ sourceCode: string,
537
+ declaredResources: readonly DeclaredStaticResource[],
538
+ lookups: readonly ResourceLookup[],
539
+ diagnostics: WardenDiagnostic[]
540
+ ): void => {
541
+ const declaredNames = buildDeclaredNameSet(declaredResources);
542
+ const declaredNameById = buildDeclaredNameById(declaredResources);
543
+
544
+ for (const lookup of lookups) {
545
+ const resourceName =
546
+ (lookup.name && declaredNames.has(lookup.name) ? lookup.name : null) ??
547
+ (lookup.id ? (declaredNameById.get(lookup.id) ?? null) : null);
548
+
549
+ if (!resourceName) {
550
+ continue;
551
+ }
552
+ if (lookup.shadowedDeclaredNames.has(resourceName)) {
553
+ continue;
554
+ }
555
+
556
+ diagnostics.push(
557
+ buildAccessorDiagnostic(
558
+ trailId,
559
+ lookup,
560
+ resourceName,
561
+ filePath,
562
+ sourceCode
563
+ )
564
+ );
565
+ }
566
+ };
567
+
568
+ const reportInlineDependencyConstructions = (
569
+ trailId: string,
570
+ filePath: string,
571
+ sourceCode: string,
572
+ constructions: readonly InlineDependencyConstruction[],
573
+ diagnostics: WardenDiagnostic[]
574
+ ): void => {
575
+ for (const construction of constructions) {
576
+ diagnostics.push(
577
+ buildInlineDependencyDiagnostic(
578
+ trailId,
579
+ construction,
580
+ filePath,
581
+ sourceCode
582
+ )
583
+ );
584
+ }
585
+ };
586
+
587
+ const checkTrailDefinition = (
588
+ def: { readonly config: AstNode; readonly id: string },
589
+ filePath: string,
590
+ sourceCode: string,
591
+ resourceIdsByName: ReadonlyMap<string, string>,
592
+ dependencyConstructors: ReadonlySet<string>,
593
+ diagnostics: WardenDiagnostic[]
594
+ ): void => {
595
+ const declaredResources = extractDeclaredStaticResources(
596
+ def.config,
597
+ resourceIdsByName
598
+ );
599
+ const lookups = collectResourceLookups(
600
+ def.config,
601
+ buildDeclaredNameSet(declaredResources)
602
+ );
603
+ reportAccessorLookups(
604
+ def.id,
605
+ filePath,
606
+ sourceCode,
607
+ declaredResources,
608
+ lookups,
609
+ diagnostics
610
+ );
611
+
612
+ const constructions = collectInlineDependencyConstructions(
613
+ def.config,
614
+ dependencyConstructors
615
+ );
616
+ reportInlineDependencyConstructions(
617
+ def.id,
618
+ filePath,
619
+ sourceCode,
620
+ constructions,
621
+ diagnostics
622
+ );
623
+ };
624
+
625
+ export const staticResourceAccessorPreference: WardenRule = {
626
+ check(sourceCode: string, filePath: string): readonly WardenDiagnostic[] {
627
+ if (isTestFile(filePath) || isFrameworkInternalFile(filePath)) {
628
+ return [];
629
+ }
630
+
631
+ const ast = parse(filePath, sourceCode);
632
+ if (!ast) {
633
+ return [];
634
+ }
635
+
636
+ const diagnostics: WardenDiagnostic[] = [];
637
+ const resourceIdsByName = collectNamedResourceIds(ast);
638
+ const dependencyConstructors = collectDependencyConstructors(ast);
639
+
640
+ for (const def of findTrailDefinitions(ast)) {
641
+ checkTrailDefinition(
642
+ def,
643
+ filePath,
644
+ sourceCode,
645
+ resourceIdsByName,
646
+ dependencyConstructors,
647
+ diagnostics
648
+ );
649
+ }
650
+
651
+ return diagnostics;
652
+ },
653
+ description:
654
+ 'Prefer static resource.from(ctx) helpers over dynamic ctx.resource() lookups when the resource definition is already in scope.',
655
+ name: RULE_NAME,
656
+ severity: 'warn',
657
+ };