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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (260) hide show
  1. package/CHANGELOG.md +625 -9
  2. package/README.md +111 -26
  3. package/bin/warden.ts +50 -0
  4. package/package.json +28 -5
  5. package/src/adapter-check.ts +136 -0
  6. package/src/ast.ts +137 -0
  7. package/src/cli.ts +1415 -104
  8. package/src/command.ts +943 -0
  9. package/src/config.ts +184 -0
  10. package/src/draft.ts +22 -0
  11. package/src/drift.ts +122 -22
  12. package/src/fix.ts +120 -0
  13. package/src/formatters.ts +79 -9
  14. package/src/guide.ts +245 -0
  15. package/src/index.ts +215 -14
  16. package/src/project-context.ts +163 -0
  17. package/src/project-rules.ts +290 -0
  18. package/src/resolve.ts +531 -0
  19. package/src/rules/activation-orphan.ts +97 -0
  20. package/src/rules/ast.ts +4008 -86
  21. package/src/rules/circular-refs.ts +154 -0
  22. package/src/rules/cli-command-route-coherence.ts +177 -0
  23. package/src/rules/composes-declarations.ts +837 -0
  24. package/src/rules/context-no-surface-types.ts +78 -15
  25. package/src/rules/contour-exists.ts +251 -0
  26. package/src/rules/contour-ids.ts +15 -0
  27. package/src/rules/dead-internal-trail.ts +161 -0
  28. package/src/rules/dead-public-trail.ts +258 -0
  29. package/src/rules/draft-file-marking.ts +160 -0
  30. package/src/rules/draft-visible-debt.ts +87 -0
  31. package/src/rules/duplicate-public-contract.ts +91 -0
  32. package/src/rules/error-mapping-completeness.ts +290 -0
  33. package/src/rules/example-valid.ts +395 -0
  34. package/src/rules/fires-declarations.ts +735 -0
  35. package/src/rules/implementation-returns-result.ts +1411 -166
  36. package/src/rules/incomplete-accessor-for-standard-op.ts +272 -0
  37. package/src/rules/incomplete-crud.ts +581 -0
  38. package/src/rules/index.ts +234 -18
  39. package/src/rules/intent-propagation.ts +127 -0
  40. package/src/rules/layer-field-name-drift.ts +102 -0
  41. package/src/rules/library-projection-coherence.ts +100 -0
  42. package/src/rules/metadata.ts +755 -0
  43. package/src/rules/missing-reconcile.ts +98 -0
  44. package/src/rules/missing-visibility.ts +110 -0
  45. package/src/rules/no-destructured-compose.ts +194 -0
  46. package/src/rules/no-dev-permit-in-source.ts +99 -0
  47. package/src/rules/no-direct-implementation-call.ts +7 -7
  48. package/src/rules/no-legacy-layer-imports.ts +211 -0
  49. package/src/rules/no-native-error-result.ts +118 -0
  50. package/src/rules/no-redundant-result-error-wrap.ts +384 -0
  51. package/src/rules/no-retired-cross-vocabulary.ts +194 -0
  52. package/src/rules/no-sync-result-assumption.ts +1135 -98
  53. package/src/rules/no-throw-in-detour-recover.ts +225 -0
  54. package/src/rules/no-throw-in-implementation.ts +10 -9
  55. package/src/rules/no-top-level-surface.ts +371 -0
  56. package/src/rules/on-references-exist.ts +194 -0
  57. package/src/rules/orphaned-signal.ts +150 -0
  58. package/src/rules/owner-projection-parity.ts +143 -0
  59. package/src/rules/permit-governance.ts +25 -0
  60. package/src/rules/public-export-example-coverage.ts +562 -0
  61. package/src/rules/public-internal-deep-imports.ts +517 -0
  62. package/src/rules/public-output-schema.ts +29 -0
  63. package/src/rules/public-union-output-discriminants.ts +150 -0
  64. package/src/rules/read-intent-fires.ts +187 -0
  65. package/src/rules/reference-exists.ts +98 -0
  66. package/src/rules/registry-names.ts +155 -0
  67. package/src/rules/resolved-import-boundary.ts +146 -0
  68. package/src/rules/resource-declarations.ts +693 -0
  69. package/src/rules/resource-exists.ts +179 -0
  70. package/src/rules/resource-id-grammar.ts +65 -0
  71. package/src/rules/resource-mock-coverage.ts +115 -0
  72. package/src/rules/scan.ts +38 -25
  73. package/src/rules/scheduled-destroy-intent.ts +44 -0
  74. package/src/rules/signal-graph-coaching.ts +191 -0
  75. package/src/rules/specs.ts +9 -5
  76. package/src/rules/static-resource-accessor-preference.ts +649 -0
  77. package/src/rules/surface-facet-coherence.ts +362 -0
  78. package/src/rules/trail-fork-coaching.ts +616 -0
  79. package/src/rules/trail-versioning-source.ts +1072 -0
  80. package/src/rules/trail-versioning-topo.ts +172 -0
  81. package/src/rules/types.ts +272 -6
  82. package/src/rules/unmaterialized-activation-source.ts +84 -0
  83. package/src/rules/unreachable-detour-shadowing.ts +339 -0
  84. package/src/rules/valid-describe-refs.ts +162 -32
  85. package/src/rules/valid-detour-contract.ts +78 -0
  86. package/src/rules/warden-export-symmetry.ts +540 -0
  87. package/src/rules/warden-rules-use-ast.ts +1114 -0
  88. package/src/rules/webhook-route-collision.ts +243 -0
  89. package/src/trails/activation-orphan.trail.ts +84 -0
  90. package/src/trails/circular-refs.trail.ts +29 -0
  91. package/src/trails/cli-command-route-coherence.trail.ts +47 -0
  92. package/src/trails/composes-declarations.trail.ts +22 -0
  93. package/src/trails/context-no-surface-types.trail.ts +21 -0
  94. package/src/trails/contour-exists.trail.ts +21 -0
  95. package/src/trails/dead-internal-trail.trail.ts +26 -0
  96. package/src/trails/dead-public-trail.trail.ts +31 -0
  97. package/src/trails/deprecation-without-guidance.trail.ts +21 -0
  98. package/src/trails/draft-file-marking.trail.ts +16 -0
  99. package/src/trails/draft-visible-debt.trail.ts +16 -0
  100. package/src/trails/duplicate-public-contract.trail.ts +47 -0
  101. package/src/trails/error-mapping-completeness.trail.ts +30 -0
  102. package/src/trails/example-valid.trail.ts +25 -0
  103. package/src/trails/fires-declarations.trail.ts +23 -0
  104. package/src/trails/fork-without-preserved-blaze.trail.ts +31 -0
  105. package/src/trails/implementation-returns-result.trail.ts +20 -0
  106. package/src/trails/incomplete-accessor-for-standard-op.trail.ts +76 -0
  107. package/src/trails/incomplete-crud.trail.ts +39 -0
  108. package/src/trails/index.ts +83 -0
  109. package/src/trails/intent-propagation.trail.ts +30 -0
  110. package/src/trails/layer-field-name-drift.trail.ts +39 -0
  111. package/src/trails/library-projection-coherence.trail.ts +43 -0
  112. package/src/trails/marker-schema-unsupported.trail.ts +23 -0
  113. package/src/trails/missing-reconcile.trail.ts +33 -0
  114. package/src/trails/missing-visibility.trail.ts +22 -0
  115. package/src/trails/no-destructured-compose.trail.ts +44 -0
  116. package/src/trails/no-dev-permit-in-source.trail.ts +16 -0
  117. package/src/trails/no-direct-implementation-call.trail.ts +16 -0
  118. package/src/trails/no-legacy-layer-imports.trail.ts +41 -0
  119. package/src/trails/no-native-error-result.trail.ts +18 -0
  120. package/src/trails/no-redundant-result-error-wrap.trail.ts +55 -0
  121. package/src/trails/no-retired-cross-vocabulary.trail.ts +42 -0
  122. package/src/trails/no-sync-result-assumption.trail.ts +19 -0
  123. package/src/trails/no-throw-in-detour-recover.trail.ts +24 -0
  124. package/src/trails/no-throw-in-implementation.trail.ts +20 -0
  125. package/src/trails/no-top-level-surface.trail.ts +43 -0
  126. package/src/trails/on-references-exist.trail.ts +21 -0
  127. package/src/trails/orphaned-signal.trail.ts +36 -0
  128. package/src/trails/owner-projection-parity.trail.ts +26 -0
  129. package/src/trails/pending-force.trail.ts +21 -0
  130. package/src/trails/permit-governance.trail.ts +51 -0
  131. package/src/trails/prefer-schema-inference.trail.ts +21 -0
  132. package/src/trails/public-export-example-coverage.trail.ts +16 -0
  133. package/src/trails/public-internal-deep-imports.trail.ts +94 -0
  134. package/src/trails/public-output-schema.trail.ts +55 -0
  135. package/src/trails/public-union-output-discriminants.trail.ts +33 -0
  136. package/src/trails/read-intent-fires.trail.ts +20 -0
  137. package/src/trails/reference-exists.trail.ts +25 -0
  138. package/src/trails/resolved-import-boundary.trail.ts +109 -0
  139. package/src/trails/resource-declarations.trail.ts +25 -0
  140. package/src/trails/resource-exists.trail.ts +27 -0
  141. package/src/trails/resource-id-grammar.trail.ts +39 -0
  142. package/src/trails/resource-mock-coverage.trail.ts +40 -0
  143. package/src/trails/run.ts +162 -0
  144. package/src/trails/scheduled-destroy-intent.trail.ts +56 -0
  145. package/src/trails/schema.ts +198 -0
  146. package/src/trails/signal-graph-coaching.trail.ts +77 -0
  147. package/src/trails/static-resource-accessor-preference.trail.ts +25 -0
  148. package/src/trails/surface-facet-coherence.trail.ts +25 -0
  149. package/src/trails/topo.ts +6 -0
  150. package/src/trails/trail-fork-coaching.trail.ts +42 -0
  151. package/src/trails/unmaterialized-activation-source.trail.ts +72 -0
  152. package/src/trails/unreachable-detour-shadowing.trail.ts +45 -0
  153. package/src/trails/valid-describe-refs.trail.ts +18 -0
  154. package/src/trails/valid-detour-contract.trail.ts +71 -0
  155. package/src/trails/version-gap.trail.ts +35 -0
  156. package/src/trails/version-pinned-compose.trail.ts +23 -0
  157. package/src/trails/version-without-examples.trail.ts +38 -0
  158. package/src/trails/warden-export-symmetry.trail.ts +16 -0
  159. package/src/trails/warden-rules-use-ast.trail.ts +64 -0
  160. package/src/trails/webhook-route-collision.trail.ts +50 -0
  161. package/src/trails/wrap-rule.ts +214 -0
  162. package/src/workspaces.ts +238 -0
  163. package/.turbo/turbo-build.log +0 -1
  164. package/.turbo/turbo-lint.log +0 -3
  165. package/.turbo/turbo-typecheck.log +0 -1
  166. package/dist/cli.d.ts +0 -46
  167. package/dist/cli.d.ts.map +0 -1
  168. package/dist/cli.js +0 -221
  169. package/dist/cli.js.map +0 -1
  170. package/dist/drift.d.ts +0 -26
  171. package/dist/drift.d.ts.map +0 -1
  172. package/dist/drift.js +0 -27
  173. package/dist/drift.js.map +0 -1
  174. package/dist/formatters.d.ts +0 -29
  175. package/dist/formatters.d.ts.map +0 -1
  176. package/dist/formatters.js +0 -87
  177. package/dist/formatters.js.map +0 -1
  178. package/dist/index.d.ts +0 -26
  179. package/dist/index.d.ts.map +0 -1
  180. package/dist/index.js +0 -26
  181. package/dist/index.js.map +0 -1
  182. package/dist/rules/ast.d.ts +0 -41
  183. package/dist/rules/ast.d.ts.map +0 -1
  184. package/dist/rules/ast.js +0 -163
  185. package/dist/rules/ast.js.map +0 -1
  186. package/dist/rules/context-no-surface-types.d.ts +0 -12
  187. package/dist/rules/context-no-surface-types.d.ts.map +0 -1
  188. package/dist/rules/context-no-surface-types.js +0 -96
  189. package/dist/rules/context-no-surface-types.js.map +0 -1
  190. package/dist/rules/implementation-returns-result.d.ts +0 -13
  191. package/dist/rules/implementation-returns-result.d.ts.map +0 -1
  192. package/dist/rules/implementation-returns-result.js +0 -277
  193. package/dist/rules/implementation-returns-result.js.map +0 -1
  194. package/dist/rules/index.d.ts +0 -22
  195. package/dist/rules/index.d.ts.map +0 -1
  196. package/dist/rules/index.js +0 -41
  197. package/dist/rules/index.js.map +0 -1
  198. package/dist/rules/no-direct-impl-in-route.d.ts +0 -12
  199. package/dist/rules/no-direct-impl-in-route.d.ts.map +0 -1
  200. package/dist/rules/no-direct-impl-in-route.js +0 -46
  201. package/dist/rules/no-direct-impl-in-route.js.map +0 -1
  202. package/dist/rules/no-direct-implementation-call.d.ts +0 -12
  203. package/dist/rules/no-direct-implementation-call.d.ts.map +0 -1
  204. package/dist/rules/no-direct-implementation-call.js +0 -39
  205. package/dist/rules/no-direct-implementation-call.js.map +0 -1
  206. package/dist/rules/no-sync-result-assumption.d.ts +0 -6
  207. package/dist/rules/no-sync-result-assumption.d.ts.map +0 -1
  208. package/dist/rules/no-sync-result-assumption.js +0 -98
  209. package/dist/rules/no-sync-result-assumption.js.map +0 -1
  210. package/dist/rules/no-throw-in-detour-target.d.ts +0 -12
  211. package/dist/rules/no-throw-in-detour-target.d.ts.map +0 -1
  212. package/dist/rules/no-throw-in-detour-target.js +0 -87
  213. package/dist/rules/no-throw-in-detour-target.js.map +0 -1
  214. package/dist/rules/no-throw-in-implementation.d.ts +0 -9
  215. package/dist/rules/no-throw-in-implementation.d.ts.map +0 -1
  216. package/dist/rules/no-throw-in-implementation.js +0 -34
  217. package/dist/rules/no-throw-in-implementation.js.map +0 -1
  218. package/dist/rules/prefer-schema-inference.d.ts +0 -7
  219. package/dist/rules/prefer-schema-inference.d.ts.map +0 -1
  220. package/dist/rules/prefer-schema-inference.js +0 -86
  221. package/dist/rules/prefer-schema-inference.js.map +0 -1
  222. package/dist/rules/scan.d.ts +0 -8
  223. package/dist/rules/scan.d.ts.map +0 -1
  224. package/dist/rules/scan.js +0 -32
  225. package/dist/rules/scan.js.map +0 -1
  226. package/dist/rules/specs.d.ts +0 -29
  227. package/dist/rules/specs.d.ts.map +0 -1
  228. package/dist/rules/specs.js +0 -192
  229. package/dist/rules/specs.js.map +0 -1
  230. package/dist/rules/structure.d.ts +0 -13
  231. package/dist/rules/structure.d.ts.map +0 -1
  232. package/dist/rules/structure.js +0 -142
  233. package/dist/rules/structure.js.map +0 -1
  234. package/dist/rules/types.d.ts +0 -52
  235. package/dist/rules/types.d.ts.map +0 -1
  236. package/dist/rules/types.js +0 -2
  237. package/dist/rules/types.js.map +0 -1
  238. package/dist/rules/valid-describe-refs.d.ts +0 -7
  239. package/dist/rules/valid-describe-refs.d.ts.map +0 -1
  240. package/dist/rules/valid-describe-refs.js +0 -51
  241. package/dist/rules/valid-describe-refs.js.map +0 -1
  242. package/dist/rules/valid-detour-refs.d.ts +0 -6
  243. package/dist/rules/valid-detour-refs.d.ts.map +0 -1
  244. package/dist/rules/valid-detour-refs.js +0 -116
  245. package/dist/rules/valid-detour-refs.js.map +0 -1
  246. package/src/__tests__/cli.test.ts +0 -198
  247. package/src/__tests__/drift.test.ts +0 -74
  248. package/src/__tests__/formatters.test.ts +0 -157
  249. package/src/__tests__/implementation-returns-result.test.ts +0 -128
  250. package/src/__tests__/no-direct-implementation-call.test.ts +0 -83
  251. package/src/__tests__/no-sync-result-assumption.test.ts +0 -85
  252. package/src/__tests__/no-throw-in-detour-target.test.ts +0 -78
  253. package/src/__tests__/prefer-schema-inference.test.ts +0 -84
  254. package/src/__tests__/rules.test.ts +0 -215
  255. package/src/__tests__/valid-describe-refs.test.ts +0 -60
  256. package/src/rules/no-direct-impl-in-route.ts +0 -77
  257. package/src/rules/no-throw-in-detour-target.ts +0 -150
  258. package/src/rules/valid-detour-refs.ts +0 -187
  259. package/tsconfig.json +0 -9
  260. package/tsconfig.tsbuildinfo +0 -1
