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