@ontrails/warden 1.0.0-beta.2 → 1.0.0-beta.4
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-lint.log +1 -1
- package/CHANGELOG.md +46 -0
- package/README.md +6 -6
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +1 -4
- package/dist/cli.js.map +1 -1
- package/dist/rules/ast.d.ts +6 -6
- package/dist/rules/ast.d.ts.map +1 -1
- package/dist/rules/ast.js +8 -10
- package/dist/rules/ast.js.map +1 -1
- package/dist/rules/context-no-surface-types.js +1 -1
- package/dist/rules/context-no-surface-types.js.map +1 -1
- package/dist/rules/implementation-returns-result.d.ts +1 -1
- package/dist/rules/implementation-returns-result.d.ts.map +1 -1
- package/dist/rules/implementation-returns-result.js +52 -6
- package/dist/rules/implementation-returns-result.js.map +1 -1
- package/dist/rules/index.d.ts +1 -8
- package/dist/rules/index.d.ts.map +1 -1
- package/dist/rules/index.js +1 -8
- package/dist/rules/index.js.map +1 -1
- package/dist/rules/no-direct-impl-in-route.d.ts +4 -4
- package/dist/rules/no-direct-impl-in-route.d.ts.map +1 -1
- package/dist/rules/no-direct-impl-in-route.js +15 -14
- package/dist/rules/no-direct-impl-in-route.js.map +1 -1
- package/dist/rules/no-direct-implementation-call.d.ts +3 -3
- package/dist/rules/no-direct-implementation-call.js +7 -7
- package/dist/rules/no-direct-implementation-call.js.map +1 -1
- package/dist/rules/no-sync-result-assumption.d.ts +1 -1
- package/dist/rules/no-sync-result-assumption.js +5 -5
- package/dist/rules/no-sync-result-assumption.js.map +1 -1
- package/dist/rules/no-throw-in-detour-target.js +2 -2
- package/dist/rules/no-throw-in-detour-target.js.map +1 -1
- package/dist/rules/no-throw-in-implementation.d.ts +1 -1
- package/dist/rules/no-throw-in-implementation.js +3 -3
- package/dist/rules/no-throw-in-implementation.js.map +1 -1
- package/dist/rules/specs.d.ts +1 -1
- package/dist/rules/specs.d.ts.map +1 -1
- package/dist/rules/specs.js +2 -2
- package/dist/rules/specs.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/cli.test.ts +7 -7
- package/src/__tests__/drift.test.ts +1 -1
- package/src/__tests__/implementation-returns-result.test.ts +60 -6
- package/src/__tests__/no-direct-implementation-call.test.ts +8 -8
- package/src/__tests__/no-sync-result-assumption.test.ts +6 -6
- package/src/__tests__/no-throw-in-detour-target.test.ts +6 -6
- package/src/__tests__/prefer-schema-inference.test.ts +4 -4
- package/src/__tests__/rules.test.ts +59 -20
- package/src/__tests__/valid-describe-refs.test.ts +4 -4
- package/src/cli.ts +1 -4
- package/src/rules/ast.ts +10 -14
- package/src/rules/context-no-surface-types.ts +1 -1
- package/src/rules/implementation-returns-result.ts +63 -6
- package/src/rules/index.ts +1 -8
- package/src/rules/no-direct-impl-in-route.ts +20 -16
- package/src/rules/no-direct-implementation-call.ts +7 -7
- package/src/rules/no-sync-result-assumption.ts +5 -5
- package/src/rules/no-throw-in-detour-target.ts +2 -2
- package/src/rules/no-throw-in-implementation.ts +3 -3
- package/src/rules/specs.ts +5 -5
|
@@ -10,7 +10,7 @@ trail("entity.show", {
|
|
|
10
10
|
fields: {
|
|
11
11
|
firstName: { label: "First Name" },
|
|
12
12
|
},
|
|
13
|
-
|
|
13
|
+
run: (input) => Result.ok(input),
|
|
14
14
|
})`;
|
|
15
15
|
|
|
16
16
|
const diagnostics = preferSchemaInference.check(code, 'src/entity.ts');
|
|
@@ -31,7 +31,7 @@ trail("entity.paint", {
|
|
|
31
31
|
options: [{ value: "red" }, { value: "green" }],
|
|
32
32
|
},
|
|
33
33
|
},
|
|
34
|
-
|
|
34
|
+
run: (input) => Result.ok(input),
|
|
35
35
|
})`;
|
|
36
36
|
|
|
37
37
|
const diagnostics = preferSchemaInference.check(code, 'src/entity.ts');
|
|
@@ -56,7 +56,7 @@ trail("entity.paint", {
|
|
|
56
56
|
},
|
|
57
57
|
displayName: { label: "Public name" },
|
|
58
58
|
},
|
|
59
|
-
|
|
59
|
+
run: (input) => Result.ok(input),
|
|
60
60
|
})`;
|
|
61
61
|
|
|
62
62
|
const diagnostics = preferSchemaInference.check(code, 'src/entity.ts');
|
|
@@ -74,7 +74,7 @@ trail("entity.show", {
|
|
|
74
74
|
message: "Who should we greet?",
|
|
75
75
|
},
|
|
76
76
|
},
|
|
77
|
-
|
|
77
|
+
run: (input) => Result.ok(input),
|
|
78
78
|
})`;
|
|
79
79
|
|
|
80
80
|
const diagnostics = preferSchemaInference.check(code, 'src/entity.ts');
|
|
@@ -14,7 +14,7 @@ describe('no-throw-in-implementation', () => {
|
|
|
14
14
|
test('flags throw inside implementation body', () => {
|
|
15
15
|
const code = `
|
|
16
16
|
trail("entity.show", {
|
|
17
|
-
|
|
17
|
+
run: async (input, ctx) => {
|
|
18
18
|
throw new Error("boom");
|
|
19
19
|
}
|
|
20
20
|
})`;
|
|
@@ -27,7 +27,7 @@ trail("entity.show", {
|
|
|
27
27
|
test('allows Result.err() in implementation', () => {
|
|
28
28
|
const code = `
|
|
29
29
|
trail("entity.show", {
|
|
30
|
-
|
|
30
|
+
run: async (input, ctx) => {
|
|
31
31
|
return Result.err(new NotFoundError("not found"));
|
|
32
32
|
}
|
|
33
33
|
})`;
|
|
@@ -42,7 +42,7 @@ function helper() {
|
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
trail("entity.show", {
|
|
45
|
-
|
|
45
|
+
run: async (input, ctx) => {
|
|
46
46
|
return Result.ok(data);
|
|
47
47
|
}
|
|
48
48
|
})`;
|
|
@@ -59,7 +59,7 @@ describe('context-no-surface-types', () => {
|
|
|
59
59
|
const code = `
|
|
60
60
|
import { Request, Response } from "express";
|
|
61
61
|
trail("entity.show", {
|
|
62
|
-
|
|
62
|
+
run: async (input, ctx) => {
|
|
63
63
|
return Result.ok(data);
|
|
64
64
|
}
|
|
65
65
|
})`;
|
|
@@ -73,7 +73,7 @@ trail("entity.show", {
|
|
|
73
73
|
const code = `
|
|
74
74
|
import type { McpSession } from "@modelcontextprotocol/sdk";
|
|
75
75
|
trail("entity.show", {
|
|
76
|
-
|
|
76
|
+
run: async (input, ctx) => {
|
|
77
77
|
return Result.ok(data);
|
|
78
78
|
}
|
|
79
79
|
})`;
|
|
@@ -85,7 +85,7 @@ trail("entity.show", {
|
|
|
85
85
|
const code = `
|
|
86
86
|
import { trail, Result } from "@ontrails/core";
|
|
87
87
|
trail("entity.show", {
|
|
88
|
-
|
|
88
|
+
run: async (input, ctx) => {
|
|
89
89
|
return Result.ok(data);
|
|
90
90
|
}
|
|
91
91
|
})`;
|
|
@@ -110,7 +110,7 @@ describe('valid-detour-refs', () => {
|
|
|
110
110
|
const code = `
|
|
111
111
|
trail("entity.show", {
|
|
112
112
|
detours: [{ target: "entity.edit" }],
|
|
113
|
-
|
|
113
|
+
run: async (input, ctx) => Result.ok(data)
|
|
114
114
|
})`;
|
|
115
115
|
const diagnostics = validDetourRefs.check(code, TEST_FILE);
|
|
116
116
|
expect(diagnostics.length).toBe(1);
|
|
@@ -120,12 +120,12 @@ trail("entity.show", {
|
|
|
120
120
|
test('passes when detour target exists', () => {
|
|
121
121
|
const code = `
|
|
122
122
|
trail("entity.edit", {
|
|
123
|
-
|
|
123
|
+
run: async (input, ctx) => Result.ok(data)
|
|
124
124
|
})
|
|
125
125
|
|
|
126
126
|
trail("entity.show", {
|
|
127
127
|
detours: [{ target: "entity.edit" }],
|
|
128
|
-
|
|
128
|
+
run: async (input, ctx) => Result.ok(data)
|
|
129
129
|
})`;
|
|
130
130
|
const diagnostics = validDetourRefs.check(code, TEST_FILE);
|
|
131
131
|
expect(diagnostics.length).toBe(0);
|
|
@@ -135,7 +135,7 @@ trail("entity.show", {
|
|
|
135
135
|
const code = `
|
|
136
136
|
trail("entity.show", {
|
|
137
137
|
detours: [{ target: "entity.edit" }],
|
|
138
|
-
|
|
138
|
+
run: async (input, ctx) => Result.ok(data)
|
|
139
139
|
})`;
|
|
140
140
|
const context = { knownTrailIds: new Set(['entity.show', 'entity.edit']) };
|
|
141
141
|
const diagnostics = validDetourRefs.checkWithContext(
|
|
@@ -145,18 +145,45 @@ trail("entity.show", {
|
|
|
145
145
|
);
|
|
146
146
|
expect(diagnostics.length).toBe(0);
|
|
147
147
|
});
|
|
148
|
+
|
|
149
|
+
test('flags detour target in trail with follow that does not exist', () => {
|
|
150
|
+
const code = `
|
|
151
|
+
trail("entity.onboard", {
|
|
152
|
+
detours: [{ target: "entity.missing" }],
|
|
153
|
+
follow: ["entity.create"],
|
|
154
|
+
run: async (input, ctx) => Result.ok(data)
|
|
155
|
+
})`;
|
|
156
|
+
const diagnostics = validDetourRefs.check(code, TEST_FILE);
|
|
157
|
+
expect(diagnostics.length).toBe(1);
|
|
158
|
+
expect(diagnostics[0]?.message).toContain('entity.missing');
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test('passes when trail with follow detour target exists', () => {
|
|
162
|
+
const code = `
|
|
163
|
+
trail("entity.fallback", {
|
|
164
|
+
run: async (input, ctx) => Result.ok(data)
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
trail("entity.onboard", {
|
|
168
|
+
detours: [{ target: "entity.fallback" }],
|
|
169
|
+
follow: ["entity.create"],
|
|
170
|
+
run: async (input, ctx) => Result.ok(data)
|
|
171
|
+
})`;
|
|
172
|
+
const diagnostics = validDetourRefs.check(code, TEST_FILE);
|
|
173
|
+
expect(diagnostics.length).toBe(0);
|
|
174
|
+
});
|
|
148
175
|
});
|
|
149
176
|
|
|
150
177
|
// ---------------------------------------------------------------------------
|
|
151
178
|
// no-direct-impl-in-route
|
|
152
179
|
// ---------------------------------------------------------------------------
|
|
153
180
|
describe('no-direct-impl-in-route', () => {
|
|
154
|
-
test('warns on direct .
|
|
181
|
+
test('warns on direct .run() call in trail with follow', () => {
|
|
155
182
|
const code = `
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
const result = await entityCreate.
|
|
183
|
+
trail("entity.onboard", {
|
|
184
|
+
follow: ["entity.create"],
|
|
185
|
+
run: async (input, ctx) => {
|
|
186
|
+
const result = await entityCreate.run(data);
|
|
160
187
|
return Result.ok(result);
|
|
161
188
|
}
|
|
162
189
|
})`;
|
|
@@ -168,9 +195,9 @@ hike("entity.onboard", {
|
|
|
168
195
|
|
|
169
196
|
test('allows ctx.follow() calls', () => {
|
|
170
197
|
const code = `
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
198
|
+
trail("entity.onboard", {
|
|
199
|
+
follow: ["entity.create"],
|
|
200
|
+
run: async (input, ctx) => {
|
|
174
201
|
const result = await ctx.follow("entity.create", data);
|
|
175
202
|
return Result.ok(result);
|
|
176
203
|
}
|
|
@@ -179,9 +206,21 @@ hike("entity.onboard", {
|
|
|
179
206
|
expect(diagnostics.length).toBe(0);
|
|
180
207
|
});
|
|
181
208
|
|
|
182
|
-
test('ignores
|
|
209
|
+
test('ignores trails without follow', () => {
|
|
210
|
+
const code = `
|
|
211
|
+
trail("entity.show", {
|
|
212
|
+
run: async (input, ctx) => {
|
|
213
|
+
const result = await someTrail.run(data);
|
|
214
|
+
return Result.ok(result);
|
|
215
|
+
}
|
|
216
|
+
})`;
|
|
217
|
+
const diagnostics = noDirectImplInRoute.check(code, TEST_FILE);
|
|
218
|
+
expect(diagnostics.length).toBe(0);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test('ignores files without trail() calls', () => {
|
|
183
222
|
const code = `
|
|
184
|
-
const result = await someTrail.
|
|
223
|
+
const result = await someTrail.run(data);`;
|
|
185
224
|
const diagnostics = noDirectImplInRoute.check(code, TEST_FILE);
|
|
186
225
|
expect(diagnostics.length).toBe(0);
|
|
187
226
|
});
|
|
@@ -9,7 +9,7 @@ trail("entity.show", {
|
|
|
9
9
|
input: z.object({
|
|
10
10
|
query: z.string().describe("Search query. @see entity.search"),
|
|
11
11
|
}),
|
|
12
|
-
|
|
12
|
+
run: (input) => Result.ok(input),
|
|
13
13
|
})`;
|
|
14
14
|
|
|
15
15
|
const diagnostics = validDescribeRefs.check(code, 'src/entity.ts');
|
|
@@ -23,14 +23,14 @@ trail("entity.show", {
|
|
|
23
23
|
const code = `
|
|
24
24
|
trail("entity.search", {
|
|
25
25
|
input: z.object({ query: z.string() }),
|
|
26
|
-
|
|
26
|
+
run: (input) => Result.ok(input),
|
|
27
27
|
})
|
|
28
28
|
|
|
29
29
|
trail("entity.show", {
|
|
30
30
|
input: z.object({
|
|
31
31
|
query: z.string().describe("Search query. @see entity.search"),
|
|
32
32
|
}),
|
|
33
|
-
|
|
33
|
+
run: (input) => Result.ok(input),
|
|
34
34
|
})`;
|
|
35
35
|
|
|
36
36
|
const diagnostics = validDescribeRefs.check(code, 'src/entity.ts');
|
|
@@ -44,7 +44,7 @@ trail("entity.show", {
|
|
|
44
44
|
input: z.object({
|
|
45
45
|
query: z.string().describe("Search query. @see entity.search"),
|
|
46
46
|
}),
|
|
47
|
-
|
|
47
|
+
run: (input) => Result.ok(input),
|
|
48
48
|
})`;
|
|
49
49
|
|
|
50
50
|
const diagnostics = validDescribeRefs.checkWithContext(
|
package/src/cli.ts
CHANGED
|
@@ -152,10 +152,7 @@ const loadSourceFiles = async (
|
|
|
152
152
|
};
|
|
153
153
|
|
|
154
154
|
const buildProjectContextFromTopo = (appTopo: Topo): ProjectContext => {
|
|
155
|
-
const knownTrailIds = new Set<string>(
|
|
156
|
-
...appTopo.trails.keys(),
|
|
157
|
-
...appTopo.hikes.keys(),
|
|
158
|
-
]);
|
|
155
|
+
const knownTrailIds = new Set<string>(appTopo.trails.keys());
|
|
159
156
|
|
|
160
157
|
const detourTargetTrailIds = new Set<string>();
|
|
161
158
|
for (const t of appTopo.trails.values()) {
|
package/src/rules/ast.ts
CHANGED
|
@@ -74,15 +74,11 @@ export const offsetToLine = (sourceCode: string, offset: number): number => {
|
|
|
74
74
|
return line;
|
|
75
75
|
};
|
|
76
76
|
|
|
77
|
-
/** Find all `
|
|
78
|
-
export const
|
|
77
|
+
/** Find all `run:` property values in an AST. */
|
|
78
|
+
export const findRunBodies = (ast: AstNode): AstNode[] => {
|
|
79
79
|
const bodies: AstNode[] = [];
|
|
80
80
|
walk(ast, (node) => {
|
|
81
|
-
if (
|
|
82
|
-
node.type === 'Property' &&
|
|
83
|
-
node.key?.name === 'implementation' &&
|
|
84
|
-
node.value
|
|
85
|
-
) {
|
|
81
|
+
if (node.type === 'Property' && node.key?.name === 'run' && node.value) {
|
|
86
82
|
bodies.push(node.value);
|
|
87
83
|
}
|
|
88
84
|
});
|
|
@@ -92,20 +88,20 @@ export const findImplementationBodies = (ast: AstNode): AstNode[] => {
|
|
|
92
88
|
export interface TrailDefinition {
|
|
93
89
|
/** Trail ID string, e.g. "entity.show" */
|
|
94
90
|
readonly id: string;
|
|
95
|
-
/** "trail" or "
|
|
91
|
+
/** "trail" or "event" */
|
|
96
92
|
readonly kind: string;
|
|
97
|
-
/** The config object argument (second arg to trail
|
|
93
|
+
/** The config object argument (second arg to trail() call) */
|
|
98
94
|
readonly config: AstNode;
|
|
99
95
|
/** Start offset of the call expression */
|
|
100
96
|
readonly start: number;
|
|
101
97
|
}
|
|
102
98
|
|
|
103
99
|
/**
|
|
104
|
-
* Find all `trail("id", { ... })` and `
|
|
100
|
+
* Find all `trail("id", { ... })` and `event("id", { ... })` call sites.
|
|
105
101
|
*
|
|
106
102
|
* Returns the trail ID, kind, and config object node for each definition.
|
|
107
103
|
*/
|
|
108
|
-
const TRAIL_CALLEE_NAMES = new Set(['trail', '
|
|
104
|
+
const TRAIL_CALLEE_NAMES = new Set(['trail', 'event']);
|
|
109
105
|
|
|
110
106
|
const getTrailCalleeName = (node: AstNode): string | null => {
|
|
111
107
|
if (node.type !== 'CallExpression') {
|
|
@@ -157,8 +153,8 @@ const extractTrailDefinition = (node: AstNode): TrailDefinition | null => {
|
|
|
157
153
|
};
|
|
158
154
|
};
|
|
159
155
|
|
|
160
|
-
/** Check if a node is a call to `.
|
|
161
|
-
export const
|
|
156
|
+
/** Check if a node is a call to `.run()` on some object. */
|
|
157
|
+
export const isRunCall = (node: AstNode): boolean => {
|
|
162
158
|
if (node.type !== 'CallExpression') {
|
|
163
159
|
return false;
|
|
164
160
|
}
|
|
@@ -175,7 +171,7 @@ export const isImplementationCall = (node: AstNode): boolean => {
|
|
|
175
171
|
const prop = (callee as unknown as { property?: AstNode }).property;
|
|
176
172
|
return (
|
|
177
173
|
prop?.type === 'Identifier' &&
|
|
178
|
-
(prop as unknown as { name: string }).name === '
|
|
174
|
+
(prop as unknown as { name: string }).name === 'run'
|
|
179
175
|
);
|
|
180
176
|
};
|
|
181
177
|
|
|
@@ -123,7 +123,7 @@ const classifyImport = (
|
|
|
123
123
|
*/
|
|
124
124
|
export const contextNoSurfaceTypes: WardenRule = {
|
|
125
125
|
check(sourceCode: string, filePath: string): readonly WardenDiagnostic[] {
|
|
126
|
-
if (!/\
|
|
126
|
+
if (!/\btrail\s*\(/.test(sourceCode)) {
|
|
127
127
|
return [];
|
|
128
128
|
}
|
|
129
129
|
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Finds implementations that return raw values instead of `Result`.
|
|
3
3
|
*
|
|
4
|
-
* Uses AST parsing to find `
|
|
4
|
+
* Uses AST parsing to find `run:` bodies and check that
|
|
5
5
|
* every return statement returns Result.ok(), Result.err(), ctx.follow(),
|
|
6
6
|
* or a tracked Result-typed variable.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import {
|
|
10
|
-
|
|
10
|
+
findRunBodies,
|
|
11
11
|
findTrailDefinitions,
|
|
12
12
|
offsetToLine,
|
|
13
13
|
parse,
|
|
@@ -63,7 +63,7 @@ const isResultMemberCall = (callee: AstNode): boolean => {
|
|
|
63
63
|
if (objName === 'ctx' && propName === 'follow') {
|
|
64
64
|
return true;
|
|
65
65
|
}
|
|
66
|
-
return propName === '
|
|
66
|
+
return propName === 'run';
|
|
67
67
|
};
|
|
68
68
|
|
|
69
69
|
// ---------------------------------------------------------------------------
|
|
@@ -158,6 +158,63 @@ const trackResultVariable = (node: AstNode, resultVars: Set<string>): void => {
|
|
|
158
158
|
}
|
|
159
159
|
};
|
|
160
160
|
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
// Shallow walk (stops at nested function boundaries)
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
const FUNCTION_BOUNDARY_TYPES = new Set([
|
|
166
|
+
'ArrowFunctionExpression',
|
|
167
|
+
'FunctionExpression',
|
|
168
|
+
'FunctionDeclaration',
|
|
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
|
|
182
|
+
): void => {
|
|
183
|
+
if (Array.isArray(val)) {
|
|
184
|
+
for (const item of val) {
|
|
185
|
+
if (!isFunctionBoundary(item)) {
|
|
186
|
+
recurse(item, visit);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
} else if (
|
|
190
|
+
val &&
|
|
191
|
+
typeof val === 'object' &&
|
|
192
|
+
(val as AstNode).type &&
|
|
193
|
+
!isFunctionBoundary(val)
|
|
194
|
+
) {
|
|
195
|
+
recurse(val, visit);
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Walk an AST node tree without recursing into nested function bodies.
|
|
201
|
+
*
|
|
202
|
+
* This ensures that return statements inside `.map()`, `.filter()`, `.then()`
|
|
203
|
+
* callbacks etc. are not mistakenly checked as implementation-level returns.
|
|
204
|
+
*/
|
|
205
|
+
const walkShallow = (node: unknown, visit: (node: AstNode) => void): void => {
|
|
206
|
+
if (!node || typeof node !== 'object') {
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
const n = node as AstNode;
|
|
210
|
+
if (n.type) {
|
|
211
|
+
visit(n);
|
|
212
|
+
}
|
|
213
|
+
for (const val of Object.values(n)) {
|
|
214
|
+
visitValue(val, visit, walkShallow);
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
|
|
161
218
|
// ---------------------------------------------------------------------------
|
|
162
219
|
// Return statement checking
|
|
163
220
|
// ---------------------------------------------------------------------------
|
|
@@ -173,7 +230,7 @@ const checkReturnStatements = (
|
|
|
173
230
|
): void => {
|
|
174
231
|
const resultVars = new Set<string>();
|
|
175
232
|
|
|
176
|
-
|
|
233
|
+
walkShallow(blockBody, (node) => {
|
|
177
234
|
if (node.type === 'VariableDeclarator') {
|
|
178
235
|
trackResultVariable(node, resultVars);
|
|
179
236
|
}
|
|
@@ -304,8 +361,8 @@ const checkAllDefinitions = (
|
|
|
304
361
|
const helperNames = collectResultHelperNames(ast, sourceCode);
|
|
305
362
|
|
|
306
363
|
for (const def of findTrailDefinitions(ast)) {
|
|
307
|
-
const info = { id: def.id, label:
|
|
308
|
-
for (const implValue of
|
|
364
|
+
const info = { id: def.id, label: 'Trail' };
|
|
365
|
+
for (const implValue of findRunBodies(def.config as AstNode)) {
|
|
309
366
|
checkImplementation(
|
|
310
367
|
implValue,
|
|
311
368
|
info,
|
package/src/rules/index.ts
CHANGED
|
@@ -29,14 +29,7 @@ export { noThrowInDetourTarget } from './no-throw-in-detour-target.js';
|
|
|
29
29
|
export { preferSchemaInference } from './prefer-schema-inference.js';
|
|
30
30
|
export { validDescribeRefs } from './valid-describe-refs.js';
|
|
31
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
|
-
*/
|
|
32
|
+
/** All built-in warden rules, keyed by rule name. */
|
|
40
33
|
export const wardenRules: ReadonlyMap<string, WardenRule> = new Map<
|
|
41
34
|
string,
|
|
42
35
|
WardenRule
|
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Detects
|
|
2
|
+
* Detects trail implementations with `follow` that call `.run()` directly.
|
|
3
3
|
*
|
|
4
|
-
* Uses AST parsing to find
|
|
5
|
-
* `.
|
|
4
|
+
* Uses AST parsing to find trail definitions that declare `follow` and check for
|
|
5
|
+
* `.run()` call expressions in their bodies.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import {
|
|
9
|
-
|
|
9
|
+
findConfigProperty,
|
|
10
|
+
findRunBodies,
|
|
10
11
|
findTrailDefinitions,
|
|
11
|
-
|
|
12
|
+
isRunCall,
|
|
12
13
|
offsetToLine,
|
|
13
14
|
parse,
|
|
14
15
|
walk,
|
|
@@ -22,20 +23,20 @@ interface AstNode {
|
|
|
22
23
|
readonly [key: string]: unknown;
|
|
23
24
|
}
|
|
24
25
|
|
|
25
|
-
const
|
|
26
|
+
const findImplCallsInTrailWithFollow = (
|
|
26
27
|
def: { readonly config: AstNode },
|
|
27
28
|
filePath: string,
|
|
28
29
|
sourceCode: string,
|
|
29
30
|
diagnostics: WardenDiagnostic[]
|
|
30
31
|
): void => {
|
|
31
|
-
for (const body of
|
|
32
|
+
for (const body of findRunBodies(def.config as AstNode)) {
|
|
32
33
|
walk(body, (node) => {
|
|
33
|
-
if (
|
|
34
|
+
if (isRunCall(node as AstNode)) {
|
|
34
35
|
diagnostics.push({
|
|
35
36
|
filePath,
|
|
36
37
|
line: offsetToLine(sourceCode, node.start),
|
|
37
38
|
message:
|
|
38
|
-
'Use ctx.follow("trailId", input) instead of direct .
|
|
39
|
+
'Use ctx.follow("trailId", input) instead of direct .run() calls. ctx.follow() validates input and propagates tracing.',
|
|
39
40
|
rule: 'no-direct-impl-in-route',
|
|
40
41
|
severity: 'warn',
|
|
41
42
|
});
|
|
@@ -44,12 +45,15 @@ const findImplCallsInHike = (
|
|
|
44
45
|
}
|
|
45
46
|
};
|
|
46
47
|
|
|
48
|
+
const hasFollowProperty = (config: AstNode): boolean =>
|
|
49
|
+
findConfigProperty(config as AstNode, 'follow') !== null;
|
|
50
|
+
|
|
47
51
|
/**
|
|
48
|
-
* Detects
|
|
52
|
+
* Detects trails with `follow` that call another trail's `.run()` directly.
|
|
49
53
|
*/
|
|
50
54
|
export const noDirectImplInRoute: WardenRule = {
|
|
51
55
|
check(sourceCode: string, filePath: string): readonly WardenDiagnostic[] {
|
|
52
|
-
if (!/\
|
|
56
|
+
if (!/\btrail\s*\(/.test(sourceCode)) {
|
|
53
57
|
return [];
|
|
54
58
|
}
|
|
55
59
|
|
|
@@ -59,18 +63,18 @@ export const noDirectImplInRoute: WardenRule = {
|
|
|
59
63
|
}
|
|
60
64
|
|
|
61
65
|
const diagnostics: WardenDiagnostic[] = [];
|
|
62
|
-
const
|
|
63
|
-
(d
|
|
66
|
+
const followDefs = findTrailDefinitions(ast as AstNode).filter((d) =>
|
|
67
|
+
hasFollowProperty(d.config as AstNode)
|
|
64
68
|
);
|
|
65
69
|
|
|
66
|
-
for (const def of
|
|
67
|
-
|
|
70
|
+
for (const def of followDefs) {
|
|
71
|
+
findImplCallsInTrailWithFollow(def, filePath, sourceCode, diagnostics);
|
|
68
72
|
}
|
|
69
73
|
|
|
70
74
|
return diagnostics;
|
|
71
75
|
},
|
|
72
76
|
description:
|
|
73
|
-
'Prefer ctx.follow() over direct .
|
|
77
|
+
'Prefer ctx.follow() over direct .run() calls in trail bodies with follow.',
|
|
74
78
|
name: 'no-direct-impl-in-route',
|
|
75
79
|
|
|
76
80
|
severity: 'warn',
|
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Flags direct `.
|
|
2
|
+
* Flags direct `.run()` calls in application code.
|
|
3
3
|
*
|
|
4
|
-
* Uses AST parsing to find `.
|
|
4
|
+
* Uses AST parsing to find `.run()` call expressions,
|
|
5
5
|
* ignoring occurrences in strings and comments.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import {
|
|
8
|
+
import { isRunCall, offsetToLine, parse, walk } from './ast.js';
|
|
9
9
|
import { isFrameworkInternalFile, isTestFile } from './scan.js';
|
|
10
10
|
import type { WardenDiagnostic, WardenRule } from './types.js';
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
|
-
* Flags direct `.
|
|
13
|
+
* Flags direct `.run()` calls in application code.
|
|
14
14
|
*/
|
|
15
15
|
export const noDirectImplementationCall: WardenRule = {
|
|
16
16
|
check(sourceCode: string, filePath: string): readonly WardenDiagnostic[] {
|
|
@@ -26,12 +26,12 @@ export const noDirectImplementationCall: WardenRule = {
|
|
|
26
26
|
const diagnostics: WardenDiagnostic[] = [];
|
|
27
27
|
|
|
28
28
|
walk(ast, (node) => {
|
|
29
|
-
if (
|
|
29
|
+
if (isRunCall(node)) {
|
|
30
30
|
diagnostics.push({
|
|
31
31
|
filePath,
|
|
32
32
|
line: offsetToLine(sourceCode, node.start),
|
|
33
33
|
message:
|
|
34
|
-
'Use ctx.follow("trailId", input) instead of direct .
|
|
34
|
+
'Use ctx.follow("trailId", input) instead of direct .run() calls. Direct implementation access bypasses validation, tracing, and layers.',
|
|
35
35
|
rule: 'no-direct-implementation-call',
|
|
36
36
|
severity: 'warn',
|
|
37
37
|
});
|
|
@@ -41,7 +41,7 @@ export const noDirectImplementationCall: WardenRule = {
|
|
|
41
41
|
return diagnostics;
|
|
42
42
|
},
|
|
43
43
|
description:
|
|
44
|
-
'Disallow direct .
|
|
44
|
+
'Disallow direct .run() calls in application code. Use ctx.follow() instead.',
|
|
45
45
|
name: 'no-direct-implementation-call',
|
|
46
46
|
severity: 'warn',
|
|
47
47
|
};
|
|
@@ -7,10 +7,10 @@ import {
|
|
|
7
7
|
|
|
8
8
|
const RESULT_ACCESS_PATTERN =
|
|
9
9
|
/\.(?:isOk|isErr|match|map)\s*\(|\.(?:value|error)\b/;
|
|
10
|
-
const IMPLEMENTATION_CALL_PATTERN = /\.
|
|
10
|
+
const IMPLEMENTATION_CALL_PATTERN = /\.run\s*\(/;
|
|
11
11
|
|
|
12
12
|
const isAwaitedImplementationCall = (line: string): boolean => {
|
|
13
|
-
const callIndex = line.indexOf('.
|
|
13
|
+
const callIndex = line.indexOf('.run(');
|
|
14
14
|
if (callIndex === -1) {
|
|
15
15
|
return false;
|
|
16
16
|
}
|
|
@@ -39,7 +39,7 @@ interface PendingCall {
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
const MISSING_AWAIT_MESSAGE =
|
|
42
|
-
'Missing await: .
|
|
42
|
+
'Missing await: .run() returns Promise<Result> after normalization. Use `const result = await trail.run(input, ctx)`.';
|
|
43
43
|
|
|
44
44
|
const createMissingAwaitDiagnostic = (
|
|
45
45
|
filePath: string,
|
|
@@ -140,7 +140,7 @@ const scanSourceCode = (
|
|
|
140
140
|
};
|
|
141
141
|
|
|
142
142
|
/**
|
|
143
|
-
* Flags code that assumes `.
|
|
143
|
+
* Flags code that assumes `.run()` returns a synchronous result.
|
|
144
144
|
*/
|
|
145
145
|
export const noSyncResultAssumption: WardenRule = {
|
|
146
146
|
check(sourceCode: string, filePath: string): readonly WardenDiagnostic[] {
|
|
@@ -150,7 +150,7 @@ export const noSyncResultAssumption: WardenRule = {
|
|
|
150
150
|
return scanSourceCode(stripQuotedContent(sourceCode), filePath);
|
|
151
151
|
},
|
|
152
152
|
description:
|
|
153
|
-
'Disallow treating .
|
|
153
|
+
'Disallow treating .run() as synchronous after normalization. Always await the returned Promise<Result>.',
|
|
154
154
|
name: 'no-sync-result-assumption',
|
|
155
155
|
severity: 'error',
|
|
156
156
|
};
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import {
|
|
9
|
-
|
|
9
|
+
findRunBodies,
|
|
10
10
|
findTrailDefinitions,
|
|
11
11
|
offsetToLine,
|
|
12
12
|
parse,
|
|
@@ -66,7 +66,7 @@ const findThrowsInTargetedTrails = (
|
|
|
66
66
|
continue;
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
for (const body of
|
|
69
|
+
for (const body of findRunBodies(def.config as AstNode)) {
|
|
70
70
|
walk(body, (node) => {
|
|
71
71
|
if (node.type === 'ThrowStatement') {
|
|
72
72
|
diagnostics.push({
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Finds `throw` statements inside `
|
|
2
|
+
* Finds `throw` statements inside `run:` function bodies.
|
|
3
3
|
*
|
|
4
4
|
* Uses AST parsing for accurate detection — no false positives from
|
|
5
5
|
* throw in comments, strings, or nested non-implementation functions.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import {
|
|
8
|
+
import { findRunBodies, offsetToLine, parse, walk } from './ast.js';
|
|
9
9
|
import type { WardenDiagnostic, WardenRule } from './types.js';
|
|
10
10
|
|
|
11
11
|
export const noThrowInImplementation: WardenRule = {
|
|
@@ -17,7 +17,7 @@ export const noThrowInImplementation: WardenRule = {
|
|
|
17
17
|
|
|
18
18
|
const diagnostics: WardenDiagnostic[] = [];
|
|
19
19
|
|
|
20
|
-
for (const body of
|
|
20
|
+
for (const body of findRunBodies(ast)) {
|
|
21
21
|
walk(body, (node) => {
|
|
22
22
|
if (node.type === 'ThrowStatement') {
|
|
23
23
|
diagnostics.push({
|