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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (279) hide show
  1. package/CHANGELOG.md +835 -13
  2. package/README.md +133 -26
  3. package/bin/warden.ts +51 -0
  4. package/package.json +27 -6
  5. package/src/adapter-check.ts +136 -0
  6. package/src/cli.ts +1570 -105
  7. package/src/command.ts +986 -0
  8. package/src/config.ts +193 -0
  9. package/src/draft.ts +22 -0
  10. package/src/drift.ts +233 -23
  11. package/src/fix.ts +126 -0
  12. package/src/formatters.ts +78 -13
  13. package/src/guide.ts +245 -0
  14. package/src/index.ts +247 -15
  15. package/src/project-context.ts +446 -0
  16. package/src/project-rules.ts +290 -0
  17. package/src/resolve.ts +531 -0
  18. package/src/rules/activation-orphan.ts +97 -0
  19. package/src/rules/captured-kernel.ts +375 -0
  20. package/src/rules/circular-refs.ts +150 -0
  21. package/src/rules/cli-command-route-coherence.ts +177 -0
  22. package/src/rules/composes-declarations.ts +839 -0
  23. package/src/rules/context-no-surface-types.ts +79 -15
  24. package/src/rules/dead-internal-trail.ts +161 -0
  25. package/src/rules/dead-public-trail.ts +258 -0
  26. package/src/rules/draft-file-marking.ts +155 -0
  27. package/src/rules/draft-visible-debt.ts +82 -0
  28. package/src/rules/duplicate-exported-symbol.ts +211 -0
  29. package/src/rules/duplicate-public-contract.ts +137 -0
  30. package/src/rules/entity-exists.ts +254 -0
  31. package/src/rules/entity-ids.ts +15 -0
  32. package/src/rules/error-mapping-completeness.ts +290 -0
  33. package/src/rules/example-valid.ts +395 -0
  34. package/src/rules/fires-declarations.ts +740 -0
  35. package/src/rules/governed-symbol-residue.ts +438 -0
  36. package/src/rules/implementation-returns-result.ts +1409 -166
  37. package/src/rules/incomplete-accessor-for-standard-op.ts +272 -0
  38. package/src/rules/incomplete-crud.ts +583 -0
  39. package/src/rules/index.ts +277 -10
  40. package/src/rules/intent-propagation.ts +125 -0
  41. package/src/rules/layer-field-name-drift.ts +102 -0
  42. package/src/rules/library-projection-coherence.ts +100 -0
  43. package/src/rules/metadata.ts +871 -0
  44. package/src/rules/missing-reconcile.ts +97 -0
  45. package/src/rules/missing-visibility.ts +111 -0
  46. package/src/rules/no-destructured-compose.ts +196 -0
  47. package/src/rules/no-dev-permit-in-source.ts +99 -0
  48. package/src/rules/no-direct-implementation-call.ts +12 -7
  49. package/src/rules/no-legacy-cli-alias-export.ts +247 -0
  50. package/src/rules/no-legacy-layer-imports.ts +211 -0
  51. package/src/rules/no-native-error-result.ts +118 -0
  52. package/src/rules/no-redundant-result-error-wrap.ts +384 -0
  53. package/src/rules/no-retired-cross-vocabulary.ts +204 -0
  54. package/src/rules/no-sync-result-assumption.ts +1141 -98
  55. package/src/rules/no-throw-in-detour-recover.ts +225 -0
  56. package/src/rules/no-throw-in-implementation.ts +15 -8
  57. package/src/rules/no-top-level-surface.ts +371 -0
  58. package/src/rules/on-references-exist.ts +194 -0
  59. package/src/rules/orphaned-signal.ts +149 -0
  60. package/src/rules/owner-projection-parity.ts +143 -0
  61. package/src/rules/permit-governance.ts +25 -0
  62. package/src/rules/public-export-example-coverage.ts +573 -0
  63. package/src/rules/public-internal-deep-imports.ts +456 -0
  64. package/src/rules/public-output-schema.ts +29 -0
  65. package/src/rules/public-union-output-discriminants.ts +150 -0
  66. package/src/rules/read-intent-fires.ts +188 -0
  67. package/src/rules/reference-exists.ts +97 -0
  68. package/src/rules/registry-names.ts +167 -0
  69. package/src/rules/resolved-import-boundary.ts +146 -0
  70. package/src/rules/resource-declarations.ts +697 -0
  71. package/src/rules/resource-exists.ts +181 -0
  72. package/src/rules/resource-id-grammar.ts +65 -0
  73. package/src/rules/resource-mock-coverage.ts +115 -0
  74. package/src/rules/retired-vocabulary.ts +633 -0
  75. package/src/rules/scan.ts +38 -25
  76. package/src/rules/scheduled-destroy-intent.ts +44 -0
  77. package/src/rules/signal-graph-coaching.ts +220 -0
  78. package/src/rules/source/composition.ts +165 -0
  79. package/src/rules/source/drafts.ts +164 -0
  80. package/src/rules/source/entities.ts +618 -0
  81. package/src/rules/source/pragmas.ts +45 -0
  82. package/src/rules/source/resources.ts +64 -0
  83. package/src/rules/source/signals.ts +397 -0
  84. package/src/rules/source/stores.ts +310 -0
  85. package/src/rules/specs.ts +9 -5
  86. package/src/rules/static-resource-accessor-preference.ts +653 -0
  87. package/src/rules/surface-overlay-coherence.ts +262 -0
  88. package/src/rules/surface-trailhead-coherence.ts +366 -0
  89. package/src/rules/trail-fork-coaching.ts +625 -0
  90. package/src/rules/trail-versioning-source.ts +1076 -0
  91. package/src/rules/trail-versioning-topo.ts +172 -0
  92. package/src/rules/trailhead-override-divergence.ts +356 -0
  93. package/src/rules/types.ts +354 -8
  94. package/src/rules/unmaterialized-activation-source.ts +85 -0
  95. package/src/rules/unreachable-detour-shadowing.ts +339 -0
  96. package/src/rules/valid-describe-refs.ts +162 -32
  97. package/src/rules/valid-detour-contract.ts +78 -0
  98. package/src/rules/warden-export-symmetry.ts +540 -0
  99. package/src/rules/warden-rules-use-ast.ts +1109 -0
  100. package/src/rules/webhook-route-collision.ts +306 -0
  101. package/src/trails/activation-orphan.trail.ts +84 -0
  102. package/src/trails/captured-kernel.trail.ts +108 -0
  103. package/src/trails/circular-refs.trail.ts +29 -0
  104. package/src/trails/cli-command-route-coherence.trail.ts +47 -0
  105. package/src/trails/composes-declarations.trail.ts +22 -0
  106. package/src/trails/context-no-surface-types.trail.ts +21 -0
  107. package/src/trails/dead-internal-trail.trail.ts +26 -0
  108. package/src/trails/dead-public-trail.trail.ts +31 -0
  109. package/src/trails/deprecation-without-guidance.trail.ts +21 -0
  110. package/src/trails/draft-file-marking.trail.ts +16 -0
  111. package/src/trails/draft-visible-debt.trail.ts +16 -0
  112. package/src/trails/duplicate-exported-symbol.trail.ts +48 -0
  113. package/src/trails/duplicate-public-contract.trail.ts +47 -0
  114. package/src/trails/entity-exists.trail.ts +21 -0
  115. package/src/trails/error-mapping-completeness.trail.ts +30 -0
  116. package/src/trails/example-valid.trail.ts +25 -0
  117. package/src/trails/fires-declarations.trail.ts +23 -0
  118. package/src/trails/fork-without-preserved-implementation.trail.ts +31 -0
  119. package/src/trails/governed-symbol-residue.trail.ts +24 -0
  120. package/src/trails/implementation-returns-result.trail.ts +20 -0
  121. package/src/trails/incomplete-accessor-for-standard-op.trail.ts +76 -0
  122. package/src/trails/incomplete-crud.trail.ts +39 -0
  123. package/src/trails/index.ts +89 -0
  124. package/src/trails/intent-propagation.trail.ts +30 -0
  125. package/src/trails/layer-field-name-drift.trail.ts +39 -0
  126. package/src/trails/library-projection-coherence.trail.ts +43 -0
  127. package/src/trails/marker-schema-unsupported.trail.ts +23 -0
  128. package/src/trails/missing-reconcile.trail.ts +33 -0
  129. package/src/trails/missing-visibility.trail.ts +22 -0
  130. package/src/trails/no-destructured-compose.trail.ts +44 -0
  131. package/src/trails/no-dev-permit-in-source.trail.ts +16 -0
  132. package/src/trails/no-direct-implementation-call.trail.ts +16 -0
  133. package/src/trails/no-legacy-cli-alias-export.trail.ts +41 -0
  134. package/src/trails/no-legacy-layer-imports.trail.ts +41 -0
  135. package/src/trails/no-native-error-result.trail.ts +18 -0
  136. package/src/trails/no-redundant-result-error-wrap.trail.ts +55 -0
  137. package/src/trails/no-retired-cross-vocabulary.trail.ts +42 -0
  138. package/src/trails/no-sync-result-assumption.trail.ts +19 -0
  139. package/src/trails/no-throw-in-detour-recover.trail.ts +24 -0
  140. package/src/trails/no-throw-in-implementation.trail.ts +20 -0
  141. package/src/trails/no-top-level-surface.trail.ts +43 -0
  142. package/src/trails/on-references-exist.trail.ts +21 -0
  143. package/src/trails/orphaned-signal.trail.ts +36 -0
  144. package/src/trails/owner-projection-parity.trail.ts +26 -0
  145. package/src/trails/pending-force.trail.ts +21 -0
  146. package/src/trails/permit-governance.trail.ts +51 -0
  147. package/src/trails/prefer-schema-inference.trail.ts +21 -0
  148. package/src/trails/public-export-example-coverage.trail.ts +16 -0
  149. package/src/trails/public-internal-deep-imports.trail.ts +94 -0
  150. package/src/trails/public-output-schema.trail.ts +55 -0
  151. package/src/trails/public-union-output-discriminants.trail.ts +33 -0
  152. package/src/trails/read-intent-fires.trail.ts +20 -0
  153. package/src/trails/reference-exists.trail.ts +25 -0
  154. package/src/trails/resolved-import-boundary.trail.ts +109 -0
  155. package/src/trails/resource-declarations.trail.ts +25 -0
  156. package/src/trails/resource-exists.trail.ts +27 -0
  157. package/src/trails/resource-id-grammar.trail.ts +39 -0
  158. package/src/trails/resource-mock-coverage.trail.ts +40 -0
  159. package/src/trails/run.ts +160 -0
  160. package/src/trails/scheduled-destroy-intent.trail.ts +56 -0
  161. package/src/trails/schema.ts +237 -0
  162. package/src/trails/signal-graph-coaching.trail.ts +77 -0
  163. package/src/trails/static-resource-accessor-preference.trail.ts +25 -0
  164. package/src/trails/surface-overlay-coherence.trail.ts +24 -0
  165. package/src/trails/surface-trailhead-coherence.trail.ts +25 -0
  166. package/src/trails/topo.ts +6 -0
  167. package/src/trails/trail-fork-coaching.trail.ts +42 -0
  168. package/src/trails/trailhead-override-divergence.trail.ts +47 -0
  169. package/src/trails/unmaterialized-activation-source.trail.ts +72 -0
  170. package/src/trails/unreachable-detour-shadowing.trail.ts +45 -0
  171. package/src/trails/valid-describe-refs.trail.ts +18 -0
  172. package/src/trails/valid-detour-contract.trail.ts +71 -0
  173. package/src/trails/version-gap.trail.ts +35 -0
  174. package/src/trails/version-pinned-compose.trail.ts +23 -0
  175. package/src/trails/version-without-examples.trail.ts +38 -0
  176. package/src/trails/warden-export-symmetry.trail.ts +16 -0
  177. package/src/trails/warden-rules-use-ast.trail.ts +64 -0
  178. package/src/trails/webhook-route-collision.trail.ts +50 -0
  179. package/src/trails/wrap-rule.ts +224 -0
  180. package/src/workspaces.ts +199 -0
  181. package/.turbo/turbo-build.log +0 -1
  182. package/.turbo/turbo-lint.log +0 -3
  183. package/.turbo/turbo-typecheck.log +0 -1
  184. package/dist/cli.d.ts +0 -46
  185. package/dist/cli.d.ts.map +0 -1
  186. package/dist/cli.js +0 -218
  187. package/dist/cli.js.map +0 -1
  188. package/dist/drift.d.ts +0 -26
  189. package/dist/drift.d.ts.map +0 -1
  190. package/dist/drift.js +0 -27
  191. package/dist/drift.js.map +0 -1
  192. package/dist/formatters.d.ts +0 -29
  193. package/dist/formatters.d.ts.map +0 -1
  194. package/dist/formatters.js +0 -87
  195. package/dist/formatters.js.map +0 -1
  196. package/dist/index.d.ts +0 -26
  197. package/dist/index.d.ts.map +0 -1
  198. package/dist/index.js +0 -26
  199. package/dist/index.js.map +0 -1
  200. package/dist/rules/ast.d.ts +0 -41
  201. package/dist/rules/ast.d.ts.map +0 -1
  202. package/dist/rules/ast.js +0 -161
  203. package/dist/rules/ast.js.map +0 -1
  204. package/dist/rules/context-no-surface-types.d.ts +0 -12
  205. package/dist/rules/context-no-surface-types.d.ts.map +0 -1
  206. package/dist/rules/context-no-surface-types.js +0 -96
  207. package/dist/rules/context-no-surface-types.js.map +0 -1
  208. package/dist/rules/implementation-returns-result.d.ts +0 -13
  209. package/dist/rules/implementation-returns-result.d.ts.map +0 -1
  210. package/dist/rules/implementation-returns-result.js +0 -277
  211. package/dist/rules/implementation-returns-result.js.map +0 -1
  212. package/dist/rules/index.d.ts +0 -15
  213. package/dist/rules/index.d.ts.map +0 -1
  214. package/dist/rules/index.js +0 -34
  215. package/dist/rules/index.js.map +0 -1
  216. package/dist/rules/no-direct-impl-in-route.d.ts +0 -12
  217. package/dist/rules/no-direct-impl-in-route.d.ts.map +0 -1
  218. package/dist/rules/no-direct-impl-in-route.js +0 -47
  219. package/dist/rules/no-direct-impl-in-route.js.map +0 -1
  220. package/dist/rules/no-direct-implementation-call.d.ts +0 -12
  221. package/dist/rules/no-direct-implementation-call.d.ts.map +0 -1
  222. package/dist/rules/no-direct-implementation-call.js +0 -39
  223. package/dist/rules/no-direct-implementation-call.js.map +0 -1
  224. package/dist/rules/no-sync-result-assumption.d.ts +0 -6
  225. package/dist/rules/no-sync-result-assumption.d.ts.map +0 -1
  226. package/dist/rules/no-sync-result-assumption.js +0 -98
  227. package/dist/rules/no-sync-result-assumption.js.map +0 -1
  228. package/dist/rules/no-throw-in-detour-target.d.ts +0 -12
  229. package/dist/rules/no-throw-in-detour-target.d.ts.map +0 -1
  230. package/dist/rules/no-throw-in-detour-target.js +0 -87
  231. package/dist/rules/no-throw-in-detour-target.js.map +0 -1
  232. package/dist/rules/no-throw-in-implementation.d.ts +0 -9
  233. package/dist/rules/no-throw-in-implementation.d.ts.map +0 -1
  234. package/dist/rules/no-throw-in-implementation.js +0 -34
  235. package/dist/rules/no-throw-in-implementation.js.map +0 -1
  236. package/dist/rules/prefer-schema-inference.d.ts +0 -7
  237. package/dist/rules/prefer-schema-inference.d.ts.map +0 -1
  238. package/dist/rules/prefer-schema-inference.js +0 -86
  239. package/dist/rules/prefer-schema-inference.js.map +0 -1
  240. package/dist/rules/scan.d.ts +0 -8
  241. package/dist/rules/scan.d.ts.map +0 -1
  242. package/dist/rules/scan.js +0 -32
  243. package/dist/rules/scan.js.map +0 -1
  244. package/dist/rules/specs.d.ts +0 -29
  245. package/dist/rules/specs.d.ts.map +0 -1
  246. package/dist/rules/specs.js +0 -192
  247. package/dist/rules/specs.js.map +0 -1
  248. package/dist/rules/structure.d.ts +0 -13
  249. package/dist/rules/structure.d.ts.map +0 -1
  250. package/dist/rules/structure.js +0 -142
  251. package/dist/rules/structure.js.map +0 -1
  252. package/dist/rules/types.d.ts +0 -52
  253. package/dist/rules/types.d.ts.map +0 -1
  254. package/dist/rules/types.js +0 -2
  255. package/dist/rules/types.js.map +0 -1
  256. package/dist/rules/valid-describe-refs.d.ts +0 -7
  257. package/dist/rules/valid-describe-refs.d.ts.map +0 -1
  258. package/dist/rules/valid-describe-refs.js +0 -51
  259. package/dist/rules/valid-describe-refs.js.map +0 -1
  260. package/dist/rules/valid-detour-refs.d.ts +0 -6
  261. package/dist/rules/valid-detour-refs.d.ts.map +0 -1
  262. package/dist/rules/valid-detour-refs.js +0 -116
  263. package/dist/rules/valid-detour-refs.js.map +0 -1
  264. package/src/__tests__/cli.test.ts +0 -198
  265. package/src/__tests__/drift.test.ts +0 -74
  266. package/src/__tests__/formatters.test.ts +0 -157
  267. package/src/__tests__/implementation-returns-result.test.ts +0 -129
  268. package/src/__tests__/no-direct-implementation-call.test.ts +0 -83
  269. package/src/__tests__/no-sync-result-assumption.test.ts +0 -85
  270. package/src/__tests__/no-throw-in-detour-target.test.ts +0 -78
  271. package/src/__tests__/prefer-schema-inference.test.ts +0 -84
  272. package/src/__tests__/rules.test.ts +0 -227
  273. package/src/__tests__/valid-describe-refs.test.ts +0 -60
  274. package/src/rules/ast.ts +0 -213
  275. package/src/rules/no-direct-impl-in-route.ts +0 -81
  276. package/src/rules/no-throw-in-detour-target.ts +0 -150
  277. package/src/rules/valid-detour-refs.ts +0 -187
  278. package/tsconfig.json +0 -9
  279. package/tsconfig.tsbuildinfo +0 -1
