@ontrails/warden 1.0.0-beta.2 → 1.0.0-beta.22
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 +508 -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
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
import { TrailsError, errorClasses } from '@ontrails/core';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
extractStringLiteral,
|
|
5
|
+
findConfigProperty,
|
|
6
|
+
findTrailDefinitions,
|
|
7
|
+
identifierName,
|
|
8
|
+
offsetToLine,
|
|
9
|
+
parse,
|
|
10
|
+
walk,
|
|
11
|
+
} from './ast.js';
|
|
12
|
+
import type { AstNode } from './ast.js';
|
|
13
|
+
import { isTestFile } from './scan.js';
|
|
14
|
+
import type { WardenDiagnostic, WardenRule } from './types.js';
|
|
15
|
+
|
|
16
|
+
interface ErrorTypeShape {
|
|
17
|
+
readonly name: string;
|
|
18
|
+
readonly prototype: TrailsError;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface DetourOnType {
|
|
22
|
+
readonly line: number;
|
|
23
|
+
readonly onType: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const knownErrorConstructors = new Map<string, ErrorTypeShape>([
|
|
27
|
+
[TrailsError.name, TrailsError],
|
|
28
|
+
...errorClasses.map(({ ctor, name }) => [name, ctor] as const),
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
const knownErrorParents = new Map<string, string | null>(
|
|
32
|
+
[...knownErrorConstructors.entries()].map(([name, ctor]) => {
|
|
33
|
+
const parent = Object.getPrototypeOf(ctor.prototype)?.constructor;
|
|
34
|
+
const parentName =
|
|
35
|
+
typeof parent?.name === 'string' &&
|
|
36
|
+
knownErrorConstructors.has(parent.name)
|
|
37
|
+
? parent.name
|
|
38
|
+
: null;
|
|
39
|
+
return [name, parentName];
|
|
40
|
+
})
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
const resolveKnownErrorName = (
|
|
44
|
+
name: string,
|
|
45
|
+
aliases: ReadonlyMap<string, string>
|
|
46
|
+
): string => aliases.get(name) ?? name;
|
|
47
|
+
|
|
48
|
+
const coreImportSource = (node: AstNode): string | null =>
|
|
49
|
+
extractStringLiteral((node as unknown as { source?: AstNode }).source);
|
|
50
|
+
|
|
51
|
+
const collectImportSpecifierAliases = (
|
|
52
|
+
specifiers: readonly AstNode[] | undefined,
|
|
53
|
+
aliases: Map<string, string>
|
|
54
|
+
): void => {
|
|
55
|
+
for (const specifier of specifiers ?? []) {
|
|
56
|
+
if (specifier.type !== 'ImportSpecifier') {
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const localName = identifierName(
|
|
61
|
+
(specifier as unknown as { local?: AstNode }).local
|
|
62
|
+
);
|
|
63
|
+
const importedName =
|
|
64
|
+
identifierName(
|
|
65
|
+
(specifier as unknown as { imported?: AstNode }).imported
|
|
66
|
+
) ?? localName;
|
|
67
|
+
|
|
68
|
+
if (localName && importedName && knownErrorConstructors.has(importedName)) {
|
|
69
|
+
aliases.set(localName, importedName);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const collectKnownErrorAliases = (
|
|
75
|
+
ast: AstNode
|
|
76
|
+
): ReadonlyMap<string, string> => {
|
|
77
|
+
const aliases = new Map<string, string>();
|
|
78
|
+
|
|
79
|
+
walk(ast, (node) => {
|
|
80
|
+
if (node.type !== 'ImportDeclaration') {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (coreImportSource(node) !== '@ontrails/core') {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const { specifiers } = node as unknown as {
|
|
89
|
+
specifiers?: readonly AstNode[];
|
|
90
|
+
};
|
|
91
|
+
collectImportSpecifierAliases(specifiers, aliases);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
return aliases;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const recordLocalErrorParent = (
|
|
98
|
+
parents: Map<string, string>,
|
|
99
|
+
aliases: ReadonlyMap<string, string>,
|
|
100
|
+
className: string | null,
|
|
101
|
+
parentName: string | null
|
|
102
|
+
): void => {
|
|
103
|
+
if (!className || !parentName) {
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
parents.set(className, resolveKnownErrorName(parentName, aliases));
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const collectClassExpressionParent = (
|
|
111
|
+
node: AstNode,
|
|
112
|
+
parents: Map<string, string>,
|
|
113
|
+
aliases: ReadonlyMap<string, string>
|
|
114
|
+
): void => {
|
|
115
|
+
if (node.type !== 'VariableDeclarator') {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const { init } = node as unknown as { init?: AstNode };
|
|
120
|
+
if (!init || init.type !== 'ClassExpression') {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const className = identifierName((node as unknown as { id?: AstNode }).id);
|
|
125
|
+
const parentName = identifierName(
|
|
126
|
+
(init as unknown as { superClass?: AstNode }).superClass
|
|
127
|
+
);
|
|
128
|
+
recordLocalErrorParent(parents, aliases, className, parentName);
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const collectLocalErrorParents = (
|
|
132
|
+
ast: AstNode,
|
|
133
|
+
aliases: ReadonlyMap<string, string>
|
|
134
|
+
): ReadonlyMap<string, string> => {
|
|
135
|
+
const parents = new Map<string, string>();
|
|
136
|
+
|
|
137
|
+
walk(ast, (node) => {
|
|
138
|
+
if (node.type === 'ClassDeclaration') {
|
|
139
|
+
const className = identifierName(
|
|
140
|
+
(node as unknown as { id?: AstNode }).id
|
|
141
|
+
);
|
|
142
|
+
const parentName = identifierName(
|
|
143
|
+
(node as unknown as { superClass?: AstNode }).superClass
|
|
144
|
+
);
|
|
145
|
+
recordLocalErrorParent(parents, aliases, className, parentName);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
collectClassExpressionParent(node, parents, aliases);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
return parents;
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Return the raw AST elements of a trail's `detours` array.
|
|
157
|
+
*
|
|
158
|
+
* @remarks
|
|
159
|
+
* Spread elements (`...baseDetours`) in the `detours` array are intentionally
|
|
160
|
+
* skipped here and by {@link extractDetourOnTypes}. This makes the ordering
|
|
161
|
+
* analysis best-effort for arrays that contain spreads: only literal inline
|
|
162
|
+
* detour object entries are ordering-checked, so spreads can cause both false
|
|
163
|
+
* negatives and false positives depending on where they sit relative to the
|
|
164
|
+
* literal entries.
|
|
165
|
+
*/
|
|
166
|
+
const getDetourElements = (config: AstNode): readonly (AstNode | null)[] => {
|
|
167
|
+
const detoursProp = findConfigProperty(config, 'detours');
|
|
168
|
+
if (!detoursProp) {
|
|
169
|
+
return [];
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const detoursValue = detoursProp.value as AstNode | undefined;
|
|
173
|
+
if (!detoursValue || detoursValue.type !== 'ArrayExpression') {
|
|
174
|
+
return [];
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const elements = (detoursValue as AstNode)['elements'] as
|
|
178
|
+
| readonly (AstNode | null)[]
|
|
179
|
+
| undefined;
|
|
180
|
+
return elements ?? [];
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
const extractDetourOnTypes = (
|
|
184
|
+
config: AstNode,
|
|
185
|
+
sourceCode: string,
|
|
186
|
+
aliases: ReadonlyMap<string, string>
|
|
187
|
+
): readonly DetourOnType[] =>
|
|
188
|
+
getDetourElements(config).flatMap((element) => {
|
|
189
|
+
if (!element || element.type !== 'ObjectExpression') {
|
|
190
|
+
return [];
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const onProp = findConfigProperty(element, 'on');
|
|
194
|
+
const onNode = onProp?.value as AstNode | undefined;
|
|
195
|
+
const onTypeName = identifierName(onNode);
|
|
196
|
+
if (!onNode || !onTypeName) {
|
|
197
|
+
return [];
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return [
|
|
201
|
+
{
|
|
202
|
+
line: offsetToLine(sourceCode, onNode.start),
|
|
203
|
+
onType: resolveKnownErrorName(onTypeName, aliases),
|
|
204
|
+
},
|
|
205
|
+
];
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
const nextParentType = (
|
|
209
|
+
errorType: string,
|
|
210
|
+
localParents: ReadonlyMap<string, string>
|
|
211
|
+
): string | null =>
|
|
212
|
+
localParents.get(errorType) ?? knownErrorParents.get(errorType) ?? null;
|
|
213
|
+
|
|
214
|
+
const isSameOrSubtype = (
|
|
215
|
+
candidate: string,
|
|
216
|
+
ancestor: string,
|
|
217
|
+
localParents: ReadonlyMap<string, string>
|
|
218
|
+
): boolean => {
|
|
219
|
+
let current: string | null = candidate;
|
|
220
|
+
const seen = new Set<string>();
|
|
221
|
+
|
|
222
|
+
while (current && !seen.has(current)) {
|
|
223
|
+
if (current === ancestor) {
|
|
224
|
+
return true;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
seen.add(current);
|
|
228
|
+
current = nextParentType(current, localParents);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return false;
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
const buildDiagnostic = (
|
|
235
|
+
trailId: string,
|
|
236
|
+
shadowedType: string,
|
|
237
|
+
shadowingType: string,
|
|
238
|
+
filePath: string,
|
|
239
|
+
line: number
|
|
240
|
+
): WardenDiagnostic => ({
|
|
241
|
+
filePath,
|
|
242
|
+
line,
|
|
243
|
+
message: `Trail "${trailId}" declares detour on "${shadowedType}" after earlier detour on "${shadowingType}". Because "${shadowingType}" matches "${shadowedType}" first, the later detour is unreachable.`,
|
|
244
|
+
rule: 'unreachable-detour-shadowing',
|
|
245
|
+
severity: 'error',
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
const findShadowingDetour = (
|
|
249
|
+
detours: readonly DetourOnType[],
|
|
250
|
+
index: number,
|
|
251
|
+
localParents: ReadonlyMap<string, string>
|
|
252
|
+
): DetourOnType | null => {
|
|
253
|
+
const detour = detours[index];
|
|
254
|
+
if (!detour) {
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
for (let previousIndex = 0; previousIndex < index; previousIndex += 1) {
|
|
259
|
+
const previous = detours[previousIndex];
|
|
260
|
+
if (
|
|
261
|
+
previous &&
|
|
262
|
+
isSameOrSubtype(detour.onType, previous.onType, localParents)
|
|
263
|
+
) {
|
|
264
|
+
return previous;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return null;
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
const buildTrailDiagnostics = (
|
|
272
|
+
trailId: string,
|
|
273
|
+
detours: readonly DetourOnType[],
|
|
274
|
+
filePath: string,
|
|
275
|
+
localParents: ReadonlyMap<string, string>
|
|
276
|
+
): readonly WardenDiagnostic[] => {
|
|
277
|
+
const diagnostics: WardenDiagnostic[] = [];
|
|
278
|
+
|
|
279
|
+
for (let index = 1; index < detours.length; index += 1) {
|
|
280
|
+
const detour = detours[index];
|
|
281
|
+
const shadowing = findShadowingDetour(detours, index, localParents);
|
|
282
|
+
if (!detour || !shadowing) {
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
diagnostics.push(
|
|
287
|
+
buildDiagnostic(
|
|
288
|
+
trailId,
|
|
289
|
+
detour.onType,
|
|
290
|
+
shadowing.onType,
|
|
291
|
+
filePath,
|
|
292
|
+
detour.line
|
|
293
|
+
)
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return diagnostics;
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
const buildDiagnostics = (
|
|
301
|
+
ast: AstNode,
|
|
302
|
+
sourceCode: string,
|
|
303
|
+
filePath: string
|
|
304
|
+
): readonly WardenDiagnostic[] => {
|
|
305
|
+
const aliases = collectKnownErrorAliases(ast);
|
|
306
|
+
const localParents = collectLocalErrorParents(ast, aliases);
|
|
307
|
+
const diagnostics: WardenDiagnostic[] = [];
|
|
308
|
+
|
|
309
|
+
for (const definition of findTrailDefinitions(ast)) {
|
|
310
|
+
if (definition.kind !== 'trail') {
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
diagnostics.push(
|
|
315
|
+
...buildTrailDiagnostics(
|
|
316
|
+
definition.id,
|
|
317
|
+
extractDetourOnTypes(definition.config, sourceCode, aliases),
|
|
318
|
+
filePath,
|
|
319
|
+
localParents
|
|
320
|
+
)
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
return diagnostics;
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
export const unreachableDetourShadowing: WardenRule = {
|
|
328
|
+
check(sourceCode: string, filePath: string): readonly WardenDiagnostic[] {
|
|
329
|
+
if (isTestFile(filePath)) {
|
|
330
|
+
return [];
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const ast = parse(filePath, sourceCode);
|
|
334
|
+
if (!ast) {
|
|
335
|
+
return [];
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
return buildDiagnostics(ast, sourceCode, filePath);
|
|
339
|
+
},
|
|
340
|
+
description:
|
|
341
|
+
'Detect later detours whose on: error type is already matched by an earlier same or broader detour.',
|
|
342
|
+
name: 'unreachable-detour-shadowing',
|
|
343
|
+
severity: 'error',
|
|
344
|
+
};
|
|
@@ -1,13 +1,17 @@
|
|
|
1
|
+
import {
|
|
2
|
+
extractStringOrTemplateLiteral,
|
|
3
|
+
offsetToLine,
|
|
4
|
+
parse,
|
|
5
|
+
walk,
|
|
6
|
+
} from './ast.js';
|
|
7
|
+
import type { AstNode } from './ast.js';
|
|
8
|
+
import { isTestFile } from './scan.js';
|
|
9
|
+
import { collectTrailIds } from './specs.js';
|
|
1
10
|
import type {
|
|
2
11
|
ProjectAwareWardenRule,
|
|
3
12
|
ProjectContext,
|
|
4
13
|
WardenDiagnostic,
|
|
5
14
|
} from './types.js';
|
|
6
|
-
import { isTestFile } from './scan.js';
|
|
7
|
-
import { collectTrailIds, parseStringLiteral } from './specs.js';
|
|
8
|
-
import { captureBalanced, lineNumberAt } from './structure.js';
|
|
9
|
-
|
|
10
|
-
const DESCRIBE_PATTERN = /\.describe\s*\(/g;
|
|
11
15
|
|
|
12
16
|
const SEE_PATTERN = /@see\s+([A-Za-z0-9_.-]+)/g;
|
|
13
17
|
|
|
@@ -16,41 +20,160 @@ interface DescribeRef {
|
|
|
16
20
|
readonly ref: string;
|
|
17
21
|
}
|
|
18
22
|
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
const STRING_LITERAL_ARG_TYPES: ReadonlySet<string> = new Set([
|
|
24
|
+
'Literal',
|
|
25
|
+
'StringLiteral',
|
|
26
|
+
'TemplateLiteral',
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
const MEMBER_CALLEE_TYPES: ReadonlySet<string> = new Set([
|
|
30
|
+
'MemberExpression',
|
|
31
|
+
'StaticMemberExpression',
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
const isDescribeMemberCallee = (callee: AstNode | undefined): boolean => {
|
|
35
|
+
if (!callee || !MEMBER_CALLEE_TYPES.has(callee.type)) {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
const prop = (callee as unknown as { property?: AstNode }).property;
|
|
39
|
+
return (
|
|
40
|
+
prop?.type === 'Identifier' &&
|
|
41
|
+
(prop as unknown as { name?: string }).name === 'describe'
|
|
42
|
+
);
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const hasStringLiteralFirstArg = (node: AstNode): boolean => {
|
|
46
|
+
const args = node['arguments'] as readonly AstNode[] | undefined;
|
|
47
|
+
const firstArg = args?.[0];
|
|
48
|
+
return !!firstArg && STRING_LITERAL_ARG_TYPES.has(firstArg.type);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const isDescribeCall = (node: AstNode): boolean => {
|
|
52
|
+
if (node.type !== 'CallExpression') {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
if (!isDescribeMemberCallee(node['callee'] as AstNode | undefined)) {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
// Narrow to calls whose first argument is a string/template literal.
|
|
59
|
+
// Filters out RxJS-style `.describe(fn)` and other custom APIs whose
|
|
60
|
+
// `.describe()` overloads take non-string arguments. Zod's shape always
|
|
61
|
+
// passes a string literal here.
|
|
62
|
+
return hasStringLiteralFirstArg(node);
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Extract scannable text from a template literal, even when it contains
|
|
67
|
+
* `${...}` expressions. Concatenates the cooked quasi chunks with a NUL
|
|
68
|
+
* sentinel between them — interpolated values are runtime-only and cannot
|
|
69
|
+
* contribute static `@see` tokens, but the surrounding quasi text can. The
|
|
70
|
+
* sentinel prevents phantom tokens that would otherwise appear when a quasi
|
|
71
|
+
* boundary splits the `@see` marker itself (e.g. `\`@s${x}ee missing\``
|
|
72
|
+
* would naively join to `"@seemissing"` and match `@see`).
|
|
73
|
+
*
|
|
74
|
+
* This is intentionally describe-local: the shared
|
|
75
|
+
* {@link extractStringOrTemplateLiteral} helper preserves "plain template
|
|
76
|
+
* literal only" semantics for other rules (e.g. resolving trail/signal IDs)
|
|
77
|
+
* that require a single clean string value. Here we only need to scan for
|
|
78
|
+
* `@see` tokens, so concatenating quasi cooked text is sound.
|
|
79
|
+
*
|
|
80
|
+
* @remarks
|
|
81
|
+
* A quasi's `cooked` value can be `null` in tagged-template positions where
|
|
82
|
+
* the literal contains escape sequences the parser can't decode. `.describe`
|
|
83
|
+
* is a plain method call, not a tagged template, so in practice its quasis
|
|
84
|
+
* always have a `cooked` string today. The `raw` fallback is defensive: if a
|
|
85
|
+
* future refactor wraps `describe(\`...\`)` in a tagged template, we still
|
|
86
|
+
* scan the raw source rather than silently dropping the quasi text and
|
|
87
|
+
* missing an `@see` token.
|
|
88
|
+
*/
|
|
89
|
+
const extractQuasiText = (quasi: AstNode): string | null => {
|
|
90
|
+
const { value } = quasi as unknown as {
|
|
91
|
+
value?: { cooked?: unknown; raw?: unknown };
|
|
92
|
+
};
|
|
93
|
+
if (typeof value?.cooked === 'string') {
|
|
94
|
+
return value.cooked;
|
|
95
|
+
}
|
|
96
|
+
if (typeof value?.raw === 'string') {
|
|
97
|
+
return value.raw;
|
|
98
|
+
}
|
|
99
|
+
return null;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const extractTemplateLiteralQuasiText = (node: AstNode): string | null => {
|
|
103
|
+
if (node.type !== 'TemplateLiteral') {
|
|
25
104
|
return null;
|
|
26
105
|
}
|
|
106
|
+
const quasis = (node['quasis'] as readonly AstNode[] | undefined) ?? [];
|
|
107
|
+
const parts: string[] = [];
|
|
108
|
+
for (const quasi of quasis) {
|
|
109
|
+
const text = extractQuasiText(quasi);
|
|
110
|
+
if (text !== null) {
|
|
111
|
+
parts.push(text);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
// Use a NUL sentinel (not a letter / ref character) so interpolation
|
|
115
|
+
// boundaries cannot silently fuse neighbouring quasis into a phantom
|
|
116
|
+
// `@see <ident>` match. `\u0000` cannot appear inside a valid trail ID,
|
|
117
|
+
// so it safely terminates any partial token on either side.
|
|
118
|
+
return parts.join('\u0000');
|
|
119
|
+
};
|
|
27
120
|
|
|
28
|
-
|
|
121
|
+
const extractDescribeDescription = (node: AstNode): string | null => {
|
|
122
|
+
const args = node['arguments'] as readonly AstNode[] | undefined;
|
|
123
|
+
const [firstArg] = args ?? [];
|
|
124
|
+
if (!firstArg) {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
return (
|
|
128
|
+
extractStringOrTemplateLiteral(firstArg) ??
|
|
129
|
+
extractTemplateLiteralQuasiText(firstArg)
|
|
130
|
+
);
|
|
29
131
|
};
|
|
30
132
|
|
|
31
|
-
|
|
133
|
+
/**
|
|
134
|
+
* Anchor the diagnostic on the string argument that actually contains the
|
|
135
|
+
* `@see` token, not on the call-expression start. For multi-line schema
|
|
136
|
+
* chains, the call-expression start can be many lines above the describe
|
|
137
|
+
* argument, which confuses editor tooling.
|
|
138
|
+
*/
|
|
139
|
+
const describeAnchorOffset = (node: AstNode): number => {
|
|
140
|
+
const args = node['arguments'] as readonly AstNode[] | undefined;
|
|
141
|
+
return args?.[0]?.start ?? node.start;
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const collectRefsFromDescription = (
|
|
32
145
|
description: string,
|
|
33
|
-
line: number
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
146
|
+
line: number,
|
|
147
|
+
out: DescribeRef[]
|
|
148
|
+
): void => {
|
|
149
|
+
for (const match of description.matchAll(SEE_PATTERN)) {
|
|
150
|
+
const [, ref] = match;
|
|
151
|
+
if (ref) {
|
|
152
|
+
out.push({ line, ref });
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
};
|
|
38
156
|
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
157
|
+
const collectDescribeRefs = (
|
|
158
|
+
ast: AstNode,
|
|
159
|
+
sourceCode: string
|
|
42
160
|
): readonly DescribeRef[] => {
|
|
43
|
-
const
|
|
44
|
-
const description = args ? parseStringLiteral(args) : null;
|
|
45
|
-
return description === null
|
|
46
|
-
? []
|
|
47
|
-
: refsInDescription(description, lineNumberAt(sourceCode, matchIndex));
|
|
48
|
-
};
|
|
161
|
+
const refs: DescribeRef[] = [];
|
|
49
162
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
163
|
+
walk(ast, (node) => {
|
|
164
|
+
if (!isDescribeCall(node)) {
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
const description = extractDescribeDescription(node);
|
|
168
|
+
if (description === null) {
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
const line = offsetToLine(sourceCode, describeAnchorOffset(node));
|
|
172
|
+
collectRefsFromDescription(description, line, refs);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
return refs;
|
|
176
|
+
};
|
|
54
177
|
|
|
55
178
|
const checkDescribeRefs = (
|
|
56
179
|
sourceCode: string,
|
|
@@ -61,7 +184,12 @@ const checkDescribeRefs = (
|
|
|
61
184
|
return [];
|
|
62
185
|
}
|
|
63
186
|
|
|
64
|
-
|
|
187
|
+
const ast = parse(filePath, sourceCode);
|
|
188
|
+
if (!ast) {
|
|
189
|
+
return [];
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return collectDescribeRefs(ast, sourceCode)
|
|
65
193
|
.filter(({ ref }) => !knownTrailIds.has(ref))
|
|
66
194
|
.map(({ line, ref }) => ({
|
|
67
195
|
filePath,
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { Topo } from '@ontrails/core';
|
|
2
|
+
|
|
3
|
+
import type { TopoAwareWardenRule, WardenDiagnostic } from './types.js';
|
|
4
|
+
|
|
5
|
+
interface DetourLike {
|
|
6
|
+
readonly on?: unknown;
|
|
7
|
+
readonly recover?: unknown;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const isErrorConstructor = (
|
|
11
|
+
value: unknown
|
|
12
|
+
): value is abstract new (...args: never[]) => Error => {
|
|
13
|
+
if (typeof value !== 'function') {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const { prototype } = value as { prototype?: unknown };
|
|
18
|
+
return prototype instanceof Error;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const describeOnValue = (value: unknown): string => {
|
|
22
|
+
if (typeof value === 'function') {
|
|
23
|
+
const { name } = value as { name?: unknown };
|
|
24
|
+
return typeof name === 'string' && name.length > 0
|
|
25
|
+
? name
|
|
26
|
+
: '<anonymous constructor>';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return String(value);
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const buildDiagnostic = (message: string, rule: string): WardenDiagnostic => ({
|
|
33
|
+
filePath: '<topo>',
|
|
34
|
+
line: 1,
|
|
35
|
+
message,
|
|
36
|
+
rule,
|
|
37
|
+
severity: 'error',
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const collectTrailDiagnostics = (topo: Topo): readonly WardenDiagnostic[] => {
|
|
41
|
+
const diagnostics: WardenDiagnostic[] = [];
|
|
42
|
+
|
|
43
|
+
for (const trail of topo.trails.values()) {
|
|
44
|
+
for (const [index, detour] of trail.detours.entries()) {
|
|
45
|
+
const candidate = detour as DetourLike;
|
|
46
|
+
|
|
47
|
+
if (!isErrorConstructor(candidate.on)) {
|
|
48
|
+
diagnostics.push(
|
|
49
|
+
buildDiagnostic(
|
|
50
|
+
`Trail "${trail.id}" detour[${index}] must declare an error constructor in on:. Received ${describeOnValue(candidate.on)}.`,
|
|
51
|
+
'valid-detour-contract'
|
|
52
|
+
)
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (typeof candidate.recover !== 'function') {
|
|
57
|
+
diagnostics.push(
|
|
58
|
+
buildDiagnostic(
|
|
59
|
+
`Trail "${trail.id}" detour[${index}] must declare a callable recover function. Expected recover: (attempt, ctx) => Promise<Result<...>>; inspect attempt.error for the matched error and return Result.err(...) for unrecoverable cases.`,
|
|
60
|
+
'valid-detour-contract'
|
|
61
|
+
)
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return diagnostics;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export const validDetourContract: TopoAwareWardenRule = {
|
|
71
|
+
checkTopo(topo: Topo): readonly WardenDiagnostic[] {
|
|
72
|
+
return collectTrailDiagnostics(topo);
|
|
73
|
+
},
|
|
74
|
+
description:
|
|
75
|
+
'Ensure detours use real error constructors and callable recover functions.',
|
|
76
|
+
name: 'valid-detour-contract',
|
|
77
|
+
severity: 'error',
|
|
78
|
+
};
|