@ontrails/warden 1.0.0-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -0
- package/.turbo/turbo-lint.log +3 -0
- package/.turbo/turbo-typecheck.log +1 -0
- package/CHANGELOG.md +21 -0
- package/README.md +132 -0
- package/dist/cli.d.ts +46 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +221 -0
- package/dist/cli.js.map +1 -0
- package/dist/drift.d.ts +26 -0
- package/dist/drift.d.ts.map +1 -0
- package/dist/drift.js +27 -0
- package/dist/drift.js.map +1 -0
- package/dist/formatters.d.ts +29 -0
- package/dist/formatters.d.ts.map +1 -0
- package/dist/formatters.js +87 -0
- package/dist/formatters.js.map +1 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +26 -0
- package/dist/index.js.map +1 -0
- package/dist/rules/ast.d.ts +41 -0
- package/dist/rules/ast.d.ts.map +1 -0
- package/dist/rules/ast.js +163 -0
- package/dist/rules/ast.js.map +1 -0
- package/dist/rules/context-no-surface-types.d.ts +12 -0
- package/dist/rules/context-no-surface-types.d.ts.map +1 -0
- package/dist/rules/context-no-surface-types.js +96 -0
- package/dist/rules/context-no-surface-types.js.map +1 -0
- package/dist/rules/implementation-returns-result.d.ts +13 -0
- package/dist/rules/implementation-returns-result.d.ts.map +1 -0
- package/dist/rules/implementation-returns-result.js +231 -0
- package/dist/rules/implementation-returns-result.js.map +1 -0
- package/dist/rules/index.d.ts +22 -0
- package/dist/rules/index.d.ts.map +1 -0
- package/dist/rules/index.js +41 -0
- package/dist/rules/index.js.map +1 -0
- package/dist/rules/no-direct-impl-in-route.d.ts +12 -0
- package/dist/rules/no-direct-impl-in-route.d.ts.map +1 -0
- package/dist/rules/no-direct-impl-in-route.js +46 -0
- package/dist/rules/no-direct-impl-in-route.js.map +1 -0
- package/dist/rules/no-direct-implementation-call.d.ts +12 -0
- package/dist/rules/no-direct-implementation-call.d.ts.map +1 -0
- package/dist/rules/no-direct-implementation-call.js +39 -0
- package/dist/rules/no-direct-implementation-call.js.map +1 -0
- package/dist/rules/no-sync-result-assumption.d.ts +6 -0
- package/dist/rules/no-sync-result-assumption.d.ts.map +1 -0
- package/dist/rules/no-sync-result-assumption.js +98 -0
- package/dist/rules/no-sync-result-assumption.js.map +1 -0
- package/dist/rules/no-throw-in-detour-target.d.ts +12 -0
- package/dist/rules/no-throw-in-detour-target.d.ts.map +1 -0
- package/dist/rules/no-throw-in-detour-target.js +87 -0
- package/dist/rules/no-throw-in-detour-target.js.map +1 -0
- package/dist/rules/no-throw-in-implementation.d.ts +9 -0
- package/dist/rules/no-throw-in-implementation.d.ts.map +1 -0
- package/dist/rules/no-throw-in-implementation.js +34 -0
- package/dist/rules/no-throw-in-implementation.js.map +1 -0
- package/dist/rules/prefer-schema-inference.d.ts +7 -0
- package/dist/rules/prefer-schema-inference.d.ts.map +1 -0
- package/dist/rules/prefer-schema-inference.js +86 -0
- package/dist/rules/prefer-schema-inference.js.map +1 -0
- package/dist/rules/scan.d.ts +8 -0
- package/dist/rules/scan.d.ts.map +1 -0
- package/dist/rules/scan.js +32 -0
- package/dist/rules/scan.js.map +1 -0
- package/dist/rules/specs.d.ts +29 -0
- package/dist/rules/specs.d.ts.map +1 -0
- package/dist/rules/specs.js +192 -0
- package/dist/rules/specs.js.map +1 -0
- package/dist/rules/structure.d.ts +13 -0
- package/dist/rules/structure.d.ts.map +1 -0
- package/dist/rules/structure.js +142 -0
- package/dist/rules/structure.js.map +1 -0
- package/dist/rules/types.d.ts +52 -0
- package/dist/rules/types.d.ts.map +1 -0
- package/dist/rules/types.js +2 -0
- package/dist/rules/types.js.map +1 -0
- package/dist/rules/valid-describe-refs.d.ts +7 -0
- package/dist/rules/valid-describe-refs.d.ts.map +1 -0
- package/dist/rules/valid-describe-refs.js +51 -0
- package/dist/rules/valid-describe-refs.js.map +1 -0
- package/dist/rules/valid-detour-refs.d.ts +6 -0
- package/dist/rules/valid-detour-refs.d.ts.map +1 -0
- package/dist/rules/valid-detour-refs.js +116 -0
- package/dist/rules/valid-detour-refs.js.map +1 -0
- package/package.json +25 -0
- package/src/__tests__/cli.test.ts +198 -0
- package/src/__tests__/drift.test.ts +74 -0
- package/src/__tests__/formatters.test.ts +157 -0
- package/src/__tests__/implementation-returns-result.test.ts +75 -0
- package/src/__tests__/no-direct-implementation-call.test.ts +83 -0
- package/src/__tests__/no-sync-result-assumption.test.ts +85 -0
- package/src/__tests__/no-throw-in-detour-target.test.ts +78 -0
- package/src/__tests__/prefer-schema-inference.test.ts +84 -0
- package/src/__tests__/rules.test.ts +188 -0
- package/src/__tests__/valid-describe-refs.test.ts +60 -0
- package/src/cli.ts +343 -0
- package/src/drift.ts +50 -0
- package/src/formatters.ts +113 -0
- package/src/index.ts +47 -0
- package/src/rules/ast.ts +217 -0
- package/src/rules/context-no-surface-types.ts +150 -0
- package/src/rules/implementation-returns-result.ts +343 -0
- package/src/rules/index.ts +54 -0
- package/src/rules/no-direct-impl-in-route.ts +77 -0
- package/src/rules/no-direct-implementation-call.ts +47 -0
- package/src/rules/no-sync-result-assumption.ts +156 -0
- package/src/rules/no-throw-in-detour-target.ts +150 -0
- package/src/rules/no-throw-in-implementation.ts +41 -0
- package/src/rules/prefer-schema-inference.ts +141 -0
- package/src/rules/scan.ts +46 -0
- package/src/rules/specs.ts +384 -0
- package/src/rules/structure.ts +234 -0
- package/src/rules/types.ts +62 -0
- package/src/rules/valid-describe-refs.ts +94 -0
- package/src/rules/valid-detour-refs.ts +187 -0
- package/tsconfig.json +9 -0
- package/tsconfig.tsbuildinfo +1 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { contextNoSurfaceTypes } from './context-no-surface-types.js';
|
|
2
|
+
import { implementationReturnsResult } from './implementation-returns-result.js';
|
|
3
|
+
import { noDirectImplInRoute } from './no-direct-impl-in-route.js';
|
|
4
|
+
import { noDirectImplementationCall } from './no-direct-implementation-call.js';
|
|
5
|
+
import { noSyncResultAssumption } from './no-sync-result-assumption.js';
|
|
6
|
+
import { noThrowInDetourTarget } from './no-throw-in-detour-target.js';
|
|
7
|
+
import { noThrowInImplementation } from './no-throw-in-implementation.js';
|
|
8
|
+
import { preferSchemaInference } from './prefer-schema-inference.js';
|
|
9
|
+
import type { WardenRule } from './types.js';
|
|
10
|
+
import { validDescribeRefs } from './valid-describe-refs.js';
|
|
11
|
+
import { validDetourRefs } from './valid-detour-refs.js';
|
|
12
|
+
|
|
13
|
+
export type {
|
|
14
|
+
ProjectAwareWardenRule,
|
|
15
|
+
ProjectContext,
|
|
16
|
+
WardenDiagnostic,
|
|
17
|
+
WardenRule,
|
|
18
|
+
WardenSeverity,
|
|
19
|
+
} from './types.js';
|
|
20
|
+
|
|
21
|
+
export { noThrowInImplementation } from './no-throw-in-implementation.js';
|
|
22
|
+
export { contextNoSurfaceTypes } from './context-no-surface-types.js';
|
|
23
|
+
export { validDetourRefs } from './valid-detour-refs.js';
|
|
24
|
+
export { noDirectImplInRoute } from './no-direct-impl-in-route.js';
|
|
25
|
+
export { noDirectImplementationCall } from './no-direct-implementation-call.js';
|
|
26
|
+
export { noSyncResultAssumption } from './no-sync-result-assumption.js';
|
|
27
|
+
export { implementationReturnsResult } from './implementation-returns-result.js';
|
|
28
|
+
export { noThrowInDetourTarget } from './no-throw-in-detour-target.js';
|
|
29
|
+
export { preferSchemaInference } from './prefer-schema-inference.js';
|
|
30
|
+
export { validDescribeRefs } from './valid-describe-refs.js';
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* All built-in warden rules, keyed by rule name.
|
|
34
|
+
*
|
|
35
|
+
* Rules that duplicate validateTopo checks (follows-trails-exist,
|
|
36
|
+
* no-recursive-follows, event-origins-exist, examples-match-schema,
|
|
37
|
+
* require-output-schema) and follows-matches-calls (now covered by
|
|
38
|
+
* testExamples follows coverage) have been removed.
|
|
39
|
+
*/
|
|
40
|
+
export const wardenRules: ReadonlyMap<string, WardenRule> = new Map<
|
|
41
|
+
string,
|
|
42
|
+
WardenRule
|
|
43
|
+
>([
|
|
44
|
+
[noThrowInImplementation.name, noThrowInImplementation],
|
|
45
|
+
[contextNoSurfaceTypes.name, contextNoSurfaceTypes],
|
|
46
|
+
[preferSchemaInference.name, preferSchemaInference],
|
|
47
|
+
[validDescribeRefs.name, validDescribeRefs],
|
|
48
|
+
[validDetourRefs.name, validDetourRefs],
|
|
49
|
+
[noDirectImplementationCall.name, noDirectImplementationCall],
|
|
50
|
+
[noSyncResultAssumption.name, noSyncResultAssumption],
|
|
51
|
+
[implementationReturnsResult.name, implementationReturnsResult],
|
|
52
|
+
[noThrowInDetourTarget.name, noThrowInDetourTarget],
|
|
53
|
+
[noDirectImplInRoute.name, noDirectImplInRoute],
|
|
54
|
+
]);
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detects hike implementations that call `.implementation()` directly.
|
|
3
|
+
*
|
|
4
|
+
* Uses AST parsing to find hike definition bodies and check for
|
|
5
|
+
* `.implementation()` call expressions.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
findImplementationBodies,
|
|
10
|
+
findTrailDefinitions,
|
|
11
|
+
isImplementationCall,
|
|
12
|
+
offsetToLine,
|
|
13
|
+
parse,
|
|
14
|
+
walk,
|
|
15
|
+
} from './ast.js';
|
|
16
|
+
import type { WardenDiagnostic, WardenRule } from './types.js';
|
|
17
|
+
|
|
18
|
+
interface AstNode {
|
|
19
|
+
readonly type: string;
|
|
20
|
+
readonly start: number;
|
|
21
|
+
readonly end: number;
|
|
22
|
+
readonly [key: string]: unknown;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const findImplCallsInHike = (
|
|
26
|
+
def: { readonly config: AstNode },
|
|
27
|
+
filePath: string,
|
|
28
|
+
sourceCode: string,
|
|
29
|
+
diagnostics: WardenDiagnostic[]
|
|
30
|
+
): void => {
|
|
31
|
+
for (const body of findImplementationBodies(def.config as AstNode)) {
|
|
32
|
+
walk(body, (node) => {
|
|
33
|
+
if (isImplementationCall(node as AstNode)) {
|
|
34
|
+
diagnostics.push({
|
|
35
|
+
filePath,
|
|
36
|
+
line: offsetToLine(sourceCode, node.start),
|
|
37
|
+
message:
|
|
38
|
+
'Use ctx.follow("trailId", input) instead of direct .implementation() calls. ctx.follow() validates input and propagates tracing.',
|
|
39
|
+
rule: 'no-direct-impl-in-route',
|
|
40
|
+
severity: 'warn',
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Detects routes that call another trail's `.implementation()` directly.
|
|
49
|
+
*/
|
|
50
|
+
export const noDirectImplInRoute: WardenRule = {
|
|
51
|
+
check(sourceCode: string, filePath: string): readonly WardenDiagnostic[] {
|
|
52
|
+
if (!/\bhike\s*\(/.test(sourceCode)) {
|
|
53
|
+
return [];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const ast = parse(filePath, sourceCode);
|
|
57
|
+
if (!ast) {
|
|
58
|
+
return [];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const diagnostics: WardenDiagnostic[] = [];
|
|
62
|
+
const hikeDefs = findTrailDefinitions(ast as AstNode).filter(
|
|
63
|
+
(d) => d.kind === 'hike'
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
for (const def of hikeDefs) {
|
|
67
|
+
findImplCallsInHike(def, filePath, sourceCode, diagnostics);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return diagnostics;
|
|
71
|
+
},
|
|
72
|
+
description:
|
|
73
|
+
'Prefer ctx.follow() over direct .implementation() calls in route bodies.',
|
|
74
|
+
name: 'no-direct-impl-in-route',
|
|
75
|
+
|
|
76
|
+
severity: 'warn',
|
|
77
|
+
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Flags direct `.implementation()` calls in application code.
|
|
3
|
+
*
|
|
4
|
+
* Uses AST parsing to find `.implementation()` call expressions,
|
|
5
|
+
* ignoring occurrences in strings and comments.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { isImplementationCall, offsetToLine, parse, walk } from './ast.js';
|
|
9
|
+
import { isFrameworkInternalFile, isTestFile } from './scan.js';
|
|
10
|
+
import type { WardenDiagnostic, WardenRule } from './types.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Flags direct `.implementation()` calls in application code.
|
|
14
|
+
*/
|
|
15
|
+
export const noDirectImplementationCall: WardenRule = {
|
|
16
|
+
check(sourceCode: string, filePath: string): readonly WardenDiagnostic[] {
|
|
17
|
+
if (isTestFile(filePath) || isFrameworkInternalFile(filePath)) {
|
|
18
|
+
return [];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const ast = parse(filePath, sourceCode);
|
|
22
|
+
if (!ast) {
|
|
23
|
+
return [];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const diagnostics: WardenDiagnostic[] = [];
|
|
27
|
+
|
|
28
|
+
walk(ast, (node) => {
|
|
29
|
+
if (isImplementationCall(node)) {
|
|
30
|
+
diagnostics.push({
|
|
31
|
+
filePath,
|
|
32
|
+
line: offsetToLine(sourceCode, node.start),
|
|
33
|
+
message:
|
|
34
|
+
'Use ctx.follow("trailId", input) instead of direct .implementation() calls. Direct implementation access bypasses validation, tracing, and layers.',
|
|
35
|
+
rule: 'no-direct-implementation-call',
|
|
36
|
+
severity: 'warn',
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
return diagnostics;
|
|
42
|
+
},
|
|
43
|
+
description:
|
|
44
|
+
'Disallow direct .implementation() calls in application code. Use ctx.follow() instead.',
|
|
45
|
+
name: 'no-direct-implementation-call',
|
|
46
|
+
severity: 'warn',
|
|
47
|
+
};
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import type { WardenDiagnostic, WardenRule } from './types.js';
|
|
2
|
+
import {
|
|
3
|
+
isFrameworkInternalFile,
|
|
4
|
+
isTestFile,
|
|
5
|
+
stripQuotedContent,
|
|
6
|
+
} from './scan.js';
|
|
7
|
+
|
|
8
|
+
const RESULT_ACCESS_PATTERN =
|
|
9
|
+
/\.(?:isOk|isErr|match|map)\s*\(|\.(?:value|error)\b/;
|
|
10
|
+
const IMPLEMENTATION_CALL_PATTERN = /\.implementation\s*\(/;
|
|
11
|
+
|
|
12
|
+
const isAwaitedImplementationCall = (line: string): boolean => {
|
|
13
|
+
const callIndex = line.indexOf('.implementation(');
|
|
14
|
+
if (callIndex === -1) {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const awaitIndex = line.indexOf('await');
|
|
19
|
+
return awaitIndex !== -1 && awaitIndex < callIndex;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const isDirectResultAccess = (line: string): boolean =>
|
|
23
|
+
IMPLEMENTATION_CALL_PATTERN.test(line) &&
|
|
24
|
+
RESULT_ACCESS_PATTERN.test(line) &&
|
|
25
|
+
!isAwaitedImplementationCall(line);
|
|
26
|
+
|
|
27
|
+
const isPendingUse = (line: string, variableName: string): boolean => {
|
|
28
|
+
const escaped = variableName.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
29
|
+
const pendingPattern = new RegExp(
|
|
30
|
+
`\\b${escaped}\\s*(?:\\.(?:isOk|isErr|match|map)\\s*\\(|\\.(?:value|error)\\b)`
|
|
31
|
+
);
|
|
32
|
+
return pendingPattern.test(line);
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
interface PendingCall {
|
|
36
|
+
line: number;
|
|
37
|
+
remainingLines: number;
|
|
38
|
+
variableName: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const MISSING_AWAIT_MESSAGE =
|
|
42
|
+
'Missing await: .implementation() returns Promise<Result> after normalization. Use `const result = await trail.implementation(input, ctx)`.';
|
|
43
|
+
|
|
44
|
+
const createMissingAwaitDiagnostic = (
|
|
45
|
+
filePath: string,
|
|
46
|
+
line: number
|
|
47
|
+
): WardenDiagnostic => ({
|
|
48
|
+
filePath,
|
|
49
|
+
line,
|
|
50
|
+
message: MISSING_AWAIT_MESSAGE,
|
|
51
|
+
rule: 'no-sync-result-assumption',
|
|
52
|
+
severity: 'error',
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const trackPendingCall = (line: string): string | undefined => {
|
|
56
|
+
const match = line.match(
|
|
57
|
+
/\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*([^;]*)/
|
|
58
|
+
);
|
|
59
|
+
if (!match?.[1] || !match[2] || !IMPLEMENTATION_CALL_PATTERN.test(match[2])) {
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (isAwaitedImplementationCall(match[2])) {
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return match[1];
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const addPendingCall = (
|
|
71
|
+
pendingCalls: PendingCall[],
|
|
72
|
+
variableName: string,
|
|
73
|
+
lineNumber: number
|
|
74
|
+
): void => {
|
|
75
|
+
pendingCalls.push({
|
|
76
|
+
line: lineNumber,
|
|
77
|
+
remainingLines: 6,
|
|
78
|
+
variableName,
|
|
79
|
+
});
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const advancePendingCalls = (
|
|
83
|
+
line: string,
|
|
84
|
+
filePath: string,
|
|
85
|
+
lineNumber: number,
|
|
86
|
+
pendingCalls: PendingCall[],
|
|
87
|
+
diagnostics: WardenDiagnostic[]
|
|
88
|
+
): void => {
|
|
89
|
+
for (let j = pendingCalls.length - 1; j >= 0; j -= 1) {
|
|
90
|
+
const pendingCall = pendingCalls[j];
|
|
91
|
+
if (pendingCall && isPendingUse(line, pendingCall.variableName)) {
|
|
92
|
+
diagnostics.push(createMissingAwaitDiagnostic(filePath, lineNumber));
|
|
93
|
+
pendingCalls.splice(j, 1);
|
|
94
|
+
} else if (pendingCall) {
|
|
95
|
+
pendingCall.remainingLines -= 1;
|
|
96
|
+
if (pendingCall.remainingLines <= 0) {
|
|
97
|
+
pendingCalls.splice(j, 1);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const processLine = (
|
|
104
|
+
line: string,
|
|
105
|
+
filePath: string,
|
|
106
|
+
lineNumber: number,
|
|
107
|
+
pendingCalls: PendingCall[],
|
|
108
|
+
diagnostics: WardenDiagnostic[]
|
|
109
|
+
): void => {
|
|
110
|
+
if (isDirectResultAccess(line)) {
|
|
111
|
+
diagnostics.push(createMissingAwaitDiagnostic(filePath, lineNumber));
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const variableName = trackPendingCall(line);
|
|
116
|
+
if (variableName) {
|
|
117
|
+
addPendingCall(pendingCalls, variableName, lineNumber);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
advancePendingCalls(line, filePath, lineNumber, pendingCalls, diagnostics);
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const scanSourceCode = (
|
|
124
|
+
sourceCode: string,
|
|
125
|
+
filePath: string
|
|
126
|
+
): readonly WardenDiagnostic[] => {
|
|
127
|
+
const diagnostics: WardenDiagnostic[] = [];
|
|
128
|
+
const lines = sourceCode.split('\n');
|
|
129
|
+
const pendingCalls: PendingCall[] = [];
|
|
130
|
+
|
|
131
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
132
|
+
const line = lines[i];
|
|
133
|
+
if (!line) {
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
processLine(line, filePath, i + 1, pendingCalls, diagnostics);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return diagnostics;
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Flags code that assumes `.implementation()` returns a synchronous result.
|
|
144
|
+
*/
|
|
145
|
+
export const noSyncResultAssumption: WardenRule = {
|
|
146
|
+
check(sourceCode: string, filePath: string): readonly WardenDiagnostic[] {
|
|
147
|
+
if (isTestFile(filePath) || isFrameworkInternalFile(filePath)) {
|
|
148
|
+
return [];
|
|
149
|
+
}
|
|
150
|
+
return scanSourceCode(stripQuotedContent(sourceCode), filePath);
|
|
151
|
+
},
|
|
152
|
+
description:
|
|
153
|
+
'Disallow treating .implementation() as synchronous after normalization. Always await the returned Promise<Result>.',
|
|
154
|
+
name: 'no-sync-result-assumption',
|
|
155
|
+
severity: 'error',
|
|
156
|
+
};
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Flags throws in implementations that are used as detour targets.
|
|
3
|
+
*
|
|
4
|
+
* Uses AST parsing for accurate detection of detour target IDs and
|
|
5
|
+
* throw statements within those trail implementations.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
findImplementationBodies,
|
|
10
|
+
findTrailDefinitions,
|
|
11
|
+
offsetToLine,
|
|
12
|
+
parse,
|
|
13
|
+
walk,
|
|
14
|
+
} from './ast.js';
|
|
15
|
+
import { isTestFile } from './scan.js';
|
|
16
|
+
import type {
|
|
17
|
+
ProjectAwareWardenRule,
|
|
18
|
+
ProjectContext,
|
|
19
|
+
WardenDiagnostic,
|
|
20
|
+
} from './types.js';
|
|
21
|
+
|
|
22
|
+
interface AstNode {
|
|
23
|
+
readonly type: string;
|
|
24
|
+
readonly start: number;
|
|
25
|
+
readonly end: number;
|
|
26
|
+
readonly [key: string]: unknown;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Collect all trail IDs referenced as detour targets in the AST. */
|
|
30
|
+
const collectDetourTargets = (ast: AstNode): ReadonlySet<string> => {
|
|
31
|
+
const targets = new Set<string>();
|
|
32
|
+
|
|
33
|
+
walk(ast, (node) => {
|
|
34
|
+
if (
|
|
35
|
+
node.type !== 'Property' ||
|
|
36
|
+
node.key?.name !== 'detours' ||
|
|
37
|
+
!node.value
|
|
38
|
+
) {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
walk(node.value as AstNode, (inner) => {
|
|
43
|
+
if (inner.type === 'Literal' || inner.type === 'StringLiteral') {
|
|
44
|
+
const { value } = inner as unknown as { value?: unknown };
|
|
45
|
+
if (typeof value === 'string' && value.includes('.')) {
|
|
46
|
+
targets.add(value);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
return targets;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/** Find throws in implementation bodies of targeted trails. */
|
|
56
|
+
const findThrowsInTargetedTrails = (
|
|
57
|
+
ast: AstNode,
|
|
58
|
+
sourceCode: string,
|
|
59
|
+
filePath: string,
|
|
60
|
+
detourTargets: ReadonlySet<string>
|
|
61
|
+
): WardenDiagnostic[] => {
|
|
62
|
+
const diagnostics: WardenDiagnostic[] = [];
|
|
63
|
+
|
|
64
|
+
for (const def of findTrailDefinitions(ast)) {
|
|
65
|
+
if (!detourTargets.has(def.id)) {
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
for (const body of findImplementationBodies(def.config as AstNode)) {
|
|
70
|
+
walk(body, (node) => {
|
|
71
|
+
if (node.type === 'ThrowStatement') {
|
|
72
|
+
diagnostics.push({
|
|
73
|
+
filePath,
|
|
74
|
+
line: offsetToLine(sourceCode, node.start),
|
|
75
|
+
message: `Trail "${def.id}" is a detour target and must not throw. Use Result.err() instead.`,
|
|
76
|
+
rule: 'no-throw-in-detour-target',
|
|
77
|
+
severity: 'error',
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return diagnostics;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const checkThrowInDetourTargets = (
|
|
88
|
+
sourceCode: string,
|
|
89
|
+
filePath: string,
|
|
90
|
+
detourTargets: ReadonlySet<string>
|
|
91
|
+
): readonly WardenDiagnostic[] => {
|
|
92
|
+
if (isTestFile(filePath)) {
|
|
93
|
+
return [];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const ast = parse(filePath, sourceCode);
|
|
97
|
+
if (!ast) {
|
|
98
|
+
return [];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return findThrowsInTargetedTrails(
|
|
102
|
+
ast as AstNode,
|
|
103
|
+
sourceCode,
|
|
104
|
+
filePath,
|
|
105
|
+
detourTargets
|
|
106
|
+
);
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Flags throws in implementations that are used as detour targets.
|
|
111
|
+
*/
|
|
112
|
+
export const noThrowInDetourTarget: ProjectAwareWardenRule = {
|
|
113
|
+
check(sourceCode: string, filePath: string): readonly WardenDiagnostic[] {
|
|
114
|
+
const ast = parse(filePath, sourceCode);
|
|
115
|
+
if (!ast) {
|
|
116
|
+
return [];
|
|
117
|
+
}
|
|
118
|
+
return checkThrowInDetourTargets(
|
|
119
|
+
sourceCode,
|
|
120
|
+
filePath,
|
|
121
|
+
collectDetourTargets(ast as AstNode)
|
|
122
|
+
);
|
|
123
|
+
},
|
|
124
|
+
checkWithContext(
|
|
125
|
+
sourceCode: string,
|
|
126
|
+
filePath: string,
|
|
127
|
+
context: ProjectContext
|
|
128
|
+
): readonly WardenDiagnostic[] {
|
|
129
|
+
if (context.detourTargetTrailIds) {
|
|
130
|
+
return checkThrowInDetourTargets(
|
|
131
|
+
sourceCode,
|
|
132
|
+
filePath,
|
|
133
|
+
context.detourTargetTrailIds
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
const ast = parse(filePath, sourceCode);
|
|
137
|
+
if (!ast) {
|
|
138
|
+
return [];
|
|
139
|
+
}
|
|
140
|
+
return checkThrowInDetourTargets(
|
|
141
|
+
sourceCode,
|
|
142
|
+
filePath,
|
|
143
|
+
collectDetourTargets(ast as AstNode)
|
|
144
|
+
);
|
|
145
|
+
},
|
|
146
|
+
description:
|
|
147
|
+
'Disallow throw statements inside implementations that are referenced as detour targets.',
|
|
148
|
+
name: 'no-throw-in-detour-target',
|
|
149
|
+
severity: 'error',
|
|
150
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Finds `throw` statements inside `implementation:` function bodies.
|
|
3
|
+
*
|
|
4
|
+
* Uses AST parsing for accurate detection — no false positives from
|
|
5
|
+
* throw in comments, strings, or nested non-implementation functions.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { findImplementationBodies, offsetToLine, parse, walk } from './ast.js';
|
|
9
|
+
import type { WardenDiagnostic, WardenRule } from './types.js';
|
|
10
|
+
|
|
11
|
+
export const noThrowInImplementation: WardenRule = {
|
|
12
|
+
check(sourceCode: string, filePath: string): readonly WardenDiagnostic[] {
|
|
13
|
+
const ast = parse(filePath, sourceCode);
|
|
14
|
+
if (!ast) {
|
|
15
|
+
return [];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const diagnostics: WardenDiagnostic[] = [];
|
|
19
|
+
|
|
20
|
+
for (const body of findImplementationBodies(ast)) {
|
|
21
|
+
walk(body, (node) => {
|
|
22
|
+
if (node.type === 'ThrowStatement') {
|
|
23
|
+
diagnostics.push({
|
|
24
|
+
filePath,
|
|
25
|
+
line: offsetToLine(sourceCode, node.start),
|
|
26
|
+
message:
|
|
27
|
+
'Do not throw inside implementation. Use Result.err() instead.',
|
|
28
|
+
rule: 'no-throw-in-implementation',
|
|
29
|
+
severity: 'error',
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return diagnostics;
|
|
36
|
+
},
|
|
37
|
+
description:
|
|
38
|
+
'Disallow throw statements inside trail/route implementation bodies. Use Result.err() instead.',
|
|
39
|
+
name: 'no-throw-in-implementation',
|
|
40
|
+
severity: 'error',
|
|
41
|
+
};
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import type { WardenDiagnostic, WardenRule } from './types.js';
|
|
2
|
+
import { isTestFile } from './scan.js';
|
|
3
|
+
import {
|
|
4
|
+
findTrailLikeSpecs,
|
|
5
|
+
parseArrayEntries,
|
|
6
|
+
parseObjectProperties,
|
|
7
|
+
parseStringLiteral,
|
|
8
|
+
parseZodObjectShape,
|
|
9
|
+
} from './specs.js';
|
|
10
|
+
|
|
11
|
+
const REDUNDANT_OVERRIDE_KEYS = new Set(['label', 'options']);
|
|
12
|
+
|
|
13
|
+
const hasOnlyRedundantKeys = (
|
|
14
|
+
properties: ReadonlyMap<string, { value: string }>
|
|
15
|
+
): boolean =>
|
|
16
|
+
[...properties.keys()].every((key) => REDUNDANT_OVERRIDE_KEYS.has(key));
|
|
17
|
+
|
|
18
|
+
const redundantLabelPart = (
|
|
19
|
+
derivedLabel: string,
|
|
20
|
+
properties: ReadonlyMap<string, { value: string }>
|
|
21
|
+
): string[] => {
|
|
22
|
+
const label = parseStringLiteral(properties.get('label')?.value ?? '');
|
|
23
|
+
return label !== null && label === derivedLabel ? ['label'] : [];
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const optionsAreDerivedDefaults = (
|
|
27
|
+
optionsText: string,
|
|
28
|
+
schemaOptions: readonly string[]
|
|
29
|
+
): boolean => {
|
|
30
|
+
const entries = parseArrayEntries(optionsText, 0, optionsText);
|
|
31
|
+
if (entries.length !== schemaOptions.length) {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return entries.every((entry, index) => {
|
|
36
|
+
if (!entry.text.startsWith('{')) {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const properties = parseObjectProperties(entry.text, 0, entry.text);
|
|
41
|
+
if (properties.size !== 1) {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const value = parseStringLiteral(properties.get('value')?.value ?? '');
|
|
46
|
+
return value !== null && value === schemaOptions[index];
|
|
47
|
+
});
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const redundantOptionsPart = (
|
|
51
|
+
options: { value: string } | undefined,
|
|
52
|
+
schemaOptions: readonly string[] | undefined
|
|
53
|
+
): string[] =>
|
|
54
|
+
options !== undefined &&
|
|
55
|
+
schemaOptions !== undefined &&
|
|
56
|
+
optionsAreDerivedDefaults(options.value, schemaOptions)
|
|
57
|
+
? ['options']
|
|
58
|
+
: [];
|
|
59
|
+
|
|
60
|
+
const findRedundantParts = (
|
|
61
|
+
fieldKey: string,
|
|
62
|
+
fieldOverride: string,
|
|
63
|
+
schemaText: string
|
|
64
|
+
): string[] => {
|
|
65
|
+
const fieldInfo = parseZodObjectShape(schemaText).get(fieldKey);
|
|
66
|
+
if (!fieldInfo) {
|
|
67
|
+
return [];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const properties = parseObjectProperties(fieldOverride, 0, fieldOverride);
|
|
71
|
+
if (properties.size === 0) {
|
|
72
|
+
return ['field metadata'];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (!hasOnlyRedundantKeys(properties)) {
|
|
76
|
+
return [];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const redundant = [
|
|
80
|
+
...redundantLabelPart(fieldInfo.derivedLabel, properties),
|
|
81
|
+
...redundantOptionsPart(properties.get('options'), fieldInfo.options),
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
return redundant.length === properties.size ? redundant : [];
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const diagnosticsForSpec = (
|
|
88
|
+
sourceCode: string,
|
|
89
|
+
filePath: string,
|
|
90
|
+
spec: ReturnType<typeof findTrailLikeSpecs>[number]
|
|
91
|
+
): readonly WardenDiagnostic[] => {
|
|
92
|
+
const input = spec.properties.get('input');
|
|
93
|
+
const fields = spec.properties.get('fields');
|
|
94
|
+
if (!input || !fields) {
|
|
95
|
+
return [];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const fieldEntries = parseObjectProperties(
|
|
99
|
+
fields.value,
|
|
100
|
+
fields.start,
|
|
101
|
+
sourceCode
|
|
102
|
+
);
|
|
103
|
+
return [...fieldEntries.entries()]
|
|
104
|
+
.map(([fieldKey, fieldEntry]) => ({
|
|
105
|
+
fieldEntry,
|
|
106
|
+
fieldKey,
|
|
107
|
+
redundantParts: findRedundantParts(
|
|
108
|
+
fieldKey,
|
|
109
|
+
fieldEntry.value,
|
|
110
|
+
input.value
|
|
111
|
+
),
|
|
112
|
+
}))
|
|
113
|
+
.filter(({ redundantParts }) => redundantParts.length > 0)
|
|
114
|
+
.map(({ fieldEntry, fieldKey, redundantParts }) => ({
|
|
115
|
+
filePath,
|
|
116
|
+
line: fieldEntry.line,
|
|
117
|
+
message: `Trail "${spec.id}" field "${fieldKey}" only repeats schema-derived ${redundantParts.join(' and ')}. Remove the override and let deriveFields() infer it.`,
|
|
118
|
+
rule: 'prefer-schema-inference',
|
|
119
|
+
severity: 'warn' as const,
|
|
120
|
+
}));
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Warns when a fields override only repeats metadata deriveFields() already gets from
|
|
125
|
+
* the schema.
|
|
126
|
+
*/
|
|
127
|
+
export const preferSchemaInference: WardenRule = {
|
|
128
|
+
check(sourceCode: string, filePath: string): readonly WardenDiagnostic[] {
|
|
129
|
+
if (isTestFile(filePath)) {
|
|
130
|
+
return [];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return findTrailLikeSpecs(sourceCode).flatMap((spec) =>
|
|
134
|
+
diagnosticsForSpec(sourceCode, filePath, spec)
|
|
135
|
+
);
|
|
136
|
+
},
|
|
137
|
+
description:
|
|
138
|
+
'Warn when fields overrides only restate labels or enum options deriveFields() already infers from the Zod schema.',
|
|
139
|
+
name: 'prefer-schema-inference',
|
|
140
|
+
severity: 'warn',
|
|
141
|
+
};
|