@@ -1,69 +1,67 @@
1
1
  /**
2
2
  * Finds implementations that return raw values instead of `Result`.
3
3
  *
4
- * Uses AST parsing to find `run:` bodies and check that
5
- * every return statement returns Result.ok(), Result.err(), ctx.follow(),
4
+ * Uses AST parsing to find `implementation:` bodies and check that
5
+ * every return statement returns Result.ok(), Result.err(), ctx.compose(),
6
6
  * or a tracked Result-typed variable.
7
7
  */
8
8
 
9
+ import { existsSync, readFileSync } from 'node:fs';
10
+ import { dirname, isAbsolute, resolve } from 'node:path';
11
+ import { escapeRegExp } from '@ontrails/core';
9
12
  import {
10
- findRunBodies,
13
+ collectScopeFrameBindings,
14
+ findImplementationBodies,
11
15
  findTrailDefinitions,
16
+ getNodeAlternate,
17
+ getNodeArgument,
18
+ getNodeBodyNode,
19
+ getNodeBodyStatements,
20
+ getNodeConsequent,
21
+ getNodeDeclaration,
22
+ getNodeExportKind,
23
+ getNodeExpression,
24
+ getNodeId,
25
+ getNodeImported,
26
+ getNodeInit,
27
+ getNodeLocal,
28
+ getNodeName,
29
+ getNodeReturnType,
30
+ getNodeSource,
31
+ getNodeTypeAnnotation,
32
+ getNodeValue,
33
+ identifierName,
12
34
  offsetToLine,
13
35
  parse,
14
36
  walk,
15
- } from './ast.js';
37
+ walkWithScopes,
38
+ } from '@ontrails/source';
39
+ import type { AstNode } from '@ontrails/source';
40
+ import { getMemberExpression } from './source/stores.js';
16
41
  import { isTestFile } from './scan.js';
17
42
  import type { WardenDiagnostic, WardenRule } from './types.js';
18
43
 
19
- // ---------------------------------------------------------------------------
20
- // Types
21
- // ---------------------------------------------------------------------------
22
-
23
- interface AstNode {
24
- readonly type: string;
25
- readonly start: number;
26
- readonly end: number;
27
- readonly [key: string]: unknown;
28
- }
44
+ const buildUnrecognizedResultMessage = (label: string, id: string): string =>
45
+ `${label} "${id}": return value is not a recognized Result expression. Return Result.ok(...), Result.err(...), or a Result-producing expression such as await ctx.compose(...). If you are returning a composed/helper Result, keep the provenance visible or add a Result return annotation Warden can trace.`;
29
46
 
30
47
  // ---------------------------------------------------------------------------
31
48
  // Member expression helpers
32
49
  // ---------------------------------------------------------------------------
33
50
 
