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