@ontrails/warden 0.2.0

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