@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
@@ -1,45 +1,43 @@
1
- import type { WardenDiagnostic, WardenRule } from './types.js';
2
- import {
3
- isFrameworkInternalFile,
4
- isTestFile,
5
- stripQuotedContent,
6
- } from './scan.js';
7
-
8
- const RESULT_ACCESS_PATTERN =
9
- /\.(?:isOk|isErr|match|map)\s*\(|\.(?:value|error)\b/;
10
- const IMPLEMENTATION_CALL_PATTERN = /\.run\s*\(/;
11
-
12
- const isAwaitedImplementationCall = (line: string): boolean => {
13
- const callIndex = line.indexOf('.run(');
14
- if (callIndex === -1) {
15
- return false;
16
- }
17
-
18
- const awaitIndex = line.indexOf('await');
19
- return awaitIndex !== -1 && awaitIndex < callIndex;
20
- };
21
-
22
- const isDirectResultAccess = (line: string): boolean =>
23
- IMPLEMENTATION_CALL_PATTERN.test(line) &&
24
- RESULT_ACCESS_PATTERN.test(line) &&
25
- !isAwaitedImplementationCall(line);
1
+ import { resultAccessorNames } from '@ontrails/core';
26
2
 
27
- const isPendingUse = (line: string, variableName: string): boolean => {
28
- const escaped = variableName.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&');
29
- const pendingPattern = new RegExp(
30
- `\\b${escaped}\\s*(?:\\.(?:isOk|isErr|match|map)\\s*\\(|\\.(?:value|error)\\b)`
31
- );
32
- return pendingPattern.test(line);
33
- };
3
+ import {
4
+ getNodeAlternate,
5
+ getNodeArgument,
6
+ getNodeBodyNode,
7
+ getNodeBodyStatements,
8
+ getNodeConsequent,
9
+ getNodeDeclarations,
10
+ getNodeElements,
11
+ getNodeExpression,
12
+ getNodeId,
13
+ getNodeInit,
14
+ getNodeKey,
15
+ getNodeKind,
16
+ getNodeLeft,
17
+ getNodeName,
18
+ getNodeObject,
19
+ getNodeOperator,
20
+ getNodeParam,
21
+ getNodeParams,
22
+ getNodeProperties,
23
+ getNodeProperty,
24
+ getNodeRight,
25
+ getNodeValueNode,
26
+ identifierName,
27
+ isImplementationCall,
28
+ offsetToLine,
29
+ parse,
30
+ } from '@ontrails/source';
31
+ import type { AstNode } from '@ontrails/source';
32
+ import { isFrameworkInternalFile, isTestFile } from './scan.js';
33
+ import type { WardenDiagnostic, WardenRule } from './types.js';
34
34
 
35
- interface PendingCall {
36
- line: number;
37
- remainingLines: number;
38
- variableName: string;
39
- }
35
+ const RESULT_ACCESSOR_PROPERTIES: ReadonlySet<string> = new Set(
36
+ resultAccessorNames
37
+ );
40
38
 
41
39
  const MISSING_AWAIT_MESSAGE =
42
- 'Missing await: .run() returns Promise<Result> after normalization. Use `const result = await trail.run(input, ctx)`.';
40
+ 'Missing await: .implementation() returns Promise<Result> after normalization. Use `const result = await trail.implementation(input, ctx)`.';
43
41
 
44
42
  const createMissingAwaitDiagnostic = (
45
43
  filePath: string,
@@ -52,105 +50,1150 @@ const createMissingAwaitDiagnostic = (
52
50
  severity: 'error',
53
51
  });
54
52
 
55
- const trackPendingCall = (line: string): string | undefined => {
56
- const match = line.match(
57
- /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*([^;]*)/
53
+ const isAstLike = (value: unknown): value is AstNode =>
54
+ !!value && typeof value === 'object' && !!(value as AstNode).type;
55
+
56
+ /**
57
+ * Build parent map for a full AST.
58
+ *
59
+ * Populates a `WeakMap` directly during traversal so we never materialize a
60
+ * strong `Map` holding references to every AST node — the WeakMap lets parent
61
+ * entries be reclaimed alongside their nodes once the rule invocation ends.
62
+ */
63
+ const buildParentMap = (ast: AstNode): WeakMap<AstNode, AstNode> => {
64
+ const parents = new WeakMap<AstNode, AstNode>();
65
+
66
+ const recordAndVisit = (child: unknown, parent: AstNode): void => {
67
+ if (isAstLike(child)) {
68
+ parents.set(child, parent);
69
+ // eslint-disable-next-line no-use-before-define
70
+ visit(child);
71
+ }
72
+ };
73
+
74
+ const visit = (node: AstNode): void => {
75
+ for (const val of Object.values(node)) {
76
+ if (Array.isArray(val)) {
77
+ for (const item of val) {
78
+ recordAndVisit(item, node);
79
+ }
80
+ } else {
81
+ recordAndVisit(val, node);
82
+ }
83
+ }
84
+ };
85
+
86
+ visit(ast);
87
+ return parents;
88
+ };
89
+
90
+ /**
91
+ * Walk up the parent chain and return true when the expression is awaited
92
+ * before any result-accessing member access fires on it.
93
+ *
94
+ * `await x.implementation(...)` → awaited.
95
+ * `(await x.implementation(...)).isOk()` → awaited (await wraps before member access).
96
+ * `x.implementation(...).isOk()` → NOT awaited (member access on raw call).
97
+ */
98
+ const TRANSPARENT_WRAPPER_TYPES = new Set([
99
+ 'ParenthesizedExpression',
100
+ 'TSAsExpression',
101
+ 'TSSatisfiesExpression',
102
+ 'TSNonNullExpression',
103
+ 'TSTypeAssertion',
104
+ ]);
105
+
106
+ const skipParens = (
107
+ node: AstNode,
108
+ parents: WeakMap<AstNode, AstNode>
109
+ ): AstNode => {
110
+ let current = node;
111
+ let parent = parents.get(current);
112
+ while (parent?.type && TRANSPARENT_WRAPPER_TYPES.has(parent.type)) {
113
+ current = parent;
114
+ parent = parents.get(current);
115
+ }
116
+ return current;
117
+ };
118
+
119
+ /**
120
+ * Walk up through any wrapping parentheses and, when the current node sits
121
+ * in the `consequent` or `alternate` of a `ConditionalExpression`, through
122
+ * that conditional too. Returns the node whose parent should be inspected.
123
+ *
124
+ * Conservative: we only hop across a conditional when the node is one of
125
+ * its branches (not the `test` position). This lets us treat both
126
+ * `const r = cond ? x.implementation(...) : fallback` and
127
+ * `await (cond ? x.implementation(...) : fallback)` correctly without misattributing
128
+ * calls used as conditions.
129
+ */
130
+ const isBranchOfConditional = (outer: AstNode, parent: AstNode): boolean => {
131
+ if (parent.type !== 'ConditionalExpression') {
132
+ return false;
133
+ }
134
+ return (
135
+ getNodeConsequent(parent) === outer || getNodeAlternate(parent) === outer
58
136
  );
59
- if (!match?.[1] || !match[2] || !IMPLEMENTATION_CALL_PATTERN.test(match[2])) {
60
- return undefined;
137
+ };
138
+
139
+ /**
140
+ * Logical expressions (`&&`, `||`, `??`) carry the implementation result through either
141
+ * side. A `.implementation()` on either operand may be the value ultimately bound to a
142
+ * declarator (e.g. `const r = cond && trail.implementation(...)`), so we treat both
143
+ * operands as carriers.
144
+ */
145
+ const isOperandOfLogical = (outer: AstNode, parent: AstNode): boolean => {
146
+ if (parent.type !== 'LogicalExpression') {
147
+ return false;
61
148
  }
149
+ return getNodeLeft(parent) === outer || getNodeRight(parent) === outer;
150
+ };
62
151
 
63
- if (isAwaitedImplementationCall(match[2])) {
64
- return undefined;
152
+ const skipParensAndBranchConditionals = (
153
+ node: AstNode,
154
+ parents: WeakMap<AstNode, AstNode>
155
+ ): AstNode => {
156
+ let outer = skipParens(node, parents);
157
+ while (true) {
158
+ const parent = parents.get(outer);
159
+ if (!parent) {
160
+ return outer;
161
+ }
162
+ if (
163
+ !(
164
+ isBranchOfConditional(outer, parent) ||
165
+ isOperandOfLogical(outer, parent)
166
+ )
167
+ ) {
168
+ return outer;
169
+ }
170
+ outer = skipParens(parent, parents);
171
+ }
172
+ };
173
+
174
+ const isAwaited = (
175
+ node: AstNode,
176
+ parents: WeakMap<AstNode, AstNode>
177
+ ): boolean => {
178
+ // Walk up through parens and any conditional whose branch is the implementation
179
+ // call. `await (c ? x.implementation(...) : fallback)` awaits the conditional as a
180
+ // whole, so the implementation call in a branch is effectively awaited.
181
+ const outer = skipParensAndBranchConditionals(node, parents);
182
+ return parents.get(outer)?.type === 'AwaitExpression';
183
+ };
184
+
185
+ const memberPropertyName = (node: AstNode): string | null => {
186
+ if (
187
+ node.type !== 'MemberExpression' &&
188
+ node.type !== 'StaticMemberExpression'
189
+ ) {
190
+ return null;
191
+ }
192
+ const prop = getNodeProperty(node);
193
+ if (prop?.type !== 'Identifier') {
194
+ return null;
195
+ }
196
+ return getNodeName(prop) ?? null;
197
+ };
198
+
199
+ /**
200
+ * Check if the implementation call is directly consumed by a result accessor
201
+ * (e.g. `foo.implementation(...).isOk()` or `foo.implementation(...).value`).
202
+ */
203
+ const hasDirectResultAccess = (
204
+ implementationCall: AstNode,
205
+ parents: WeakMap<AstNode, AstNode>
206
+ ): boolean => {
207
+ // Unwrap wrapping parentheses, conditional branches, and logical-operator
208
+ // operands so `(x.implementation(...)).isOk()`,
209
+ // `(cond ? x.implementation(...) : fb).isOk()`, and
210
+ // `(cond && x.implementation(...)).isOk()` are all detected the same way as the
211
+ // bare `x.implementation(...).isOk()` shape.
212
+ const outer = skipParensAndBranchConditionals(implementationCall, parents);
213
+ const parent = parents.get(outer);
214
+ if (!parent) {
215
+ return false;
65
216
  }
217
+ const property = memberPropertyName(parent);
218
+ return property !== null && RESULT_ACCESSOR_PROPERTIES.has(property);
219
+ };
66
220
 
67
- return match[1];
221
+ /**
222
+ * If the implementation call is the init of a VariableDeclarator (directly, through
223
+ * parens, or as a branch of a ConditionalExpression init), return the bound
224
+ * identifier name. Otherwise null.
225
+ */
226
+ const extractAssignedBinding = (
227
+ implementationCall: AstNode,
228
+ parents: WeakMap<AstNode, AstNode>
229
+ ): string | null => {
230
+ const outer = skipParensAndBranchConditionals(implementationCall, parents);
231
+ const parent = parents.get(outer);
232
+ if (!parent || parent.type !== 'VariableDeclarator') {
233
+ return null;
234
+ }
235
+ const id = getNodeId(parent);
236
+ return identifierName(id);
68
237
  };
69
238
 
70
- const addPendingCall = (
71
- pendingCalls: PendingCall[],
72
- variableName: string,
73
- lineNumber: number
239
+ interface PendingBinding {
240
+ readonly name: string;
241
+ readonly declarationNode: AstNode;
242
+ /** Unique id of the scope frame that owns this binding. */
243
+ readonly scopeId: number;
244
+ }
245
+
246
+ const isResultAccessorMember = (node: AstNode): boolean => {
247
+ if (
248
+ node.type !== 'MemberExpression' &&
249
+ node.type !== 'StaticMemberExpression'
250
+ ) {
251
+ return false;
252
+ }
253
+ const property = memberPropertyName(node);
254
+ return property !== null && RESULT_ACCESSOR_PROPERTIES.has(property);
255
+ };
256
+
257
+ const getIdentifierObjectName = (node: AstNode): string | null => {
258
+ const object = getNodeObject(node);
259
+ return object?.type === 'Identifier' ? identifierName(object) : null;
260
+ };
261
+
262
+ // ---------------------------------------------------------------------------
263
+ // Scope tracking
264
+ // ---------------------------------------------------------------------------
265
+
266
+ const collectIdentifierBinding = (pattern: AstNode, out: Set<string>): void => {
267
+ const name = identifierName(pattern);
268
+ if (name) {
269
+ out.add(name);
270
+ }
271
+ };
272
+
273
+ const collectAssignmentPatternBindings = (
274
+ pattern: AstNode,
275
+ out: Set<string>
74
276
  ): void => {
75
- pendingCalls.push({
76
- line: lineNumber,
77
- remainingLines: 6,
78
- variableName,
79
- });
277
+ const left = getNodeLeft(pattern);
278
+ // eslint-disable-next-line no-use-before-define
279
+ collectPatternBindings(left, out);
80
280
  };
81
281
 
82
- const advancePendingCalls = (
83
- line: string,
84
- filePath: string,
85
- lineNumber: number,
86
- pendingCalls: PendingCall[],
87
- diagnostics: WardenDiagnostic[]
282
+ const collectRestElementBindings = (
283
+ pattern: AstNode,
284
+ out: Set<string>
285
+ ): void => {
286
+ const argument = getNodeArgument(pattern);
287
+ // eslint-disable-next-line no-use-before-define
288
+ collectPatternBindings(argument, out);
289
+ };
290
+
291
+ type PatternHandler = (pattern: AstNode, out: Set<string>) => void;
292
+
293
+ const PATTERN_HANDLERS: Record<string, PatternHandler> = {
294
+ // eslint-disable-next-line no-use-before-define
295
+ ArrayPattern: (p, out) => collectArrayPatternBindings(p, out),
296
+ AssignmentPattern: collectAssignmentPatternBindings,
297
+ Identifier: collectIdentifierBinding,
298
+ // eslint-disable-next-line no-use-before-define
299
+ ObjectPattern: (p, out) => collectObjectPatternBindings(p, out),
300
+ RestElement: collectRestElementBindings,
301
+ };
302
+
303
+ /**
304
+ * Collect binding names introduced by a destructuring / parameter pattern.
305
+ * Handles Identifier, AssignmentPattern, ObjectPattern, ArrayPattern,
306
+ * and RestElement shapes.
307
+ *
308
+ * `function` declaration (instead of an arrow) so it can be hoisted for the
309
+ * mutually recursive calls from the array / object pattern helpers below.
310
+ */
311
+ // biome-ignore lint/style/useConst: hoisted for mutual recursion
312
+ // eslint-disable-next-line func-style
313
+ function collectPatternBindings(
314
+ pattern: AstNode | undefined,
315
+ out: Set<string>
316
+ ): void {
317
+ if (!pattern) {
318
+ return;
319
+ }
320
+ const handler = PATTERN_HANDLERS[pattern.type];
321
+ if (handler) {
322
+ handler(pattern, out);
323
+ }
324
+ }
325
+
326
+ const collectArrayPatternBindings = (
327
+ pattern: AstNode,
328
+ out: Set<string>
329
+ ): void => {
330
+ const elements = getNodeElements(pattern);
331
+ if (!elements) {
332
+ return;
333
+ }
334
+ for (const element of elements) {
335
+ if (element) {
336
+ // eslint-disable-next-line no-use-before-define
337
+ collectPatternBindings(element, out);
338
+ }
339
+ }
340
+ };
341
+
342
+ const collectObjectPatternBindings = (
343
+ pattern: AstNode,
344
+ out: Set<string>
345
+ ): void => {
346
+ const properties = getNodeProperties(pattern);
347
+ if (!properties) {
348
+ return;
349
+ }
350
+ for (const prop of properties) {
351
+ if (prop.type === 'RestElement') {
352
+ // eslint-disable-next-line no-use-before-define
353
+ collectPatternBindings(prop, out);
354
+ } else {
355
+ // Property node: value holds the binding pattern.
356
+ const value = getNodeValueNode(prop);
357
+ // eslint-disable-next-line no-use-before-define
358
+ collectPatternBindings(value, out);
359
+ }
360
+ }
361
+ };
362
+
363
+ const SCOPE_NODE_TYPES = new Set([
364
+ 'FunctionDeclaration',
365
+ 'FunctionExpression',
366
+ 'ArrowFunctionExpression',
367
+ 'BlockStatement',
368
+ 'StaticBlock',
369
+ 'CatchClause',
370
+ 'ForStatement',
371
+ 'ForInStatement',
372
+ 'ForOfStatement',
373
+ ]);
374
+
375
+ const isScopeBoundary = (node: AstNode): boolean =>
376
+ SCOPE_NODE_TYPES.has(node.type);
377
+
378
+ /**
379
+ * Collect the local binding names introduced directly in this scope's own
380
+ * declarations (params + var/let/const/catch/for declarations), without
381
+ * descending into nested function or block scopes.
382
+ *
383
+ * For function-like scopes, the body (a BlockStatement) is its own child
384
+ * scope — we do not merge params into it. Params and body bindings are
385
+ * treated as sibling frames via the scope walk: when entering the function,
386
+ * we push a frame with params; when entering its body block, we push another
387
+ * frame with the block's declarations. Nearest-scope resolution treats them
388
+ * as a single effective scope chain.
389
+ */
390
+ const FUNCTION_SCOPE_TYPES = new Set([
391
+ 'FunctionDeclaration',
392
+ 'FunctionExpression',
393
+ 'ArrowFunctionExpression',
394
+ ]);
395
+
396
+ const collectVariableDeclarationBindings = (
397
+ declNode: AstNode | undefined,
398
+ out: Set<string>
399
+ ): void => {
400
+ if (!declNode || declNode.type !== 'VariableDeclaration') {
401
+ return;
402
+ }
403
+ const declarators = getNodeDeclarations(declNode);
404
+ if (!declarators) {
405
+ return;
406
+ }
407
+ for (const d of declarators) {
408
+ const id = getNodeId(d);
409
+ collectPatternBindings(id, out);
410
+ }
411
+ };
412
+
413
+ const getVariableDeclarationKind = (
414
+ declNode: AstNode | undefined
415
+ ): string | null => {
416
+ if (!declNode || declNode.type !== 'VariableDeclaration') {
417
+ return null;
418
+ }
419
+ return getNodeKind(declNode) ?? null;
420
+ };
421
+
422
+ /** True if declaration is `var` (function/program-scoped, hoistable). */
423
+ const isVarDeclaration = (declNode: AstNode | undefined): boolean =>
424
+ getVariableDeclarationKind(declNode) === 'var';
425
+
426
+ /** Collect only `let`/`const` declarator bindings (block-scoped). */
427
+ const collectBlockScopedDeclaratorBindings = (
428
+ declNode: AstNode | undefined,
429
+ out: Set<string>
88
430
  ): void => {
89
- for (let j = pendingCalls.length - 1; j >= 0; j -= 1) {
90
- const pendingCall = pendingCalls[j];
91
- if (pendingCall && isPendingUse(line, pendingCall.variableName)) {
92
- diagnostics.push(createMissingAwaitDiagnostic(filePath, lineNumber));
93
- pendingCalls.splice(j, 1);
94
- } else if (pendingCall) {
95
- pendingCall.remainingLines -= 1;
96
- if (pendingCall.remainingLines <= 0) {
97
- pendingCalls.splice(j, 1);
431
+ const kind = getVariableDeclarationKind(declNode);
432
+ if (!kind || kind === 'var') {
433
+ return;
434
+ }
435
+ collectVariableDeclarationBindings(declNode, out);
436
+ };
437
+
438
+ interface FunctionScopeBindings {
439
+ readonly bindings: Set<string>;
440
+ readonly paramBindings: Set<string>;
441
+ }
442
+
443
+ const collectParamBindings = (scope: AstNode): Set<string> => {
444
+ const paramBindings = new Set<string>();
445
+ const params = getNodeParams(scope);
446
+ if (params) {
447
+ for (const param of params) {
448
+ collectPatternBindings(param, paramBindings);
449
+ }
450
+ }
451
+ return paramBindings;
452
+ };
453
+
454
+ const addHoistedVarsFromBody = (scope: AstNode, out: Set<string>): void => {
455
+ const body = getNodeBodyNode(scope);
456
+ if (!(body && isAstLike(body))) {
457
+ return;
458
+ }
459
+ const hoisted = new Set<string>();
460
+ // eslint-disable-next-line no-use-before-define
461
+ collectHoistedVarBindings(body, hoisted);
462
+ for (const name of hoisted) {
463
+ out.add(name);
464
+ }
465
+ };
466
+
467
+ const collectFunctionScopeBindingsEx = (
468
+ scope: AstNode
469
+ ): FunctionScopeBindings => {
470
+ const paramBindings = collectParamBindings(scope);
471
+ const bindings = new Set<string>(paramBindings);
472
+ addHoistedVarsFromBody(scope, bindings);
473
+ return { bindings, paramBindings };
474
+ };
475
+
476
+ const collectFunctionScopeBindings = (scope: AstNode): Set<string> =>
477
+ collectFunctionScopeBindingsEx(scope).bindings;
478
+
479
+ const collectCatchScopeBindings = (scope: AstNode): Set<string> => {
480
+ const bindings = new Set<string>();
481
+ const param = getNodeParam(scope);
482
+ collectPatternBindings(param, bindings);
483
+ return bindings;
484
+ };
485
+
486
+ const collectForScopeBindings = (scope: AstNode): Set<string> => {
487
+ const bindings = new Set<string>();
488
+ if (scope.type === 'ForStatement') {
489
+ const init = getNodeInit(scope);
490
+ collectBlockScopedDeclaratorBindings(init, bindings);
491
+ } else {
492
+ const left = getNodeLeft(scope);
493
+ collectBlockScopedDeclaratorBindings(left, bindings);
494
+ }
495
+ return bindings;
496
+ };
497
+
498
+ const addFunctionDeclarationName = (stmt: AstNode, out: Set<string>): void => {
499
+ if (stmt.type !== 'FunctionDeclaration') {
500
+ return;
501
+ }
502
+ const id = getNodeId(stmt);
503
+ const fnName = identifierName(id);
504
+ if (fnName) {
505
+ out.add(fnName);
506
+ }
507
+ };
508
+
509
+ const addClassDeclarationName = (stmt: AstNode, out: Set<string>): void => {
510
+ if (stmt.type !== 'ClassDeclaration') {
511
+ return;
512
+ }
513
+ const id = getNodeId(stmt);
514
+ const className = identifierName(id);
515
+ if (className) {
516
+ out.add(className);
517
+ }
518
+ };
519
+
520
+ const collectBlockScopedStatementListBindings = (
521
+ statements: readonly AstNode[] | undefined,
522
+ out: Set<string>
523
+ ): void => {
524
+ if (!statements) {
525
+ return;
526
+ }
527
+ for (const stmt of statements) {
528
+ collectBlockScopedDeclaratorBindings(stmt, out);
529
+ addFunctionDeclarationName(stmt, out);
530
+ addClassDeclarationName(stmt, out);
531
+ }
532
+ };
533
+
534
+ const collectBlockStatementBindings = (scope: AstNode): Set<string> => {
535
+ const bindings = new Set<string>();
536
+ const body = getNodeBodyStatements(scope);
537
+ collectBlockScopedStatementListBindings(body, bindings);
538
+ // Static initializer blocks own their own VariableEnvironment (per ES spec),
539
+ // so `var` declarations inside them do not escape into the enclosing class
540
+ // or function scope. `collectHoistedVarBindings` correctly refuses to compose
541
+ // a `StaticBlock` boundary from the outside, which means nothing else will
542
+ // register these bindings. Hoist them here so `var result = trail.implementation(...)`
543
+ // inside a `static { ... }` block is tracked against the block itself.
544
+ if (scope.type === 'StaticBlock') {
545
+ // `collectHoistedVarBindings` is called with the StaticBlock as the root,
546
+ // so the own-VariableEnvironment check (which refuses to descend *into* a
547
+ // nested StaticBlock) does not short-circuit traversal of the node itself.
548
+ // eslint-disable-next-line no-use-before-define
549
+ collectHoistedVarBindings(scope, bindings);
550
+ }
551
+ return bindings;
552
+ };
553
+
554
+ /**
555
+ * Collect the local binding names introduced directly in this scope's own
556
+ * declarations (params + var/let/const/catch/for declarations), without
557
+ * descending into nested function or block scopes.
558
+ */
559
+ const collectScopeBindings = (scope: AstNode): Set<string> => {
560
+ if (FUNCTION_SCOPE_TYPES.has(scope.type)) {
561
+ return collectFunctionScopeBindings(scope);
562
+ }
563
+ if (scope.type === 'CatchClause') {
564
+ return collectCatchScopeBindings(scope);
565
+ }
566
+ if (
567
+ scope.type === 'ForStatement' ||
568
+ scope.type === 'ForInStatement' ||
569
+ scope.type === 'ForOfStatement'
570
+ ) {
571
+ return collectForScopeBindings(scope);
572
+ }
573
+ if (scope.type === 'BlockStatement' || scope.type === 'StaticBlock') {
574
+ return collectBlockStatementBindings(scope);
575
+ }
576
+ return new Set();
577
+ };
578
+
579
+ type ScopeKind = 'program' | 'function' | 'block' | 'for' | 'catch';
580
+
581
+ interface ScopeFrame {
582
+ readonly id: number;
583
+ readonly kind: ScopeKind;
584
+ readonly bindings: Set<string>;
585
+ /**
586
+ * For function frames: names that came from parameters (not hoisted `var`s).
587
+ * A `var` declaration with the same name as a parameter is redundant in JS —
588
+ * the parameter is the real binding. We track params separately so we don't
589
+ * register a pending `.implementation()` binding that is actually shadowed by a param.
590
+ */
591
+ readonly paramBindings?: Set<string>;
592
+ }
593
+
594
+ const scopeKindForNode = (node: AstNode): ScopeKind => {
595
+ if (FUNCTION_SCOPE_TYPES.has(node.type)) {
596
+ return 'function';
597
+ }
598
+ if (node.type === 'CatchClause') {
599
+ return 'catch';
600
+ }
601
+ if (
602
+ node.type === 'ForStatement' ||
603
+ node.type === 'ForInStatement' ||
604
+ node.type === 'ForOfStatement'
605
+ ) {
606
+ return 'for';
607
+ }
608
+ return 'block';
609
+ };
610
+
611
+ /**
612
+ * True when a nested node owns its own VariableEnvironment and therefore stops
613
+ * `var` hoisting from composing into the enclosing function/program scope.
614
+ * Covers function-like nodes and `StaticBlock` (ECMAScript: static blocks
615
+ * introduce their own LexicalEnvironment and VariableEnvironment).
616
+ */
617
+ const ownsVariableEnvironment = (node: AstNode): boolean =>
618
+ FUNCTION_SCOPE_TYPES.has(node.type) || node.type === 'StaticBlock';
619
+
620
+ const collectHoistedVarBindings = (root: AstNode, out: Set<string>): void => {
621
+ const visit = (node: AstNode, isRoot: boolean): void => {
622
+ // Nested var-environment owners (functions, static blocks) do not leak
623
+ // their `var`s to the enclosing scope.
624
+ if (!isRoot && ownsVariableEnvironment(node)) {
625
+ return;
626
+ }
627
+ if (node.type === 'VariableDeclaration' && isVarDeclaration(node)) {
628
+ collectVariableDeclarationBindings(node, out);
629
+ }
630
+ for (const val of Object.values(node)) {
631
+ if (Array.isArray(val)) {
632
+ for (const item of val) {
633
+ if (isAstLike(item)) {
634
+ visit(item, false);
635
+ }
636
+ }
637
+ } else if (isAstLike(val)) {
638
+ visit(val, false);
98
639
  }
99
640
  }
641
+ };
642
+ visit(root, true);
643
+ };
644
+
645
+ interface AnalyzeState {
646
+ readonly parents: WeakMap<AstNode, AstNode>;
647
+ readonly diagnostics: WardenDiagnostic[];
648
+ readonly sourceCode: string;
649
+ readonly filePath: string;
650
+ /** Pending `.implementation()` bindings seen so far, keyed by scope id + name. */
651
+ readonly pendingByScopeAndName: Map<string, PendingBinding>;
652
+ readonly scopeStack: ScopeFrame[];
653
+ readonly reportedAt: Set<number>;
654
+ /**
655
+ * Monotonic counter for scope frame ids. Intentionally mutable — every other
656
+ * field on `AnalyzeState` is `readonly`, but this one is incremented with
657
+ * `state.nextScopeId += 1` each time a scope frame is pushed so sibling
658
+ * scopes get distinct ids. Keeping it as a plain number (rather than a
659
+ * boxed `{ current: number }`) avoids an extra allocation and indirection
660
+ * on a hot path; the mutability is local to `pushScopeIfBoundary`.
661
+ */
662
+ nextScopeId: number;
663
+ }
664
+
665
+ const pendingKey = (scopeId: number, name: string): string =>
666
+ `${scopeId}\u0000${name}`;
667
+
668
+ /**
669
+ * Resolve an identifier use to the nearest enclosing scope frame that binds
670
+ * the name. Returns `null` if no frame binds it.
671
+ */
672
+ const resolveNearestScope = (
673
+ name: string,
674
+ stack: readonly ScopeFrame[]
675
+ ): ScopeFrame | null => {
676
+ for (let i = stack.length - 1; i >= 0; i -= 1) {
677
+ const frame = stack[i];
678
+ if (frame && frame.bindings.has(name)) {
679
+ return frame;
680
+ }
100
681
  }
682
+ return null;
101
683
  };
102
684
 
103
- const processLine = (
104
- line: string,
105
- filePath: string,
106
- lineNumber: number,
107
- pendingCalls: PendingCall[],
108
- diagnostics: WardenDiagnostic[]
685
+ /**
686
+ * Resolve the implementation call to a `{ name, declarator }` pair when it is the init
687
+ * of a `VariableDeclarator` (directly, through parens, or as a branch of a
688
+ * `ConditionalExpression` init). Returns null otherwise.
689
+ */
690
+ const resolveImplementationBinding = (
691
+ implementationCall: AstNode,
692
+ parents: WeakMap<AstNode, AstNode>
693
+ ): { readonly name: string; readonly declarator: AstNode } | null => {
694
+ const name = extractAssignedBinding(implementationCall, parents);
695
+ if (!name) {
696
+ return null;
697
+ }
698
+ // Mirror `extractAssignedBinding`: unwrap parens and branch-position
699
+ // conditionals so the stored declaration node points at the
700
+ // `VariableDeclarator`, not at an intermediate `ParenthesizedExpression`
701
+ // or `ConditionalExpression`.
702
+ const outer = skipParensAndBranchConditionals(implementationCall, parents);
703
+ const declarator = parents.get(outer);
704
+ return declarator ? { declarator, name } : null;
705
+ };
706
+
707
+ /**
708
+ * Resolve the implementation call to a `{ name, assignment }` pair when it is the RHS
709
+ * of a plain `=` `AssignmentExpression` with an `Identifier` LHS (directly,
710
+ * through parens, or as a branch of a conditional/logical expression).
711
+ *
712
+ * Covers patterns like:
713
+ * let result;
714
+ * result = trail.implementation(input, ctx);
715
+ * result.isOk();
716
+ *
717
+ * Member-expression LHS (`obj.result = implementation(...)`) is intentionally skipped —
718
+ * those are property writes, not bare bindings we can track by name.
719
+ */
720
+ const extractPlainIdentifierAssignmentName = (
721
+ parent: AstNode | undefined
722
+ ): string | null => {
723
+ if (!parent || parent.type !== 'AssignmentExpression') {
724
+ return null;
725
+ }
726
+ const operator = getNodeOperator(parent);
727
+ const left = getNodeLeft(parent);
728
+ // Only plain `=` assignments to a bare identifier. Member-expression LHS
729
+ // (`obj.result = implementation(...)`) is a property write, not a bare binding we
730
+ // can track by name.
731
+ if (operator !== '=' || !left || left.type !== 'Identifier') {
732
+ return null;
733
+ }
734
+ return identifierName(left);
735
+ };
736
+
737
+ const resolveImplementationAssignment = (
738
+ implementationCall: AstNode,
739
+ parents: WeakMap<AstNode, AstNode>
740
+ ): { readonly name: string; readonly assignment: AstNode } | null => {
741
+ const outer = skipParensAndBranchConditionals(implementationCall, parents);
742
+ const parent = parents.get(outer);
743
+ const name = extractPlainIdentifierAssignmentName(parent);
744
+ return name && parent ? { assignment: parent, name } : null;
745
+ };
746
+
747
+ /**
748
+ * True when `declarator` is a `VariableDeclarator` whose parent
749
+ * `VariableDeclaration` uses the `var` kind. Such declarators re-initialize
750
+ * a same-named function parameter rather than shadowing it, because `var`
751
+ * and parameters share the function's VariableEnvironment.
752
+ */
753
+ const isVarDeclaratorOfParamName = (
754
+ declarator: AstNode,
755
+ parents: WeakMap<AstNode, AstNode>
756
+ ): boolean => {
757
+ if (declarator.type !== 'VariableDeclarator') {
758
+ return false;
759
+ }
760
+ const decl = parents.get(declarator);
761
+ return isVarDeclaration(decl);
762
+ };
763
+
764
+ /**
765
+ * True when `node` is a plain `=` `AssignmentExpression` with an `Identifier`
766
+ * LHS. Such an assignment writes to the existing binding for that name — if
767
+ * that name is a function parameter, the assignment re-initializes the
768
+ * parameter's slot in the VariableEnvironment, just like `var <name> = ...`.
769
+ * Compound assignments (`+=`, `??=`, etc.) are excluded because they do not
770
+ * unconditionally replace the slot with the implementation result.
771
+ */
772
+ const isAssignmentToParamName = (node: AstNode): boolean => {
773
+ if (node.type !== 'AssignmentExpression') {
774
+ return false;
775
+ }
776
+ const operator = getNodeOperator(node);
777
+ const left = getNodeLeft(node);
778
+ return operator === '=' && left?.type === 'Identifier';
779
+ };
780
+
781
+ const recordPendingBinding = (
782
+ implementationCall: AstNode,
783
+ state: AnalyzeState
784
+ ): void => {
785
+ const binding =
786
+ resolveImplementationBinding(implementationCall, state.parents) ??
787
+ (() => {
788
+ const asn = resolveImplementationAssignment(
789
+ implementationCall,
790
+ state.parents
791
+ );
792
+ return asn ? { declarator: asn.assignment, name: asn.name } : null;
793
+ })();
794
+ if (!binding) {
795
+ return;
796
+ }
797
+ const { name, declarator } = binding;
798
+ // The pending binding lives in the nearest scope that declares `name`.
799
+ // That is always the innermost scope in the current stack, because the
800
+ // variable declaration's id was contributed to its enclosing scope's
801
+ // bindings when that scope was entered.
802
+ const owningFrame = resolveNearestScope(name, state.scopeStack);
803
+ if (!owningFrame) {
804
+ return;
805
+ }
806
+ // If the name resolves to a function parameter, the `var` that visually
807
+ // appears to declare it is redundant — the parameter is the real binding,
808
+ // and parameters are not pending `.implementation()` results.
809
+ //
810
+ // Carve-out: a `var <name> = implementation(...)` *initializer* inside the same
811
+ // function body legitimately re-binds the parameter at that point. `var`
812
+ // and parameters share the function's VariableEnvironment, so the `var`
813
+ // writes to the existing parameter slot and the subsequent use resolves
814
+ // to the freshly-assigned `.implementation()` result. Treat that as a pending
815
+ // binding.
816
+ //
817
+ // The same logic applies to a bare `result = implementation(...)` assignment: it
818
+ // writes to the parameter's existing slot in the same VariableEnvironment,
819
+ // so the subsequent `result.isOk()` observes the implementation result. Only
820
+ // compound assignments (`+=`, `??=`, etc.) and member-expression LHS fall
821
+ // through the param-shadow suppression, because they do not
822
+ // unconditionally replace the parameter slot with the implementation result.
823
+ if (
824
+ owningFrame.paramBindings?.has(name) &&
825
+ !isVarDeclaratorOfParamName(declarator, state.parents) &&
826
+ !isAssignmentToParamName(declarator)
827
+ ) {
828
+ return;
829
+ }
830
+ state.pendingByScopeAndName.set(pendingKey(owningFrame.id, name), {
831
+ declarationNode: declarator,
832
+ name,
833
+ scopeId: owningFrame.id,
834
+ });
835
+ };
836
+
837
+ /**
838
+ * True when `expr`, descended through wrapping parens, conditional branches,
839
+ * and logical-operator operands, contains a `.implementation()` call that would be
840
+ * registered by `recordPendingBinding` for this assignment.
841
+ *
842
+ * This mirrors the *upward* carrier walk done by
843
+ * `skipParensAndBranchConditionals` — if a implementation call is anywhere along a
844
+ * carrier path descending from `expr`, then visiting that implementation call will
845
+ * re-register the pending binding, so we must not clear it on the way in.
846
+ */
847
+ type CarrierChildExtractor = (
848
+ expr: AstNode
849
+ ) => readonly (AstNode | undefined)[];
850
+
851
+ const CARRIER_CHILDREN: Record<string, CarrierChildExtractor> = {
852
+ ConditionalExpression: (expr) => [
853
+ getNodeConsequent(expr),
854
+ getNodeAlternate(expr),
855
+ ],
856
+ LogicalExpression: (expr) => {
857
+ const left = getNodeLeft(expr);
858
+ const right = getNodeRight(expr);
859
+ return [left, right];
860
+ },
861
+ };
862
+
863
+ const unwrapTransparentWrapper = (expr: AstNode): AstNode | undefined =>
864
+ getNodeExpression(expr);
865
+
866
+ // biome-ignore lint/style/useConst: hoisted for recursive call
867
+ // eslint-disable-next-line func-style
868
+ function rhsCarriesImplementationReinit(expr: AstNode | undefined): boolean {
869
+ if (!expr) {
870
+ return false;
871
+ }
872
+ if (TRANSPARENT_WRAPPER_TYPES.has(expr.type)) {
873
+ return rhsCarriesImplementationReinit(unwrapTransparentWrapper(expr));
874
+ }
875
+ const extractor = CARRIER_CHILDREN[expr.type];
876
+ if (extractor) {
877
+ return extractor(expr).some(rhsCarriesImplementationReinit);
878
+ }
879
+ return isImplementationCall(expr);
880
+ }
881
+
882
+ /**
883
+ * Nullish/falsy-skip compound assignments (`??=`, `||=`) only write to the slot
884
+ * when the LHS is nullish or falsy. A pending `.implementation()` binding holds a
885
+ * truthy `Promise<Result>`, so the RHS never runs and the pending binding must
886
+ * survive them.
887
+ *
888
+ * `&&=` is intentionally excluded: it writes when the LHS is truthy, so a
889
+ * pending `Promise<Result>` is *always* overwritten by the RHS. That matches
890
+ * the clearing behavior of mathematical compound operators (`+=`, `-=`, ...).
891
+ */
892
+ const NULLISH_SKIP_OPERATORS = new Set(['??=', '||=']);
893
+
894
+ interface IdentifierAssignment {
895
+ readonly operator: string;
896
+ readonly name: string;
897
+ readonly right: AstNode | undefined;
898
+ }
899
+
900
+ const extractIdentifierAssignment = (
901
+ node: AstNode
902
+ ): IdentifierAssignment | null => {
903
+ if (node.type !== 'AssignmentExpression') {
904
+ return null;
905
+ }
906
+ const operator = getNodeOperator(node);
907
+ const left = getNodeLeft(node);
908
+ const right = getNodeRight(node);
909
+ if (!(operator && left) || left.type !== 'Identifier') {
910
+ return null;
911
+ }
912
+ const name = identifierName(left);
913
+ return name ? { name, operator, right } : null;
914
+ };
915
+
916
+ const resolvePendingKeyFor = (
917
+ name: string,
918
+ state: AnalyzeState
919
+ ): string | null => {
920
+ const frame = resolveNearestScope(name, state.scopeStack);
921
+ if (!frame) {
922
+ return null;
923
+ }
924
+ const key = pendingKey(frame.id, name);
925
+ return state.pendingByScopeAndName.has(key) ? key : null;
926
+ };
927
+
928
+ /**
929
+ * Handle a plain `=` assignment (or clearing compound assignment) to a bare
930
+ * identifier whose name currently has a pending `.implementation()` binding in scope.
931
+ *
932
+ * A plain `=` whose RHS carries another implementation call leaves the pending entry
933
+ * alone — `recordPendingBinding` will re-register it when the implementation call
934
+ * itself is visited. Otherwise, clear the pending entry: the identifier has
935
+ * been overwritten with a non-Result value, so the original
936
+ * `result.isOk()`-style diagnostic no longer applies.
937
+ *
938
+ * Nullish/falsy-skip compound assignments (`??=`, `||=`) are ignored — a
939
+ * truthy pending `Promise<Result>` causes the RHS to be skipped, so the
940
+ * pending binding is preserved. `&&=` is *not* in this set: a truthy LHS
941
+ * causes the RHS to always run, overwriting the pending slot, so it falls
942
+ * through to the clearing path alongside `+=`, `-=`, etc. Member-expression
943
+ * LHS is ignored because it writes a property, not the tracked identifier.
944
+ */
945
+ const handleAssignmentReassignment = (
946
+ node: AstNode,
947
+ state: AnalyzeState
109
948
  ): void => {
110
- if (isDirectResultAccess(line)) {
111
- diagnostics.push(createMissingAwaitDiagnostic(filePath, lineNumber));
949
+ const assignment = extractIdentifierAssignment(node);
950
+ if (!assignment || NULLISH_SKIP_OPERATORS.has(assignment.operator)) {
951
+ return;
952
+ }
953
+ const key = resolvePendingKeyFor(assignment.name, state);
954
+ if (!key) {
955
+ return;
956
+ }
957
+ // Plain `=` with a implementation-carrying RHS will re-register via
958
+ // `recordPendingBinding` when the implementation call itself is visited. Other
959
+ // compound operators (`+=`, `-=`, `*=`, etc.) produce a primitive value
960
+ // from the existing slot, so they always clear.
961
+ if (
962
+ assignment.operator === '=' &&
963
+ rhsCarriesImplementationReinit(assignment.right)
964
+ ) {
965
+ return;
966
+ }
967
+ state.pendingByScopeAndName.delete(key);
968
+ };
969
+
970
+ const reportMissingAwait = (node: AstNode, state: AnalyzeState): void => {
971
+ if (state.reportedAt.has(node.start)) {
112
972
  return;
113
973
  }
974
+ state.reportedAt.add(node.start);
975
+ state.diagnostics.push(
976
+ createMissingAwaitDiagnostic(
977
+ state.filePath,
978
+ offsetToLine(state.sourceCode, node.start)
979
+ )
980
+ );
981
+ };
982
+
983
+ const findPendingBindingForUse = (
984
+ node: AstNode,
985
+ state: AnalyzeState
986
+ ): PendingBinding | null => {
987
+ if (!isResultAccessorMember(node)) {
988
+ return null;
989
+ }
990
+ const name = getIdentifierObjectName(node);
991
+ if (!name) {
992
+ return null;
993
+ }
994
+ const frame = resolveNearestScope(name, state.scopeStack);
995
+ if (!frame) {
996
+ return null;
997
+ }
998
+ return state.pendingByScopeAndName.get(pendingKey(frame.id, name)) ?? null;
999
+ };
1000
+
1001
+ const checkPendingAccess = (node: AstNode, state: AnalyzeState): void => {
1002
+ const binding = findPendingBindingForUse(node, state);
1003
+ if (!binding) {
1004
+ return;
1005
+ }
1006
+ // Declaration must precede the use. Use source offsets for ordering.
1007
+ if (node.start < binding.declarationNode.end) {
1008
+ return;
1009
+ }
1010
+ reportMissingAwait(node, state);
1011
+ };
114
1012
 
115
- const variableName = trackPendingCall(line);
116
- if (variableName) {
117
- addPendingCall(pendingCalls, variableName, lineNumber);
1013
+ /**
1014
+ * If the implementation call is the init of a VariableDeclarator whose id is an
1015
+ * ObjectPattern that destructures any known Result accessor property,
1016
+ * return the declarator node. Otherwise null.
1017
+ *
1018
+ * Catches the core missing-await shape when written as destructuring:
1019
+ * `const { isOk } = entityShow.implementation(input, ctx)` — no await, immediate
1020
+ * access to a Result accessor, should fire.
1021
+ */
1022
+ const propertyDestructuresResultAccessor = (prop: AstNode): boolean => {
1023
+ if (prop.type === 'RestElement') {
1024
+ return false;
118
1025
  }
1026
+ const keyName = identifierName(getNodeKey(prop));
1027
+ return keyName !== null && RESULT_ACCESSOR_PROPERTIES.has(keyName);
1028
+ };
119
1029
 
120
- advancePendingCalls(line, filePath, lineNumber, pendingCalls, diagnostics);
1030
+ const objectPatternHasResultAccessorKey = (pattern: AstNode): boolean => {
1031
+ const properties = getNodeProperties(pattern);
1032
+ return properties?.some(propertyDestructuresResultAccessor) ?? false;
121
1033
  };
122
1034
 
123
- const scanSourceCode = (
1035
+ const getDestructuredResultAccessorDeclarator = (
1036
+ implementationCall: AstNode,
1037
+ parents: WeakMap<AstNode, AstNode>
1038
+ ): AstNode | null => {
1039
+ // Unwrap any wrapping parentheses and branch-position conditionals so
1040
+ // `const { isOk } = (trail.implementation(...));` and
1041
+ // `const { isOk } = cond ? trail.implementation(...) : fallback;` are treated as
1042
+ // `const { isOk } = trail.implementation(...);`.
1043
+ const outer = skipParensAndBranchConditionals(implementationCall, parents);
1044
+ const parent = parents.get(outer);
1045
+ if (!parent || parent.type !== 'VariableDeclarator') {
1046
+ return null;
1047
+ }
1048
+ const id = getNodeId(parent);
1049
+ if (!id || id.type !== 'ObjectPattern') {
1050
+ return null;
1051
+ }
1052
+ return objectPatternHasResultAccessorKey(id) ? parent : null;
1053
+ };
1054
+
1055
+ const visitImplementationCall = (node: AstNode, state: AnalyzeState): void => {
1056
+ if (!isImplementationCall(node) || isAwaited(node, state.parents)) {
1057
+ return;
1058
+ }
1059
+ if (hasDirectResultAccess(node, state.parents)) {
1060
+ reportMissingAwait(node, state);
1061
+ return;
1062
+ }
1063
+ const destructuredDeclarator = getDestructuredResultAccessorDeclarator(
1064
+ node,
1065
+ state.parents
1066
+ );
1067
+ if (destructuredDeclarator) {
1068
+ reportMissingAwait(destructuredDeclarator, state);
1069
+ return;
1070
+ }
1071
+ recordPendingBinding(node, state);
1072
+ };
1073
+
1074
+ const visitNode = (node: AstNode, state: AnalyzeState): void => {
1075
+ visitImplementationCall(node, state);
1076
+ checkPendingAccess(node, state);
1077
+ };
1078
+
1079
+ /**
1080
+ * Post-order visitor for assignment re-assignment clearing.
1081
+ *
1082
+ * `handleAssignmentReassignment` must run *after* the RHS subtree has been
1083
+ * walked. Otherwise a self-referential `result = result.value` would clear
1084
+ * the pending entry before the RHS `result.value` access is observed — the
1085
+ * missing-await diagnostic would disappear even though the write produced
1086
+ * a non-Result value from the same pending slot.
1087
+ */
1088
+ const visitNodePost = (node: AstNode, state: AnalyzeState): void => {
1089
+ handleAssignmentReassignment(node, state);
1090
+ };
1091
+
1092
+ const pushScopeIfBoundary = (node: AstNode, state: AnalyzeState): boolean => {
1093
+ if (!isScopeBoundary(node)) {
1094
+ return false;
1095
+ }
1096
+ const kind = scopeKindForNode(node);
1097
+ if (kind === 'function') {
1098
+ const { bindings, paramBindings } = collectFunctionScopeBindingsEx(node);
1099
+ state.scopeStack.push({
1100
+ bindings,
1101
+ id: state.nextScopeId,
1102
+ kind,
1103
+ paramBindings,
1104
+ });
1105
+ } else {
1106
+ state.scopeStack.push({
1107
+ bindings: collectScopeBindings(node),
1108
+ id: state.nextScopeId,
1109
+ kind,
1110
+ });
1111
+ }
1112
+ state.nextScopeId += 1;
1113
+ return true;
1114
+ };
1115
+
1116
+ const walkChild = (child: unknown, state: AnalyzeState): void => {
1117
+ if (child && typeof child === 'object' && (child as AstNode).type) {
1118
+ // eslint-disable-next-line no-use-before-define
1119
+ walkWithScopes(child as AstNode, state);
1120
+ }
1121
+ };
1122
+
1123
+ const walkChildren = (node: AstNode, state: AnalyzeState): void => {
1124
+ for (const val of Object.values(node)) {
1125
+ if (Array.isArray(val)) {
1126
+ for (const item of val) {
1127
+ walkChild(item, state);
1128
+ }
1129
+ } else {
1130
+ walkChild(val, state);
1131
+ }
1132
+ }
1133
+ };
1134
+
1135
+ // biome-ignore lint/style/useConst: hoisted for mutual recursion with walkChildren
1136
+ // eslint-disable-next-line func-style
1137
+ function walkWithScopes(node: AstNode, state: AnalyzeState): void {
1138
+ const pushed = pushScopeIfBoundary(node, state);
1139
+ visitNode(node, state);
1140
+ walkChildren(node, state);
1141
+ visitNodePost(node, state);
1142
+ if (pushed) {
1143
+ state.scopeStack.pop();
1144
+ }
1145
+ }
1146
+
1147
+ const collectProgramBindings = (ast: AstNode): Set<string> => {
1148
+ const bindings = new Set<string>();
1149
+ const programBody = getNodeBodyStatements(ast);
1150
+ // Top-level `let`/`const`/function declarations.
1151
+ collectBlockScopedStatementListBindings(programBody, bindings);
1152
+ // Top-level `var`s are program-scoped; also hoist any `var`s nested
1153
+ // inside blocks/loops at program level.
1154
+ collectHoistedVarBindings(ast, bindings);
1155
+ return bindings;
1156
+ };
1157
+
1158
+ const analyze = (
1159
+ ast: AstNode,
124
1160
  sourceCode: string,
125
1161
  filePath: string
126
1162
  ): readonly WardenDiagnostic[] => {
127
- const diagnostics: WardenDiagnostic[] = [];
128
- const lines = sourceCode.split('\n');
129
- const pendingCalls: PendingCall[] = [];
130
-
131
- for (let i = 0; i < lines.length; i += 1) {
132
- const line = lines[i];
133
- if (!line) {
134
- continue;
135
- }
136
- processLine(line, filePath, i + 1, pendingCalls, diagnostics);
137
- }
1163
+ const state: AnalyzeState = {
1164
+ diagnostics: [],
1165
+ filePath,
1166
+ nextScopeId: 1,
1167
+ parents: buildParentMap(ast),
1168
+ pendingByScopeAndName: new Map(),
1169
+ reportedAt: new Set(),
1170
+ scopeStack: [
1171
+ { bindings: collectProgramBindings(ast), id: 0, kind: 'program' },
1172
+ ],
1173
+ sourceCode,
1174
+ };
1175
+
1176
+ walkWithScopes(ast, state);
138
1177
 
139
- return diagnostics;
1178
+ return state.diagnostics;
140
1179
  };
141
1180
 
142
1181
  /**
143
- * Flags code that assumes `.run()` returns a synchronous result.
1182
+ * Flags code that assumes `.implementation()` returns a synchronous result.
144
1183
  */
145
1184
  export const noSyncResultAssumption: WardenRule = {
146
1185
  check(sourceCode: string, filePath: string): readonly WardenDiagnostic[] {
147
1186
  if (isTestFile(filePath) || isFrameworkInternalFile(filePath)) {
148
1187
  return [];
149
1188
  }
150
- return scanSourceCode(stripQuotedContent(sourceCode), filePath);
1189
+ const ast = parse(filePath, sourceCode);
1190
+ if (!ast) {
1191
+ return [];
1192
+ }
1193
+ return analyze(ast, sourceCode, filePath);
151
1194
  },
152
1195
  description:
153
- 'Disallow treating .run() as synchronous after normalization. Always await the returned Promise<Result>.',
1196
+ 'Disallow treating .implementation() as synchronous after normalization. Always await the returned Promise<Result>.',
154
1197
  name: 'no-sync-result-assumption',
155
1198
  severity: 'error',
156
1199
  };