34
- /** Extract object.property names from a MemberExpression callee. */
35
- const extractMemberNames = (
36
- callee: AstNode
37
- ): { objName: string | undefined; propName: string | undefined } => {
38
- const obj = (callee as unknown as { object?: AstNode }).object;
39
- const prop = (callee as unknown as { property?: AstNode }).property;
40
- const objName =
41
- obj?.type === 'Identifier'
42
- ? (obj as unknown as { name: string }).name
43
- : undefined;
44
- const propName =
45
- prop?.type === 'Identifier'
46
- ? (prop as unknown as { name: string }).name
47
- : undefined;
48
- return { objName, propName };
49
- };
50
-
51
- const isMemberExpression = (callee: AstNode): boolean =>
52
- callee.type === 'StaticMemberExpression' ||
53
- callee.type === 'MemberExpression';
54
-
55
51
  const isResultMemberCall = (callee: AstNode): boolean => {
56
- if (!isMemberExpression(callee)) {
52
+ const member = getMemberExpression(callee);
53
+ if (!member) {
57
54
  return false;
58
55
  }
59
- const { objName, propName } = extractMemberNames(callee);
56
+ const objName = identifierName(member.object) ?? undefined;
57
+ const propName = identifierName(member.property) ?? undefined;
60
58
  if (objName === 'Result' && (propName === 'ok' || propName === 'err')) {
61
59
  return true;
62
60
  }
63
- if (objName === 'ctx' && propName === 'follow') {
61
+ if (objName === 'ctx' && propName === 'compose') {
64
62
  return true;
65
63
  }
66
- return propName === 'run';
64
+ return propName === 'implementation';
67
65
  };
68
66
 
69
67
  // ---------------------------------------------------------------------------
@@ -71,7 +69,7 @@ const isResultMemberCall = (callee: AstNode): boolean => {
71
69
  // ---------------------------------------------------------------------------
72
70
 
73
71
  /** Check if an expression node is an allowed Result-returning expression. */
74
- const isResultExpression = (node: AstNode): boolean => {
72
+ export const isResultExpression = (node: AstNode): boolean => {
75
73
  if (node.type === 'CallExpression') {
76
74
  const callee = node['callee'] as AstNode | undefined;
77
75
  if (!callee) {
@@ -81,22 +79,93 @@ const isResultExpression = (node: AstNode): boolean => {
81
79
  }
82
80
 
83
81
  if (node.type === 'AwaitExpression') {
84
- const arg = (node as unknown as { argument?: AstNode }).argument;
82
+ const arg = getNodeArgument(node);
85
83
  return arg ? isResultExpression(arg) : false;
86
84
  }
87
85
 
88
86
  return false;
89
87
  };
90
88
 
89
+ /** Map of namespace-import local name to the set of Result-helper names exported by the target module. */
90
+ export type NamespaceHelperMap = ReadonlyMap<string, ReadonlySet<string>>;
91
+
92
+ /** Map of lexical scope frames to local helper bindings with explicit Result return types. */
93
+ export type ScopedHelperMap = ReadonlyMap<
94
+ ReadonlySet<string>,
95
+ ReadonlySet<string>
96
+ >;
97
+
98
+ export type MutableScopedHelperMap = Map<ReadonlySet<string>, Set<string>>;
99
+
100
+ type ScopedResultVariableMap = ReadonlyMap<
101
+ ReadonlySet<string>,
102
+ ReadonlySet<string>
103
+ >;
104
+
105
+ type MutableScopedResultVariableMap = Map<ReadonlySet<string>, Set<string>>;
106
+
107
+ export const findNearestBindingScope = (
108
+ name: string,
109
+ scopes: readonly ReadonlySet<string>[]
110
+ ): ReadonlySet<string> | null =>
111
+ scopes.find((scope) => scope.has(name)) ?? null;
112
+
113
+ const isScopedHelperBinding = (
114
+ name: string,
115
+ scope: ReadonlySet<string>,
116
+ scopedHelpers: ScopedHelperMap
117
+ ): boolean => scopedHelpers.get(scope)?.has(name) ?? false;
118
+
119
+ const isScopedResultVariableBinding = (
120
+ name: string,
121
+ scopes: readonly ReadonlySet<string>[],
122
+ resultVars: ScopedResultVariableMap
123
+ ): boolean => {
124
+ const bindingScope = findNearestBindingScope(name, scopes);
125
+ return Boolean(bindingScope && resultVars.get(bindingScope)?.has(name));
126
+ };
127
+
128
+ /**
129
+ * Check whether a namespace-member call like `ns.helper(...)` resolves to a
130
+ * known Result helper.
131
+ *
132
+ * When a non-empty `scopes` stack is provided, the namespace binding must not
133
+ * be shadowed by a parameter or local declaration in any enclosing scope at
134
+ * the call site. Without this check, any local `ns` (e.g. a implementation parameter
135
+ * named `ns`, or `const ns = ...` inside the body) would be misread as the
136
+ * module-scope namespace import.
137
+ */
138
+ const isNamespaceHelperMemberCall = (
139
+ callee: AstNode,
140
+ namespaceHelpers: NamespaceHelperMap,
141
+ scopes: readonly ReadonlySet<string>[] = []
142
+ ): boolean => {
143
+ const member = getMemberExpression(callee);
144
+ if (!member) {
145
+ return false;
146
+ }
147
+ const objName = identifierName(member.object) ?? undefined;
148
+ const propName = identifierName(member.property) ?? undefined;
149
+ if (!(objName && propName)) {
150
+ return false;
151
+ }
152
+ // Nearest binding is a local, not the namespace import.
153
+ if (scopes.some((scope) => scope.has(objName))) {
154
+ return false;
155
+ }
156
+ return namespaceHelpers.get(objName)?.has(propName) ?? false;
157
+ };
158
+
91
159
  /** Check if a node is a call to a known Result-returning helper. */
92
- const isHelperCall = (
160
+ export const isHelperCall = (
93
161
  node: AstNode,
94
- helperNames: ReadonlySet<string>
162
+ helperNames: ReadonlySet<string>,
163
+ namespaceHelpers: NamespaceHelperMap = new Map(),
164
+ scopes: readonly ReadonlySet<string>[] = [],
165
+ scopedHelpers: ScopedHelperMap = new Map()
95
166
  ): boolean => {
96
167
  const target =
97
- node.type === 'AwaitExpression'
98
- ? ((node as unknown as { argument?: AstNode }).argument ?? null)
99
- : node;
168
+ node.type === 'AwaitExpression' ? (getNodeArgument(node) ?? null) : node;
100
169
 
101
170
  if (!target || target.type !== 'CallExpression') {
102
171
  return false;
@@ -104,114 +173,281 @@ const isHelperCall = (
104
173
 
105
174
  const callee = target['callee'] as AstNode | undefined;
106
175
  if (callee?.type === 'Identifier') {
107
- const { name } = callee as unknown as { name: string };
176
+ const name = getNodeName(callee);
177
+ if (!name) {
178
+ return false;
179
+ }
180
+ const bindingScope = findNearestBindingScope(name, scopes);
181
+ if (
182
+ bindingScope &&
183
+ !isScopedHelperBinding(name, bindingScope, scopedHelpers)
184
+ ) {
185
+ return false;
186
+ }
108
187
  return helperNames.has(name);
109
188
  }
110
189
 
111
- return false;
190
+ return callee
191
+ ? isNamespaceHelperMemberCall(callee, namespaceHelpers, scopes)
192
+ : false;
112
193
  };
113
194
 
114
195
  /** Unwrap an optional AwaitExpression to get the inner identifier name. */
115
196
  const resolveIdentifierName = (node: AstNode): string | null => {
116
197
  if (node.type === 'Identifier') {
117
- return (node as unknown as { name: string }).name;
198
+ return getNodeName(node) ?? null;
118
199
  }
119
200
  if (node.type === 'AwaitExpression') {
120
- const inner = (node as unknown as { argument?: AstNode }).argument;
201
+ const inner = getNodeArgument(node);
121
202
  if (inner?.type === 'Identifier') {
122
- return (inner as unknown as { name: string }).name;
203
+ return getNodeName(inner) ?? null;
123
204
  }
124
205
  }
125
206
  return null;
126
207
  };
127
208
 
209
+ const unwrapReturnExpression = (node: AstNode): AstNode => {
210
+ let current = node;
211
+ while (
212
+ current.type === 'AwaitExpression' ||
213
+ current.type === 'ParenthesizedExpression'
214
+ ) {
215
+ const next =
216
+ current.type === 'AwaitExpression'
217
+ ? getNodeArgument(current)
218
+ : getNodeExpression(current);
219
+ if (!next) {
220
+ return current;
221
+ }
222
+ current = next;
223
+ }
224
+ return current;
225
+ };
226
+
128
227
  /** Check if a return argument is an allowed Result value. */
129
228
  const isAllowedReturnArgument = (
130
229
  argument: AstNode,
131
230
  helperNames: ReadonlySet<string>,
132
- resultVars: ReadonlySet<string>
231
+ resultVars: ScopedResultVariableMap,
232
+ namespaceHelpers: NamespaceHelperMap,
233
+ scopes: readonly ReadonlySet<string>[] = [],
234
+ scopedHelpers: ScopedHelperMap = new Map()
133
235
  ): boolean => {
134
- if (isResultExpression(argument)) {
236
+ const target = unwrapReturnExpression(argument);
237
+ if (target.type === 'ConditionalExpression') {
238
+ const alternate = getNodeAlternate(target);
239
+ const consequent = getNodeConsequent(target);
240
+ return (
241
+ consequent !== undefined &&
242
+ alternate !== undefined &&
243
+ isAllowedReturnArgument(
244
+ consequent,
245
+ helperNames,
246
+ resultVars,
247
+ namespaceHelpers,
248
+ scopes,
249
+ scopedHelpers
250
+ ) &&
251
+ isAllowedReturnArgument(
252
+ alternate,
253
+ helperNames,
254
+ resultVars,
255
+ namespaceHelpers,
256
+ scopes,
257
+ scopedHelpers
258
+ )
259
+ );
260
+ }
261
+ if (isResultExpression(target)) {
135
262
  return true;
136
263
  }
137
- if (isHelperCall(argument, helperNames)) {
264
+ if (
265
+ isHelperCall(target, helperNames, namespaceHelpers, scopes, scopedHelpers)
266
+ ) {
138
267
  return true;
139
268
  }
140
269
 
141
- const varName = resolveIdentifierName(argument);
142
- return varName !== null && resultVars.has(varName);
270
+ const varName = resolveIdentifierName(target);
271
+ return (
272
+ varName !== null &&
273
+ isScopedResultVariableBinding(varName, scopes, resultVars)
274
+ );
143
275
  };
144
276
 
145
277
  // ---------------------------------------------------------------------------
146
- // Variable tracking
278
+ // Result helper name collection
147
279
  // ---------------------------------------------------------------------------
148
280
 
149
- /** Track a VariableDeclarator, adding to resultVars if it produces a Result. */
150
- const trackResultVariable = (node: AstNode, resultVars: Set<string>): void => {
151
- const { init } = node as unknown as { init?: AstNode };
152
- const { id } = node as unknown as { id?: AstNode };
153
- if (init && id?.type === 'Identifier') {
154
- const { name } = id as unknown as { name: string };
155
- if (isResultExpression(init)) {
156
- resultVars.add(name);
281
+ const getImportSourceValue = (node: AstNode): string | null => {
282
+ const sourceNode = getNodeSource(node);
283
+ const sourceValue = sourceNode ? getNodeValue(sourceNode) : undefined;
284
+ return typeof sourceValue === 'string' ? sourceValue : null;
285
+ };
286
+
287
+ const extractIdentifierName = (node: AstNode | undefined): string | null =>
288
+ node?.type === 'Identifier' ? (getNodeName(node) ?? null) : null;
289
+
290
+ const DEFAULT_RESULT_TYPE_NAMES = new Set(['Result']);
291
+
292
+ const hasGenericTypeReference = (
293
+ annotationText: string,
294
+ typeName: string
295
+ ): boolean =>
296
+ new RegExp(`(^|[^\\w$])${escapeRegExp(typeName)}\\s*<`).test(annotationText);
297
+
298
+ export const collectResultTypeNames = (ast: AstNode): ReadonlySet<string> => {
299
+ const names = new Set(DEFAULT_RESULT_TYPE_NAMES);
300
+ walk(ast, (node) => {
301
+ if (
302
+ node.type !== 'ImportDeclaration' ||
303
+ getImportSourceValue(node) !== '@ontrails/core'
304
+ ) {
305
+ return;
306
+ }
307
+ const specifiers =
308
+ (node['specifiers'] as readonly AstNode[] | undefined) ?? [];
309
+ for (const specifier of specifiers) {
310
+ if (specifier.type !== 'ImportSpecifier') {
311
+ continue;
312
+ }
313
+ const imported = getNodeImported(specifier);
314
+ const local = getNodeLocal(specifier);
315
+ if (extractIdentifierName(imported) !== 'Result') {
316
+ continue;
317
+ }
318
+ names.add(extractIdentifierName(local) ?? 'Result');
319
+ }
320
+ });
321
+ return names;
322
+ };
323
+
324
+ /** Check if a return type annotation mentions Result or an imported Result alias. */
325
+ const hasResultReturnType = (
326
+ node: AstNode,
327
+ sourceCode: string,
328
+ resultTypeNames: ReadonlySet<string> = DEFAULT_RESULT_TYPE_NAMES
329
+ ): boolean => {
330
+ const returnType = getNodeReturnType(node);
331
+ if (!returnType) {
332
+ return false;
333
+ }
334
+ const annotationText = sourceCode.slice(returnType.start, returnType.end);
335
+ for (const name of resultTypeNames) {
336
+ if (hasGenericTypeReference(annotationText, name)) {
337
+ return true;
157
338
  }
158
339
  }
340
+ return false;
159
341
  };
160
342
 
161
- // ---------------------------------------------------------------------------
162
- // Shallow walk (stops at nested function boundaries)
163
- // ---------------------------------------------------------------------------
343
+ const isFunctionLikeExpression = (node: AstNode): boolean =>
344
+ node.type === 'ArrowFunctionExpression' || node.type === 'FunctionExpression';
164
345
 
165
- const FUNCTION_BOUNDARY_TYPES = new Set([
166
- 'ArrowFunctionExpression',
167
- 'FunctionExpression',
168
- 'FunctionDeclaration',
169
- ]);
170
-
171
- /** Check if a value is a function-boundary AST node that should not be recursed into. */
172
- const isFunctionBoundary = (val: unknown): boolean =>
173
- !!val &&
174
- typeof val === 'object' &&
175
- FUNCTION_BOUNDARY_TYPES.has((val as AstNode).type);
176
-
177
- /** Recurse into a single AST property value, skipping function boundaries. */
178
- const visitValue = (
179
- val: unknown,
180
- visit: (node: AstNode) => void,
181
- recurse: (node: unknown, visit: (node: AstNode) => void) => void
346
+ const addScopedHelper = (
347
+ scopedHelpers: MutableScopedHelperMap,
348
+ scope: ReadonlySet<string>,
349
+ name: string
182
350
  ): void => {
183
- if (Array.isArray(val)) {
184
- for (const item of val) {
185
- if (!isFunctionBoundary(item)) {
186
- recurse(item, visit);
187
- }
188
- }
189
- } else if (
190
- val &&
191
- typeof val === 'object' &&
192
- (val as AstNode).type &&
193
- !isFunctionBoundary(val)
194
- ) {
195
- recurse(val, visit);
351
+ const existing = scopedHelpers.get(scope);
352
+ if (existing) {
353
+ existing.add(name);
354
+ return;
196
355
  }
356
+ scopedHelpers.set(scope, new Set([name]));
197
357
  };
198
358
 
199
- /**
200
- * Walk an AST node tree without recursing into nested function bodies.
201
- *
202
- * This ensures that return statements inside `.map()`, `.filter()`, `.then()`
203
- * callbacks etc. are not mistakenly checked as implementation-level returns.
204
- */
205
- const walkShallow = (node: unknown, visit: (node: AstNode) => void): void => {
206
- if (!node || typeof node !== 'object') {
359
+ const addScopedResultVariable = (
360
+ resultVars: MutableScopedResultVariableMap,
361
+ scope: ReadonlySet<string>,
362
+ name: string
363
+ ): void => {
364
+ const existing = resultVars.get(scope);
365
+ if (existing) {
366
+ existing.add(name);
207
367
  return;
208
368
  }
209
- const n = node as AstNode;
210
- if (n.type) {
211
- visit(n);
369
+ resultVars.set(scope, new Set([name]));
370
+ };
371
+
372
+ /** Record `const helper = (): Result<...> => ...` declarations for the current lexical scope. */
373
+ export const trackScopedResultHelperDeclaration = (
374
+ node: AstNode,
375
+ scopes: readonly ReadonlySet<string>[],
376
+ sourceCode: string,
377
+ resultTypeNames: ReadonlySet<string>,
378
+ scopedHelpers: MutableScopedHelperMap
379
+ ): void => {
380
+ if (node.type !== 'VariableDeclarator') {
381
+ return;
382
+ }
383
+ const id = getNodeId(node);
384
+ const init = getNodeInit(node);
385
+ const name = extractIdentifierName(id);
386
+ if (!(name && init && isFunctionLikeExpression(init))) {
387
+ return;
388
+ }
389
+ if (!hasResultReturnType(init, sourceCode, resultTypeNames)) {
390
+ return;
391
+ }
392
+ const bindingScope = findNearestBindingScope(name, scopes);
393
+ if (bindingScope) {
394
+ addScopedHelper(scopedHelpers, bindingScope, name);
395
+ }
396
+ };
397
+
398
+ // ---------------------------------------------------------------------------
399
+ // Variable tracking
400
+ // ---------------------------------------------------------------------------
401
+
402
+ const hasResultVariableAnnotation = (
403
+ node: AstNode,
404
+ sourceCode: string,
405
+ resultTypeNames: ReadonlySet<string>
406
+ ): boolean => {
407
+ const typeAnnotation = getNodeTypeAnnotation(node);
408
+ if (!typeAnnotation) {
409
+ return false;
212
410
  }
213
- for (const val of Object.values(n)) {
214
- visitValue(val, visit, walkShallow);
411
+ const annotationText = sourceCode.slice(
412
+ typeAnnotation.start,
413
+ typeAnnotation.end
414
+ );
415
+ for (const name of resultTypeNames) {
416
+ if (hasGenericTypeReference(annotationText, name)) {
417
+ return true;
418
+ }
419
+ }
420
+ return false;
421
+ };
422
+
423
+ /** Track a VariableDeclarator, adding to resultVars if it produces a Result. */
424
+ const trackResultVariable = (
425
+ node: AstNode,
426
+ resultVars: MutableScopedResultVariableMap,
427
+ helperNames: ReadonlySet<string>,
428
+ namespaceHelpers: NamespaceHelperMap,
429
+ scopes: readonly ReadonlySet<string>[],
430
+ scopedHelpers: ScopedHelperMap,
431
+ sourceCode: string,
432
+ resultTypeNames: ReadonlySet<string>
433
+ ): void => {
434
+ const init = getNodeInit(node);
435
+ const id = getNodeId(node);
436
+ if (init && id?.type === 'Identifier') {
437
+ const name = getNodeName(id);
438
+ if (!name) {
439
+ return;
440
+ }
441
+ if (
442
+ hasResultVariableAnnotation(id, sourceCode, resultTypeNames) ||
443
+ isResultExpression(init) ||
444
+ isHelperCall(init, helperNames, namespaceHelpers, scopes, scopedHelpers)
445
+ ) {
446
+ const bindingScope = findNearestBindingScope(name, scopes);
447
+ if (bindingScope) {
448
+ addScopedResultVariable(resultVars, bindingScope, name);
449
+ }
450
+ }
215
451
  }
216
452
  };
217
453
 
@@ -226,81 +462,108 @@ const checkReturnStatements = (
226
462
  filePath: string,
227
463
  sourceCode: string,
228
464
  helperNames: ReadonlySet<string>,
229
- diagnostics: WardenDiagnostic[]
465
+ namespaceHelpers: NamespaceHelperMap,
466
+ resultTypeNames: ReadonlySet<string>,
467
+ diagnostics: WardenDiagnostic[],
468
+ implScope: ReadonlySet<string> = new Set<string>()
230
469
  ): void => {
231
- const resultVars = new Set<string>();
470
+ const resultVars: MutableScopedResultVariableMap = new Map();
471
+ const scopedHelpers: MutableScopedHelperMap = new Map();
472
+ const initialScopes = implScope.size > 0 ? [implScope] : [];
232
473
 
233
- walkShallow(blockBody, (node) => {
234
- if (node.type === 'VariableDeclarator') {
235
- trackResultVariable(node, resultVars);
236
- }
237
-
238
- if (node.type !== 'ReturnStatement') {
239
- return;
240
- }
241
-
242
- const { argument } = node as unknown as { argument?: AstNode };
243
- // Bare return — not a value return
244
- if (!argument) {
245
- return;
246
- }
474
+ walkWithScopes(
475
+ blockBody,
476
+ (node, currentScopes) => {
477
+ if (node.type === 'VariableDeclarator') {
478
+ trackScopedResultHelperDeclaration(
479
+ node,
480
+ currentScopes,
481
+ sourceCode,
482
+ resultTypeNames,
483
+ scopedHelpers
484
+ );
485
+ trackResultVariable(
486
+ node,
487
+ resultVars,
488
+ helperNames,
489
+ namespaceHelpers,
490
+ currentScopes,
491
+ scopedHelpers,
492
+ sourceCode,
493
+ resultTypeNames
494
+ );
495
+ }
247
496
 
248
- if (isAllowedReturnArgument(argument, helperNames, resultVars)) {
249
- return;
250
- }
497
+ if (node.type !== 'ReturnStatement') {
498
+ return;
499
+ }
251
500
 
252
- diagnostics.push({
253
- filePath,
254
- line: offsetToLine(sourceCode, node.start),
255
- message: `${trailInfo.label} "${trailInfo.id}" implementation must return Result.ok(...) or Result.err(...), not a raw value.`,
256
- rule: 'implementation-returns-result',
257
- severity: 'error',
258
- });
259
- });
260
- };
501
+ const argument = getNodeArgument(node);
502
+ // Bare return is not a value return.
503
+ if (!argument) {
504
+ return;
505
+ }
261
506
 
262
- // ---------------------------------------------------------------------------
263
- // Result helper name collection
264
- // ---------------------------------------------------------------------------
507
+ if (
508
+ isAllowedReturnArgument(
509
+ argument,
510
+ helperNames,
511
+ resultVars,
512
+ namespaceHelpers,
513
+ currentScopes,
514
+ scopedHelpers
515
+ )
516
+ ) {
517
+ return;
518
+ }
265
519
 
266
- /** Check if a return type annotation mentions Result. */
267
- const hasResultReturnType = (node: AstNode, sourceCode: string): boolean => {
268
- const { returnType } = node as unknown as { returnType?: AstNode };
269
- if (!returnType) {
270
- return false;
271
- }
272
- const annotationText = sourceCode.slice(returnType.start, returnType.end);
273
- return /\bResult\s*</.test(annotationText);
520
+ diagnostics.push({
521
+ filePath,
522
+ line: offsetToLine(sourceCode, node.start),
523
+ message: buildUnrecognizedResultMessage(trailInfo.label, trailInfo.id),
524
+ rule: 'implementation-returns-result',
525
+ severity: 'error',
526
+ });
527
+ },
528
+ { initialScopes, stopAtNestedFunctions: true }
529
+ );
274
530
  };
275
531
 
276
- const isFunctionLikeExpression = (node: AstNode): boolean =>
277
- node.type === 'ArrowFunctionExpression' || node.type === 'FunctionExpression';
278
-
279
532
  /** Collect names of top-level functions/consts with explicit Result return types. */
280
533
  const collectResultHelperNames = (
281
534
  ast: AstNode,
282
535
  sourceCode: string
283
536
  ): ReadonlySet<string> => {
284
537
  const names = new Set<string>();
538
+ const resultTypeNames = collectResultTypeNames(ast);
285
539
 
286
540
  walk(ast, (node) => {
287
541
  if (node.type === 'VariableDeclarator') {
288
- const { id } = node as unknown as { id?: AstNode };
289
- const { init } = node as unknown as { init?: AstNode };
542
+ const id = getNodeId(node);
543
+ const init = getNodeInit(node);
290
544
  if (
291
545
  id?.type === 'Identifier' &&
292
546
  init &&
293
547
  isFunctionLikeExpression(init) &&
294
- hasResultReturnType(init, sourceCode)
548
+ hasResultReturnType(init, sourceCode, resultTypeNames)
295
549
  ) {
296
- names.add((id as unknown as { name: string }).name);
550
+ const name = getNodeName(id);
551
+ if (name) {
552
+ names.add(name);
553
+ }
297
554
  }
298
555
  }
299
556
 
300
557
  if (node.type === 'FunctionDeclaration') {
301
- const { id } = node as unknown as { id?: AstNode };
302
- if (id?.type === 'Identifier' && hasResultReturnType(node, sourceCode)) {
303
- names.add((id as unknown as { name: string }).name);
558
+ const id = getNodeId(node);
559
+ if (
560
+ id?.type === 'Identifier' &&
561
+ hasResultReturnType(node, sourceCode, resultTypeNames)
562
+ ) {
563
+ const name = getNodeName(id);
564
+ if (name) {
565
+ names.add(name);
566
+ }
304
567
  }
305
568
  }
306
569
  });
@@ -308,6 +571,954 @@ const collectResultHelperNames = (
308
571
  return names;
309
572
  };
310
573
 
574
+ // ---------------------------------------------------------------------------
575
+ // Imported Result helper resolution
576
+ // ---------------------------------------------------------------------------
577
+
578
+ /**
579
+ * Per-target-file cache of exported Result-helper names keyed by the absolute
580
+ * target path. Saves re-parsing when multiple rule invocations resolve the
581
+ * same file during a single warden run.
582
+ *
583
+ * @remarks
584
+ * Long-running processes calling `implementationReturnsResult.check` after
585
+ * source files change (e.g. watch mode, editor language servers) should call
586
+ * `clearImplementationReturnsResultCache()` between runs to avoid returning
587
+ * stale helper-name sets. The cache is intentionally not auto-invalidated per
588
+ * invocation — that would defeat its purpose within a single warden run.
589
+ */
590
+ const targetFileResultExportCache = new Map<string, ReadonlySet<string>>();
591
+
592
+ /**
593
+ * Clear the module-level cache used by the `implementation-returns-result`
594
+ * rule to remember which exported names on a target file carry a `Result<...>`
595
+ * return annotation.
596
+ *
597
+ * Call this between runs in long-lived processes where the set of Trails
598
+ * source files may have changed on disk since the last check.
599
+ */
600
+ export const clearImplementationReturnsResultCache = (): void => {
601
+ targetFileResultExportCache.clear();
602
+ };
603
+
604
+ interface ImportBinding {
605
+ /** Local alias used in the importing file. */
606
+ readonly localName: string;
607
+ /** Original exported name from the target module. */
608
+ readonly importedName: string;
609
+ /** Raw import source specifier (e.g. './foo.js'). */
610
+ readonly source: string;
611
+ }
612
+
613
+ const buildDefaultImportBinding = (
614
+ specifier: AstNode,
615
+ source: string
616
+ ): ImportBinding | null => {
617
+ const local = getNodeLocal(specifier);
618
+ const localName = extractIdentifierName(local);
619
+ if (!localName) {
620
+ return null;
621
+ }
622
+ return { importedName: 'default', localName, source };
623
+ };
624
+
625
+ const buildNamedImportBinding = (
626
+ specifier: AstNode,
627
+ source: string
628
+ ): ImportBinding | null => {
629
+ const local = getNodeLocal(specifier);
630
+ const imported = getNodeImported(specifier);
631
+ const localName = extractIdentifierName(local);
632
+ const importedName = extractIdentifierName(imported) ?? localName;
633
+ if (!(localName && importedName)) {
634
+ return null;
635
+ }
636
+ return { importedName, localName, source };
637
+ };
638
+
639
+ /**
640
+ * @remarks
641
+ * `import foo from './bar.js'` is treated as a re-export of `default` so the
642
+ * target file's `export default` declaration is considered as a potential
643
+ * Result helper. `import * as ns from './bar.js'` is handled separately by
644
+ * `collectNamespaceHelperImports`, which maps the namespace binding to the
645
+ * target's exported Result-helper names so `ns.helper(...)` member calls are
646
+ * recognized.
647
+ */
648
+ const buildImportBinding = (
649
+ specifier: AstNode,
650
+ source: string
651
+ ): ImportBinding | null => {
652
+ if (specifier.type === 'ImportDefaultSpecifier') {
653
+ return buildDefaultImportBinding(specifier, source);
654
+ }
655
+ if (specifier.type === 'ImportSpecifier') {
656
+ return buildNamedImportBinding(specifier, source);
657
+ }
658
+ return null;
659
+ };
660
+
661
+ const collectBindingsFromImportDeclaration = (
662
+ node: AstNode
663
+ ): readonly ImportBinding[] => {
664
+ const source = getImportSourceValue(node);
665
+ if (!source) {
666
+ return [];
667
+ }
668
+ const specifiers =
669
+ (node['specifiers'] as readonly AstNode[] | undefined) ?? [];
670
+ return specifiers.flatMap((specifier) => {
671
+ const binding = buildImportBinding(specifier, source);
672
+ return binding ? [binding] : [];
673
+ });
674
+ };
675
+
676
+ /** Collect `import {
677
+ foo as bar
678
+ } from './...';` bindings keyed by local name. */
679
+ const collectResolvableImports = (ast: AstNode): readonly ImportBinding[] => {
680
+ const imports: ImportBinding[] = [];
681
+ walk(ast, (node) => {
682
+ if (node.type === 'ImportDeclaration') {
683
+ imports.push(...collectBindingsFromImportDeclaration(node));
684
+ }
685
+ });
686
+ return imports;
687
+ };
688
+
689
+ /**
690
+ * Resolve a relative import source specifier to an absolute on-disk file path,
691
+ * or null when the source is not a relative path we can resolve locally.
692
+ *
693
+ * Handles `.js` -> `.ts` rewriting (the convention in this repo), plain `.ts`
694
+ * imports, and extensionless paths.
695
+ */
696
+ const buildResolutionCandidates = (resolved: string): readonly string[] => {
697
+ if (resolved.endsWith('.ts') || resolved.endsWith('.tsx')) {
698
+ return [resolved];
699
+ }
700
+ if (resolved.endsWith('.js')) {
701
+ return [
702
+ resolved.replace(/\.js$/, '.ts'),
703
+ resolved.replace(/\.js$/, '.tsx'),
704
+ resolved,
705
+ ];
706
+ }
707
+ if (resolved.endsWith('.jsx')) {
708
+ return [resolved.replace(/\.jsx$/, '.tsx'), resolved];
709
+ }
710
+ return [`${resolved}.ts`, `${resolved}.tsx`];
711
+ };
712
+
713
+ const resolveRelativeImportPath = (
714
+ source: string,
715
+ fromFile: string
716
+ ): string | null => {
717
+ if (!(source.startsWith('./') || source.startsWith('../'))) {
718
+ return null;
719
+ }
720
+ const baseDir = isAbsolute(fromFile)
721
+ ? dirname(fromFile)
722
+ : dirname(resolve(fromFile));
723
+ const resolved = resolve(baseDir, source);
724
+ return (
725
+ buildResolutionCandidates(resolved).find((candidate) =>
726
+ existsSync(candidate)
727
+ ) ?? null
728
+ );
729
+ };
730
+
731
+ /** Extract the declaration wrapped by an ExportNamedDeclaration, if any. */
732
+ const getExportedDeclaration = (node: AstNode): AstNode | null => {
733
+ if (node.type !== 'ExportNamedDeclaration') {
734
+ return null;
735
+ }
736
+ const decl = getNodeDeclaration(node);
737
+ return decl ?? null;
738
+ };
739
+
740
+ const addExportedVariableResultHelper = (
741
+ decl: AstNode,
742
+ source: string,
743
+ collected: Set<string>,
744
+ resultTypeNames: ReadonlySet<string>
745
+ ): void => {
746
+ const declarations =
747
+ (decl['declarations'] as readonly AstNode[] | undefined) ?? [];
748
+ for (const declarator of declarations) {
749
+ const id = getNodeId(declarator);
750
+ const init = getNodeInit(declarator);
751
+ const name = extractIdentifierName(id);
752
+ if (
753
+ name &&
754
+ init &&
755
+ isFunctionLikeExpression(init) &&
756
+ hasResultReturnType(init, source, resultTypeNames)
757
+ ) {
758
+ collected.add(name);
759
+ }
760
+ }
761
+ };
762
+
763
+ const addExportedFunctionResultHelper = (
764
+ decl: AstNode,
765
+ source: string,
766
+ collected: Set<string>,
767
+ resultTypeNames: ReadonlySet<string>
768
+ ): void => {
769
+ const name = extractIdentifierName(getNodeId(decl));
770
+ if (name && hasResultReturnType(decl, source, resultTypeNames)) {
771
+ collected.add(name);
772
+ }
773
+ };
774
+
775
+ // ---------------------------------------------------------------------------
776
+ // Same-file declaration index (for specifier re-exports without a source)
777
+ // ---------------------------------------------------------------------------
778
+
779
+ /**
780
+ * Index a file's top-level function-like declarations (both exported-inline
781
+ * and plain) by name to the declaration node, so we can look up the original
782
+ * binding referenced by a specifier re-export like `export { helper }`.
783
+ *
784
+ * Each entry carries the init/declaration node so the caller can check the
785
+ * return-type annotation without re-walking.
786
+ */
787
+ type DeclarationIndex = ReadonlyMap<string, AstNode>;
788
+
789
+ const indexVariableDeclarationInto = (
790
+ decl: AstNode,
791
+ index: Map<string, AstNode>
792
+ ): void => {
793
+ const declarators =
794
+ (decl['declarations'] as readonly AstNode[] | undefined) ?? [];
795
+ for (const declarator of declarators) {
796
+ const id = getNodeId(declarator);
797
+ const init = getNodeInit(declarator);
798
+ const name = extractIdentifierName(id);
799
+ if (name && init && isFunctionLikeExpression(init)) {
800
+ index.set(name, init);
801
+ }
802
+ }
803
+ };
804
+
805
+ const indexFunctionDeclarationInto = (
806
+ decl: AstNode,
807
+ index: Map<string, AstNode>
808
+ ): void => {
809
+ const name = extractIdentifierName(getNodeId(decl));
810
+ if (name) {
811
+ index.set(name, decl);
812
+ }
813
+ };
814
+
815
+ const indexDeclarationInto = (
816
+ decl: AstNode | null | undefined,
817
+ index: Map<string, AstNode>
818
+ ): void => {
819
+ if (!decl) {
820
+ return;
821
+ }
822
+ if (decl.type === 'VariableDeclaration') {
823
+ indexVariableDeclarationInto(decl, index);
824
+ } else if (decl.type === 'FunctionDeclaration') {
825
+ indexFunctionDeclarationInto(decl, index);
826
+ }
827
+ };
828
+
829
+ const indexBodyNodeInto = (
830
+ node: AstNode,
831
+ index: Map<string, AstNode>
832
+ ): void => {
833
+ if (node.type === 'ExportNamedDeclaration') {
834
+ indexDeclarationInto(getExportedDeclaration(node), index);
835
+ return;
836
+ }
837
+ indexDeclarationInto(node, index);
838
+ };
839
+
840
+ const indexLocalDeclarations = (ast: AstNode): DeclarationIndex => {
841
+ const index = new Map<string, AstNode>();
842
+ const bodyNodes = getNodeBodyStatements(ast);
843
+ for (const node of bodyNodes) {
844
+ indexBodyNodeInto(node, index);
845
+ }
846
+ return index;
847
+ };
848
+
849
+ // ---------------------------------------------------------------------------
850
+ // Export-specifier handling
851
+ // ---------------------------------------------------------------------------
852
+
853
+ interface ExportSpecifierInfo {
854
+ /** Name this export is exposed as to consumers (after `as` alias). */
855
+ readonly exportedName: string;
856
+ /** Name referenced inside the re-export (`helper` in `export { helper }`). */
857
+ readonly localName: string;
858
+ /** True when the specifier is `default` (i.e. `export { default as X }`). */
859
+ readonly isDefault: boolean;
860
+ }
861
+
862
+ const getSpecifierNameNode = (
863
+ spec: AstNode,
864
+ key: 'exported' | 'local'
865
+ ): string | null => {
866
+ const node = (spec as unknown as Record<string, AstNode | undefined>)[key];
867
+ if (!node) {
868
+ return null;
869
+ }
870
+ if (node.type === 'Identifier') {
871
+ return getNodeName(node) ?? null;
872
+ }
873
+ // Support string-literal specifiers (`export { "default" as X }`, etc).
874
+ const value = getNodeValue(node);
875
+ return typeof value === 'string' ? value : null;
876
+ };
877
+
878
+ const buildExportSpecifierInfo = (
879
+ spec: AstNode
880
+ ): ExportSpecifierInfo | null => {
881
+ if (spec.type !== 'ExportSpecifier') {
882
+ return null;
883
+ }
884
+ const localName = getSpecifierNameNode(spec, 'local');
885
+ const exportedName = getSpecifierNameNode(spec, 'exported') ?? localName;
886
+ if (!(localName && exportedName)) {
887
+ return null;
888
+ }
889
+ return {
890
+ exportedName,
891
+ isDefault: localName === 'default',
892
+ localName,
893
+ };
894
+ };
895
+
896
+ const getExportDefaultDeclaration = (ast: AstNode): AstNode | null => {
897
+ const bodyNodes = getNodeBodyStatements(ast);
898
+ for (const node of bodyNodes) {
899
+ if (node.type === 'ExportDefaultDeclaration') {
900
+ const decl = getNodeDeclaration(node);
901
+ return decl ?? null;
902
+ }
903
+ }
904
+ return null;
905
+ };
906
+
907
+ // Bounded recursion: one transitive hop through `export { ... } from`.
908
+ const MAX_RERESOLVE_DEPTH = 1;
909
+
910
+ /** Check whether a local declaration node has a `Result<...>` return annotation. */
911
+ const isResultHelperDeclaration = (
912
+ declarationNode: AstNode | undefined,
913
+ source: string,
914
+ resultTypeNames: ReadonlySet<string>
915
+ ): boolean => {
916
+ if (!declarationNode) {
917
+ return false;
918
+ }
919
+ if (isFunctionLikeExpression(declarationNode)) {
920
+ return hasResultReturnType(declarationNode, source, resultTypeNames);
921
+ }
922
+ if (declarationNode.type === 'FunctionDeclaration') {
923
+ return hasResultReturnType(declarationNode, source, resultTypeNames);
924
+ }
925
+ return false;
926
+ };
927
+
928
+ /** Resolve an `export default ...` declaration, following one identifier hop. */
929
+ const checkDefaultDeclarationIsResultHelper = (
930
+ defaultDecl: AstNode,
931
+ targetSource: string,
932
+ targetLocalDeclarations: DeclarationIndex,
933
+ resultTypeNames: ReadonlySet<string>
934
+ ): boolean => {
935
+ if (isResultHelperDeclaration(defaultDecl, targetSource, resultTypeNames)) {
936
+ return true;
937
+ }
938
+ if (defaultDecl.type === 'Identifier') {
939
+ const name = extractIdentifierName(defaultDecl);
940
+ const referenced = name ? targetLocalDeclarations.get(name) : undefined;
941
+ return isResultHelperDeclaration(referenced, targetSource, resultTypeNames);
942
+ }
943
+ return false;
944
+ };
945
+
946
+ interface LoadedTargetFile {
947
+ readonly ast: AstNode;
948
+ readonly source: string;
949
+ readonly localDeclarations: DeclarationIndex;
950
+ readonly resultTypeNames: ReadonlySet<string>;
951
+ }
952
+
953
+ const loadTargetFile = (targetPath: string): LoadedTargetFile | null => {
954
+ try {
955
+ const source = readFileSync(targetPath, 'utf8');
956
+ const ast = parse(targetPath, source) as AstNode | null;
957
+ if (!ast) {
958
+ return null;
959
+ }
960
+ return {
961
+ ast,
962
+ localDeclarations: indexLocalDeclarations(ast),
963
+ resultTypeNames: collectResultTypeNames(ast),
964
+ source,
965
+ };
966
+ } catch {
967
+ return null;
968
+ }
969
+ };
970
+
971
+ interface ReExportContext {
972
+ readonly loadedTarget: LoadedTargetFile | null;
973
+ readonly downstreamResultNames: ReadonlySet<string>;
974
+ }
975
+
976
+ const applyDefaultSpecifier = (
977
+ info: ExportSpecifierInfo,
978
+ loadedTarget: LoadedTargetFile | null,
979
+ collected: Set<string>
980
+ ): void => {
981
+ if (!loadedTarget) {
982
+ return;
983
+ }
984
+ const defaultDecl = getExportDefaultDeclaration(loadedTarget.ast);
985
+ if (!defaultDecl) {
986
+ return;
987
+ }
988
+ if (
989
+ checkDefaultDeclarationIsResultHelper(
990
+ defaultDecl,
991
+ loadedTarget.source,
992
+ loadedTarget.localDeclarations,
993
+ loadedTarget.resultTypeNames
994
+ )
995
+ ) {
996
+ collected.add(info.exportedName);
997
+ }
998
+ };
999
+
1000
+ const applySpecifierInfo = (
1001
+ info: ExportSpecifierInfo,
1002
+ ctx: ReExportContext,
1003
+ collected: Set<string>
1004
+ ): void => {
1005
+ if (info.isDefault) {
1006
+ applyDefaultSpecifier(info, ctx.loadedTarget, collected);
1007
+ return;
1008
+ }
1009
+ if (ctx.downstreamResultNames.has(info.localName)) {
1010
+ collected.add(info.exportedName);
1011
+ }
1012
+ };
1013
+
1014
+ const resolveReExportTargetPath = (
1015
+ node: AstNode,
1016
+ targetPath: string,
1017
+ visited: ReadonlySet<string>,
1018
+ depth: number
1019
+ ): string | null => {
1020
+ if (depth >= MAX_RERESOLVE_DEPTH) {
1021
+ return null;
1022
+ }
1023
+ const reSource = getImportSourceValue(node);
1024
+ if (!reSource) {
1025
+ return null;
1026
+ }
1027
+ const reTargetPath = resolveRelativeImportPath(reSource, targetPath);
1028
+ if (!reTargetPath || visited.has(reTargetPath)) {
1029
+ return null;
1030
+ }
1031
+ return reTargetPath;
1032
+ };
1033
+
1034
+ const buildReExportContext = (
1035
+ reTargetPath: string,
1036
+ specifierInfos: readonly ExportSpecifierInfo[],
1037
+ targetPath: string,
1038
+ visited: ReadonlySet<string>,
1039
+ depth: number
1040
+ ): ReExportContext => {
1041
+ const needsDefault = specifierInfos.some((info) => info.isDefault);
1042
+ // Load once when the default specifier branch needs the target AST; the
1043
+ // same loaded object is threaded into the downstream walk so it isn't
1044
+ // read and parsed a second time within this check() call.
1045
+ const loadedTarget = needsDefault ? loadTargetFile(reTargetPath) : null;
1046
+ // eslint-disable-next-line no-use-before-define
1047
+ const downstreamResultNames = collectTargetExportedResultHelperNames(
1048
+ reTargetPath,
1049
+ visited,
1050
+ targetPath,
1051
+ depth + 1,
1052
+ loadedTarget
1053
+ );
1054
+ return {
1055
+ downstreamResultNames,
1056
+ loadedTarget,
1057
+ };
1058
+ };
1059
+
1060
+ /**
1061
+ * Resolve a re-export with source (`export { ... } from './x.js'`) by pulling
1062
+ * the matching names off the target file, honoring aliases and `default`.
1063
+ */
1064
+ const resolveReExportWithSource = (
1065
+ node: AstNode,
1066
+ specifiers: readonly AstNode[],
1067
+ targetPath: string,
1068
+ visited: ReadonlySet<string>,
1069
+ depth: number,
1070
+ collected: Set<string>
1071
+ ): void => {
1072
+ const reTargetPath = resolveReExportTargetPath(
1073
+ node,
1074
+ targetPath,
1075
+ visited,
1076
+ depth
1077
+ );
1078
+ if (!reTargetPath) {
1079
+ return;
1080
+ }
1081
+ const specifierInfos = specifiers.flatMap((spec) => {
1082
+ const info = buildExportSpecifierInfo(spec);
1083
+ return info ? [info] : [];
1084
+ });
1085
+ const ctx = buildReExportContext(
1086
+ reTargetPath,
1087
+ specifierInfos,
1088
+ targetPath,
1089
+ visited,
1090
+ depth
1091
+ );
1092
+ for (const info of specifierInfos) {
1093
+ applySpecifierInfo(info, ctx, collected);
1094
+ }
1095
+ };
1096
+
1097
+ /** Resolve a specifier-only re-export (`export { helper };`) against same-file declarations. */
1098
+ const resolveReExportWithoutSource = (
1099
+ specifiers: readonly AstNode[],
1100
+ localDeclarations: DeclarationIndex,
1101
+ source: string,
1102
+ collected: Set<string>,
1103
+ resultTypeNames: ReadonlySet<string>
1104
+ ): void => {
1105
+ for (const spec of specifiers) {
1106
+ const info = buildExportSpecifierInfo(spec);
1107
+ if (!info || info.isDefault) {
1108
+ continue;
1109
+ }
1110
+ if (
1111
+ isResultHelperDeclaration(
1112
+ localDeclarations.get(info.localName),
1113
+ source,
1114
+ resultTypeNames
1115
+ )
1116
+ ) {
1117
+ collected.add(info.exportedName);
1118
+ }
1119
+ }
1120
+ };
1121
+
1122
+ const processInlineExportedDeclaration = (
1123
+ exportedDecl: AstNode,
1124
+ source: string,
1125
+ collected: Set<string>,
1126
+ resultTypeNames: ReadonlySet<string>
1127
+ ): boolean => {
1128
+ if (exportedDecl.type === 'VariableDeclaration') {
1129
+ addExportedVariableResultHelper(
1130
+ exportedDecl,
1131
+ source,
1132
+ collected,
1133
+ resultTypeNames
1134
+ );
1135
+ return true;
1136
+ }
1137
+ if (exportedDecl.type === 'FunctionDeclaration') {
1138
+ addExportedFunctionResultHelper(
1139
+ exportedDecl,
1140
+ source,
1141
+ collected,
1142
+ resultTypeNames
1143
+ );
1144
+ return true;
1145
+ }
1146
+ return false;
1147
+ };
1148
+
1149
+ const processExportNamedDeclaration = (
1150
+ node: AstNode,
1151
+ source: string,
1152
+ targetPath: string,
1153
+ visited: ReadonlySet<string>,
1154
+ depth: number,
1155
+ localDeclarations: DeclarationIndex,
1156
+ collected: Set<string>,
1157
+ resultTypeNames: ReadonlySet<string>
1158
+ ): void => {
1159
+ const exportedDecl = getExportedDeclaration(node);
1160
+ if (
1161
+ exportedDecl &&
1162
+ processInlineExportedDeclaration(
1163
+ exportedDecl,
1164
+ source,
1165
+ collected,
1166
+ resultTypeNames
1167
+ )
1168
+ ) {
1169
+ return;
1170
+ }
1171
+ const specifiers =
1172
+ (node['specifiers'] as readonly AstNode[] | undefined) ?? [];
1173
+ if (specifiers.length === 0) {
1174
+ return;
1175
+ }
1176
+ if (getImportSourceValue(node)) {
1177
+ resolveReExportWithSource(
1178
+ node,
1179
+ specifiers,
1180
+ targetPath,
1181
+ visited,
1182
+ depth,
1183
+ collected
1184
+ );
1185
+ return;
1186
+ }
1187
+ resolveReExportWithoutSource(
1188
+ specifiers,
1189
+ localDeclarations,
1190
+ source,
1191
+ collected,
1192
+ resultTypeNames
1193
+ );
1194
+ };
1195
+
1196
+ const processExportDefaultDeclaration = (
1197
+ node: AstNode,
1198
+ source: string,
1199
+ localDeclarations: DeclarationIndex,
1200
+ collected: Set<string>,
1201
+ resultTypeNames: ReadonlySet<string>
1202
+ ): void => {
1203
+ const defaultDecl = getNodeDeclaration(node);
1204
+ if (!defaultDecl) {
1205
+ return;
1206
+ }
1207
+ if (
1208
+ checkDefaultDeclarationIsResultHelper(
1209
+ defaultDecl,
1210
+ source,
1211
+ localDeclarations,
1212
+ resultTypeNames
1213
+ )
1214
+ ) {
1215
+ collected.add('default');
1216
+ }
1217
+ };
1218
+
1219
+ const collectExportedResultHelpersFromAst = (
1220
+ ast: AstNode,
1221
+ source: string,
1222
+ targetPath: string,
1223
+ visited: ReadonlySet<string>,
1224
+ depth: number,
1225
+ preloadedLocalDeclarations: DeclarationIndex | null = null,
1226
+ preloadedResultTypeNames: ReadonlySet<string> | null = null
1227
+ ): ReadonlySet<string> => {
1228
+ const collected = new Set<string>();
1229
+ // Reuse preloaded indexes from `loadTargetFile` when available to avoid
1230
+ // re-walking the same AST.
1231
+ const localDeclarations =
1232
+ preloadedLocalDeclarations ?? indexLocalDeclarations(ast);
1233
+ const resultTypeNames =
1234
+ preloadedResultTypeNames ?? collectResultTypeNames(ast);
1235
+ const bodyNodes = getNodeBodyStatements(ast);
1236
+
1237
+ for (const node of bodyNodes) {
1238
+ if (node.type === 'ExportNamedDeclaration') {
1239
+ processExportNamedDeclaration(
1240
+ node,
1241
+ source,
1242
+ targetPath,
1243
+ visited,
1244
+ depth,
1245
+ localDeclarations,
1246
+ collected,
1247
+ resultTypeNames
1248
+ );
1249
+ } else if (node.type === 'ExportDefaultDeclaration') {
1250
+ processExportDefaultDeclaration(
1251
+ node,
1252
+ source,
1253
+ localDeclarations,
1254
+ collected,
1255
+ resultTypeNames
1256
+ );
1257
+ } else if (node.type === 'ExportAllDeclaration') {
1258
+ // eslint-disable-next-line no-use-before-define
1259
+ processExportAllDeclaration(node, targetPath, visited, depth, collected);
1260
+ }
1261
+ }
1262
+
1263
+ return collected;
1264
+ };
1265
+
1266
+ /**
1267
+ * Handle `export * from './x.js'` by recursing into the target module and
1268
+ * unioning its exported Result-helper names. Type-only re-exports
1269
+ * (`export type * from '...'`) contribute nothing. Bounded by
1270
+ * `MAX_RERESOLVE_DEPTH` and the visited-set cycle guard shared with the
1271
+ * specifier re-export path.
1272
+ */
1273
+ const processExportAllDeclaration = (
1274
+ node: AstNode,
1275
+ targetPath: string,
1276
+ visited: ReadonlySet<string>,
1277
+ depth: number,
1278
+ collected: Set<string>
1279
+ ): void => {
1280
+ const exportKind = getNodeExportKind(node);
1281
+ if (exportKind === 'type') {
1282
+ return;
1283
+ }
1284
+ const reTargetPath = resolveReExportTargetPath(
1285
+ node,
1286
+ targetPath,
1287
+ visited,
1288
+ depth
1289
+ );
1290
+ if (!reTargetPath) {
1291
+ return;
1292
+ }
1293
+ // eslint-disable-next-line no-use-before-define
1294
+ const downstream = collectTargetExportedResultHelperNames(
1295
+ reTargetPath,
1296
+ visited,
1297
+ targetPath,
1298
+ depth + 1
1299
+ );
1300
+ // `export * from` does NOT re-export the default binding, so we union
1301
+ // only the named Result helpers from the downstream module.
1302
+ for (const name of downstream) {
1303
+ if (name !== 'default') {
1304
+ collected.add(name);
1305
+ }
1306
+ }
1307
+ };
1308
+
1309
+ const parseTargetResultHelperNames = (
1310
+ targetPath: string,
1311
+ visited: ReadonlySet<string>,
1312
+ depth: number,
1313
+ preloaded: LoadedTargetFile | null = null
1314
+ ): ReadonlySet<string> => {
1315
+ const loaded = preloaded ?? loadTargetFile(targetPath);
1316
+ if (!loaded) {
1317
+ return new Set<string>();
1318
+ }
1319
+ return collectExportedResultHelpersFromAst(
1320
+ loaded.ast,
1321
+ loaded.source,
1322
+ targetPath,
1323
+ visited,
1324
+ depth,
1325
+ loaded.localDeclarations,
1326
+ loaded.resultTypeNames
1327
+ );
1328
+ };
1329
+
1330
+ const buildVisitedPathSet = (
1331
+ parentVisited: ReadonlySet<string>,
1332
+ targetPath: string,
1333
+ parentPath: string | undefined
1334
+ ): ReadonlySet<string> => {
1335
+ const seeds = [...parentVisited, targetPath];
1336
+ if (parentPath) {
1337
+ seeds.push(parentPath);
1338
+ }
1339
+ return new Set<string>(seeds);
1340
+ };
1341
+
1342
+ /**
1343
+ * Collect the set of exported names from a target file whose declaration has
1344
+ * an explicit `Result<...>` / `Promise<Result<...>>` return annotation.
1345
+ *
1346
+ * Uses a visited-set on the recursion path to guard against `export { ... }
1347
+ * from` import cycles between files. Depth is capped at a single transitive
1348
+ * hop (see `MAX_RERESOLVE_DEPTH`) — deeper chains silently fall back.
1349
+ */
1350
+ // Only the direct-import path (no parents visited) is safe to cache: the
1351
+ // computed set is a function of (targetPath, parentVisited), and
1352
+ // cycle-truncated results from transitive walks must not bleed into later
1353
+ // direct lookups. See PR #204 review.
1354
+ const readCachedResultExports = (
1355
+ targetPath: string,
1356
+ parentVisited: ReadonlySet<string>
1357
+ ): ReadonlySet<string> | undefined => {
1358
+ if (parentVisited.size !== 0) {
1359
+ return;
1360
+ }
1361
+ return targetFileResultExportCache.get(targetPath);
1362
+ };
1363
+
1364
+ // biome-ignore lint/style/useConst: declared as a function so hoisting lets `buildReExportContext` (a const declared earlier) reference it before its textual definition
1365
+ // eslint-disable-next-line func-style, no-use-before-define
1366
+ function collectTargetExportedResultHelperNames(
1367
+ targetPath: string,
1368
+ parentVisited: ReadonlySet<string> = new Set<string>(),
1369
+ parentPath?: string,
1370
+ depth = 0,
1371
+ preloaded: LoadedTargetFile | null = null
1372
+ ): ReadonlySet<string> {
1373
+ if (parentVisited.has(targetPath)) {
1374
+ return new Set<string>();
1375
+ }
1376
+ const cached = readCachedResultExports(targetPath, parentVisited);
1377
+ if (cached) {
1378
+ return cached;
1379
+ }
1380
+ const visited = buildVisitedPathSet(parentVisited, targetPath, parentPath);
1381
+ const names = parseTargetResultHelperNames(
1382
+ targetPath,
1383
+ visited,
1384
+ depth,
1385
+ preloaded
1386
+ );
1387
+ if (parentVisited.size === 0) {
1388
+ targetFileResultExportCache.set(targetPath, names);
1389
+ }
1390
+ return names;
1391
+ }
1392
+
1393
+ /**
1394
+ * Extend a local-helper-name set with Result-returning helpers imported from
1395
+ * relative modules. Falls back silently on any resolution/parse failure.
1396
+ */
1397
+ const collectImportedResultHelperNames = (
1398
+ ast: AstNode,
1399
+ filePath: string
1400
+ ): ReadonlySet<string> => {
1401
+ const names = new Set<string>();
1402
+
1403
+ for (const binding of collectResolvableImports(ast)) {
1404
+ const targetPath = resolveRelativeImportPath(binding.source, filePath);
1405
+ if (!targetPath) {
1406
+ continue;
1407
+ }
1408
+ const exportedResultNames =
1409
+ collectTargetExportedResultHelperNames(targetPath);
1410
+ if (exportedResultNames.has(binding.importedName)) {
1411
+ names.add(binding.localName);
1412
+ }
1413
+ }
1414
+
1415
+ return names;
1416
+ };
1417
+
1418
+ interface NamespaceEntry {
1419
+ readonly localName: string;
1420
+ readonly names: ReadonlySet<string>;
1421
+ }
1422
+
1423
+ /** Extract a namespace specifier's local name if it is a namespace import. */
1424
+ const getNamespaceLocalName = (spec: AstNode): string | null => {
1425
+ if (spec.type !== 'ImportNamespaceSpecifier') {
1426
+ return null;
1427
+ }
1428
+ const local = getNodeLocal(spec);
1429
+ return extractIdentifierName(local);
1430
+ };
1431
+
1432
+ /**
1433
+ * Resolve a single namespace specifier to (localName, resultHelperNames), or
1434
+ * null when the specifier is not a resolvable namespace import.
1435
+ *
1436
+ * We intentionally record the namespace even when the target file exports no
1437
+ * Result helpers (empty set). `isNamespaceHelperMemberCall` can then identify
1438
+ * `ns.anything()` as a namespace member call against a non-Result-helper
1439
+ * target — which correctly falls through to the general return-value
1440
+ * diagnostic path. Dropping the entry would misclassify the call as a
1441
+ * *non-namespace* member call and skip the namespace-shadowing scope check.
1442
+ */
1443
+ const resolveNamespaceSpecifier = (
1444
+ spec: AstNode,
1445
+ source: string,
1446
+ filePath: string
1447
+ ): NamespaceEntry | null => {
1448
+ const localName = getNamespaceLocalName(spec);
1449
+ if (!localName) {
1450
+ return null;
1451
+ }
1452
+ const targetPath = resolveRelativeImportPath(source, filePath);
1453
+ if (!targetPath) {
1454
+ return null;
1455
+ }
1456
+ const names = collectTargetExportedResultHelperNames(targetPath);
1457
+ return { localName, names };
1458
+ };
1459
+
1460
+ /** Extract namespace helper entries from a single ImportDeclaration node. */
1461
+ const namespaceEntriesFromImport = (
1462
+ node: AstNode,
1463
+ filePath: string
1464
+ ): readonly NamespaceEntry[] => {
1465
+ const source = getImportSourceValue(node);
1466
+ if (!source) {
1467
+ return [];
1468
+ }
1469
+ const specifiers =
1470
+ (node['specifiers'] as readonly AstNode[] | undefined) ?? [];
1471
+ return specifiers.flatMap((spec) => {
1472
+ const entry = resolveNamespaceSpecifier(spec, source, filePath);
1473
+ return entry ? [entry] : [];
1474
+ });
1475
+ };
1476
+
1477
+ /**
1478
+ * Collect `import * as ns from './foo.js'` bindings and map each local
1479
+ * namespace name to the set of Result-returning helper names exported by the
1480
+ * resolved target module. Returns an empty map if no namespace imports are
1481
+ * found or none resolve to local files.
1482
+ */
1483
+ export const collectNamespaceHelperImports = (
1484
+ ast: AstNode,
1485
+ filePath: string
1486
+ ): NamespaceHelperMap => {
1487
+ const map = new Map<string, ReadonlySet<string>>();
1488
+ walk(ast, (node) => {
1489
+ if (node.type !== 'ImportDeclaration') {
1490
+ return;
1491
+ }
1492
+ for (const { localName, names } of namespaceEntriesFromImport(
1493
+ node,
1494
+ filePath
1495
+ )) {
1496
+ map.set(localName, names);
1497
+ }
1498
+ });
1499
+ return map;
1500
+ };
1501
+
1502
+ /**
1503
+ * Combine same-file helper names with helpers imported from relative modules.
1504
+ */
1505
+ export const collectAllResultHelperNames = (
1506
+ ast: AstNode,
1507
+ sourceCode: string,
1508
+ filePath: string
1509
+ ): ReadonlySet<string> => {
1510
+ const local = collectResultHelperNames(ast, sourceCode);
1511
+ const imported = collectImportedResultHelperNames(ast, filePath);
1512
+ if (imported.size === 0) {
1513
+ return local;
1514
+ }
1515
+ const merged = new Set<string>(local);
1516
+ for (const name of imported) {
1517
+ merged.add(name);
1518
+ }
1519
+ return merged;
1520
+ };
1521
+
311
1522
  // ---------------------------------------------------------------------------
312
1523
  // Per-implementation checking
313
1524
  // ---------------------------------------------------------------------------
@@ -318,13 +1529,19 @@ const checkImplementation = (
318
1529
  filePath: string,
319
1530
  sourceCode: string,
320
1531
  helperNames: ReadonlySet<string>,
1532
+ namespaceHelpers: NamespaceHelperMap,
1533
+ resultTypeNames: ReadonlySet<string>,
321
1534
  diagnostics: WardenDiagnostic[]
322
1535
  ): void => {
323
- const fnBody = (implValue as unknown as { body?: AstNode }).body;
1536
+ const fnBody = getNodeBodyNode(implValue);
324
1537
  if (!fnBody) {
325
1538
  return;
326
1539
  }
327
1540
 
1541
+ // Seed analysis with the implementation's own bindings so parameter names
1542
+ // and hoisted vars shadow namespace imports in both block and concise bodies.
1543
+ const implScope = collectScopeFrameBindings(implValue);
1544
+
328
1545
  if (fnBody.type === 'BlockStatement' || fnBody.type === 'FunctionBody') {
329
1546
  checkReturnStatements(
330
1547
  fnBody,
@@ -332,16 +1549,38 @@ const checkImplementation = (
332
1549
  filePath,
333
1550
  sourceCode,
334
1551
  helperNames,
335
- diagnostics
1552
+ namespaceHelpers,
1553
+ resultTypeNames,
1554
+ diagnostics,
1555
+ implScope
336
1556
  );
337
1557
  return;
338
1558
  }
339
1559
 
340
- if (!isResultExpression(fnBody) && !isHelperCall(fnBody, helperNames)) {
1560
+ const conciseScopes: readonly ReadonlySet<string>[] =
1561
+ implScope.size > 0 ? [implScope] : [];
1562
+ const isConciseResultBody = (node: AstNode): boolean => {
1563
+ const target = unwrapReturnExpression(node);
1564
+ if (target.type === 'ConditionalExpression') {
1565
+ const alternate = getNodeAlternate(target);
1566
+ const consequent = getNodeConsequent(target);
1567
+ return (
1568
+ consequent !== undefined &&
1569
+ alternate !== undefined &&
1570
+ isConciseResultBody(consequent) &&
1571
+ isConciseResultBody(alternate)
1572
+ );
1573
+ }
1574
+ return (
1575
+ isResultExpression(target) ||
1576
+ isHelperCall(target, helperNames, namespaceHelpers, conciseScopes)
1577
+ );
1578
+ };
1579
+ if (!isConciseResultBody(fnBody)) {
341
1580
  diagnostics.push({
342
1581
  filePath,
343
1582
  line: offsetToLine(sourceCode, implValue.start),
344
- message: `${info.label} "${info.id}" implementation must return Result.ok(...) or Result.err(...), not a raw value.`,
1583
+ message: buildUnrecognizedResultMessage(info.label, info.id),
345
1584
  rule: 'implementation-returns-result',
346
1585
  severity: 'error',
347
1586
  });
@@ -358,17 +1597,21 @@ const checkAllDefinitions = (
358
1597
  sourceCode: string
359
1598
  ): WardenDiagnostic[] => {
360
1599
  const diagnostics: WardenDiagnostic[] = [];
361
- const helperNames = collectResultHelperNames(ast, sourceCode);
1600
+ const helperNames = collectAllResultHelperNames(ast, sourceCode, filePath);
1601
+ const namespaceHelpers = collectNamespaceHelperImports(ast, filePath);
1602
+ const resultTypeNames = collectResultTypeNames(ast);
362
1603
 
363
1604
  for (const def of findTrailDefinitions(ast)) {
364
1605
  const info = { id: def.id, label: 'Trail' };
365
- for (const implValue of findRunBodies(def.config as AstNode)) {
1606
+ for (const implValue of findImplementationBodies(def.config as AstNode)) {
366
1607
  checkImplementation(
367
1608
  implValue,
368
1609
  info,
369
1610
  filePath,
370
1611
  sourceCode,
371
1612
  helperNames,
1613
+ namespaceHelpers,
1614
+ resultTypeNames,
372
1615
  diagnostics
373
1616
  );
374
1617
  }