@@ -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,649 @@
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
+ 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 './ast.js';
36
+ import type { AstNode } from './ast.js';
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 = (blazeBody: AstNode): AstNode | null => {
129
+ const params = blazeBody['params'] as readonly AstNode[] | undefined;
130
+ if (!params || params.length < 2) {
131
+ return null;
132
+ }
133
+ return params[1] ?? null;
134
+ };
135
+
136
+ const extractContextParamName = (blazeBody: AstNode): string | null => {
137
+ const param = extractContextParamNode(blazeBody);
138
+ if (!param) {
139
+ return null;
140
+ }
141
+ if (param.type === 'AssignmentPattern') {
142
+ return identifierName(getNodeLeft(param));
143
+ }
144
+ return identifierName(param);
145
+ };
146
+
147
+ const extractResourceAlias = (property: AstNode): string | null => {
148
+ if (property.type !== 'Property') {
149
+ return null;
150
+ }
151
+
152
+ const keyName = identifierName(getNodeKey(property));
153
+ if (keyName !== 'resource') {
154
+ return null;
155
+ }
156
+
157
+ return identifierName(getNodeValueNode(property)) ?? keyName;
158
+ };
159
+
160
+ const collectParamResourceAliases = (body: AstNode): ReadonlySet<string> => {
161
+ const param = extractContextParamNode(body);
162
+ if (!param || param.type !== 'ObjectPattern') {
163
+ return new Set();
164
+ }
165
+
166
+ const aliases = new Set<string>();
167
+ const properties = param['properties'] as readonly AstNode[] | undefined;
168
+ for (const property of properties ?? []) {
169
+ const alias = extractResourceAlias(property);
170
+ if (alias) {
171
+ aliases.add(alias);
172
+ }
173
+ }
174
+ return aliases;
175
+ };
176
+
177
+ const buildCtxNames = (body: AstNode): ReadonlySet<string> => {
178
+ const ctxNames = new Set<string>();
179
+ const paramName = extractContextParamName(body);
180
+ if (paramName) {
181
+ ctxNames.add(paramName);
182
+ }
183
+ return ctxNames;
184
+ };
185
+
186
+ const extractObjectPatternAliases = (
187
+ pattern: AstNode | undefined
188
+ ): readonly string[] => {
189
+ if (pattern?.type !== 'ObjectPattern') {
190
+ return [];
191
+ }
192
+
193
+ const properties = pattern['properties'] as readonly AstNode[] | undefined;
194
+ return (properties ?? []).flatMap((property) => {
195
+ const alias = extractResourceAlias(property);
196
+ return alias ? [alias] : [];
197
+ });
198
+ };
199
+
200
+ const collectResourceAliases = (
201
+ body: AstNode,
202
+ ctxNames: ReadonlySet<string>
203
+ ): ReadonlySet<string> => {
204
+ const aliases = new Set<string>();
205
+
206
+ walkScope(body, (node) => {
207
+ if (node.type !== 'VariableDeclarator') {
208
+ return;
209
+ }
210
+
211
+ const id = getNodeId(node);
212
+ const init = getNodeInit(node);
213
+ const initName = identifierName(init);
214
+ if (!initName || !ctxNames.has(initName)) {
215
+ return;
216
+ }
217
+
218
+ for (const alias of extractObjectPatternAliases(id)) {
219
+ aliases.add(alias);
220
+ }
221
+ });
222
+
223
+ return aliases;
224
+ };
225
+
226
+ const extractCallCallee = (node: AstNode): AstNode | null => {
227
+ if (node.type !== 'CallExpression') {
228
+ return null;
229
+ }
230
+ return (getNodeCallee(node) ?? 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 = getNodeSource(node);
373
+ const value = sourceNode ? getNodeValue(sourceNode) : null;
374
+ return typeof value === 'string' ? value : null;
375
+ };
376
+
377
+ const addNamedDependencyConstructors = (
378
+ source: string,
379
+ specifier: AstNode,
380
+ constructors: Set<string>
381
+ ): void => {
382
+ if (specifier.type !== 'ImportSpecifier') {
383
+ return;
384
+ }
385
+
386
+ const imported = getNodeImported(specifier);
387
+ const local = getNodeLocal(specifier);
388
+ const importedName =
389
+ identifierName(imported) ??
390
+ (imported && isStringLiteral(imported) ? getStringValue(imported) : null);
391
+ const localName = identifierName(local);
392
+ if (!importedName || !localName) {
393
+ return;
394
+ }
395
+
396
+ if (
397
+ source.startsWith('@aws-sdk/client-') &&
398
+ importedName.endsWith('Client')
399
+ ) {
400
+ constructors.add(localName);
401
+ return;
402
+ }
403
+
404
+ const names = NAMED_DEPENDENCY_CONSTRUCTORS.get(source);
405
+ if (names?.has(importedName)) {
406
+ constructors.add(localName);
407
+ }
408
+ };
409
+
410
+ const addDefaultDependencyConstructors = (
411
+ source: string,
412
+ specifier: AstNode,
413
+ constructors: Set<string>
414
+ ): void => {
415
+ if (specifier.type !== 'ImportDefaultSpecifier' || source !== 'ioredis') {
416
+ return;
417
+ }
418
+
419
+ const localName = identifierName(getNodeLocal(specifier));
420
+ if (localName) {
421
+ constructors.add(localName);
422
+ }
423
+ };
424
+
425
+ const collectDependencyConstructors = (ast: AstNode): ReadonlySet<string> => {
426
+ const constructors = new Set<string>();
427
+
428
+ walk(ast, (node) => {
429
+ if (node.type !== 'ImportDeclaration') {
430
+ return;
431
+ }
432
+
433
+ const source = getImportSourceValue(node);
434
+ if (!source) {
435
+ return;
436
+ }
437
+
438
+ const specifiers = node['specifiers'] as readonly AstNode[] | undefined;
439
+ for (const specifier of specifiers ?? []) {
440
+ addNamedDependencyConstructors(source, specifier, constructors);
441
+ addDefaultDependencyConstructors(source, specifier, constructors);
442
+ }
443
+ });
444
+
445
+ return constructors;
446
+ };
447
+
448
+ const extractInlineDependencyConstruction = (
449
+ node: AstNode,
450
+ dependencyConstructors: ReadonlySet<string>
451
+ ): InlineDependencyConstruction | null => {
452
+ if (node.type !== 'NewExpression') {
453
+ return null;
454
+ }
455
+
456
+ const ctorName = identifierName(getNodeCallee(node));
457
+ return ctorName && dependencyConstructors.has(ctorName)
458
+ ? { name: ctorName, rendered: `new ${ctorName}(...)`, start: node.start }
459
+ : null;
460
+ };
461
+
462
+ const collectInlineDependencyConstructions = (
463
+ config: AstNode,
464
+ dependencyConstructors: ReadonlySet<string>
465
+ ): readonly InlineDependencyConstruction[] => {
466
+ const constructions: InlineDependencyConstruction[] = [];
467
+
468
+ for (const body of findBlazeBodies(config)) {
469
+ walkWithScopes(
470
+ body,
471
+ (node, scopes) => {
472
+ const construction = extractInlineDependencyConstruction(
473
+ node,
474
+ dependencyConstructors
475
+ );
476
+ if (
477
+ construction &&
478
+ !isShadowedModuleBinding(construction.name, scopes)
479
+ ) {
480
+ constructions.push(construction);
481
+ }
482
+ },
483
+ {
484
+ initialScopes: [dependencyConstructors],
485
+ stopAtNestedFunctions: true,
486
+ }
487
+ );
488
+ }
489
+
490
+ return constructions;
491
+ };
492
+
493
+ const buildAccessorDiagnostic = (
494
+ trailId: string,
495
+ lookup: ResourceLookup,
496
+ resourceName: string,
497
+ filePath: string,
498
+ sourceCode: string
499
+ ): WardenDiagnostic => ({
500
+ filePath,
501
+ line: offsetToLine(sourceCode, lookup.start),
502
+ message:
503
+ `Trail "${trailId}": ${lookup.rendered} uses a dynamic resource accessor ` +
504
+ `for statically declared resource '${resourceName}'. Prefer ${resourceName}.from(ctx) ` +
505
+ 'so the dependency stays type-directed.',
506
+ rule: RULE_NAME,
507
+ severity: 'warn',
508
+ });
509
+
510
+ const buildInlineDependencyDiagnostic = (
511
+ trailId: string,
512
+ construction: InlineDependencyConstruction,
513
+ filePath: string,
514
+ sourceCode: string
515
+ ): WardenDiagnostic => ({
516
+ filePath,
517
+ line: offsetToLine(sourceCode, construction.start),
518
+ message:
519
+ `Trail "${trailId}": ${construction.rendered} constructs an external dependency ` +
520
+ 'inside blaze logic. Move the client behind a resource definition and declare it in resources.',
521
+ rule: RULE_NAME,
522
+ severity: 'warn',
523
+ });
524
+
525
+ const reportAccessorLookups = (
526
+ trailId: string,
527
+ filePath: string,
528
+ sourceCode: string,
529
+ declaredResources: readonly DeclaredStaticResource[],
530
+ lookups: readonly ResourceLookup[],
531
+ diagnostics: WardenDiagnostic[]
532
+ ): void => {
533
+ const declaredNames = buildDeclaredNameSet(declaredResources);
534
+ const declaredNameById = buildDeclaredNameById(declaredResources);
535
+
536
+ for (const lookup of lookups) {
537
+ const resourceName =
538
+ (lookup.name && declaredNames.has(lookup.name) ? lookup.name : null) ??
539
+ (lookup.id ? (declaredNameById.get(lookup.id) ?? null) : null);
540
+
541
+ if (!resourceName) {
542
+ continue;
543
+ }
544
+ if (lookup.shadowedDeclaredNames.has(resourceName)) {
545
+ continue;
546
+ }
547
+
548
+ diagnostics.push(
549
+ buildAccessorDiagnostic(
550
+ trailId,
551
+ lookup,
552
+ resourceName,
553
+ filePath,
554
+ sourceCode
555
+ )
556
+ );
557
+ }
558
+ };
559
+
560
+ const reportInlineDependencyConstructions = (
561
+ trailId: string,
562
+ filePath: string,
563
+ sourceCode: string,
564
+ constructions: readonly InlineDependencyConstruction[],
565
+ diagnostics: WardenDiagnostic[]
566
+ ): void => {
567
+ for (const construction of constructions) {
568
+ diagnostics.push(
569
+ buildInlineDependencyDiagnostic(
570
+ trailId,
571
+ construction,
572
+ filePath,
573
+ sourceCode
574
+ )
575
+ );
576
+ }
577
+ };
578
+
579
+ const checkTrailDefinition = (
580
+ def: { readonly config: AstNode; readonly id: string },
581
+ filePath: string,
582
+ sourceCode: string,
583
+ resourceIdsByName: ReadonlyMap<string, string>,
584
+ dependencyConstructors: ReadonlySet<string>,
585
+ diagnostics: WardenDiagnostic[]
586
+ ): void => {
587
+ const declaredResources = extractDeclaredStaticResources(
588
+ def.config,
589
+ resourceIdsByName
590
+ );
591
+ const lookups = collectResourceLookups(
592
+ def.config,
593
+ buildDeclaredNameSet(declaredResources)
594
+ );
595
+ reportAccessorLookups(
596
+ def.id,
597
+ filePath,
598
+ sourceCode,
599
+ declaredResources,
600
+ lookups,
601
+ diagnostics
602
+ );
603
+
604
+ const constructions = collectInlineDependencyConstructions(
605
+ def.config,
606
+ dependencyConstructors
607
+ );
608
+ reportInlineDependencyConstructions(
609
+ def.id,
610
+ filePath,
611
+ sourceCode,
612
+ constructions,
613
+ diagnostics
614
+ );
615
+ };
616
+
617
+ export const staticResourceAccessorPreference: WardenRule = {
618
+ check(sourceCode: string, filePath: string): readonly WardenDiagnostic[] {
619
+ if (isTestFile(filePath) || isFrameworkInternalFile(filePath)) {
620
+ return [];
621
+ }
622
+
623
+ const ast = parse(filePath, sourceCode);
624
+ if (!ast) {
625
+ return [];
626
+ }
627
+
628
+ const diagnostics: WardenDiagnostic[] = [];
629
+ const resourceIdsByName = collectNamedResourceIds(ast);
630
+ const dependencyConstructors = collectDependencyConstructors(ast);
631
+
632
+ for (const def of findTrailDefinitions(ast)) {
633
+ checkTrailDefinition(
634
+ def,
635
+ filePath,
636
+ sourceCode,
637
+ resourceIdsByName,
638
+ dependencyConstructors,
639
+ diagnostics
640
+ );
641
+ }
642
+
643
+ return diagnostics;
644
+ },
645
+ description:
646
+ 'Prefer static resource.from(ctx) helpers over dynamic ctx.resource() lookups when the resource definition is already in scope.',
647
+ name: RULE_NAME,
648
+ severity: 'warn',
649
+ };