@ontrails/warden 1.0.0-beta.0 → 1.0.0-beta.10
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 +159 -0
- package/README.md +57 -77
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +1 -4
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/rules/ast.d.ts +15 -8
- package/dist/rules/ast.d.ts.map +1 -1
- package/dist/rules/ast.js +99 -44
- 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/follow-declarations.d.ts +13 -0
- package/dist/rules/follow-declarations.d.ts.map +1 -0
- package/dist/rules/follow-declarations.js +264 -0
- package/dist/rules/follow-declarations.js.map +1 -0
- 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 +2 -8
- package/dist/rules/index.d.ts.map +1 -1
- package/dist/rules/index.js +4 -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/dist/trails/context-no-surface-types.trail.d.ts +13 -0
- package/dist/trails/context-no-surface-types.trail.d.ts.map +1 -0
- package/dist/trails/context-no-surface-types.trail.js +21 -0
- package/dist/trails/context-no-surface-types.trail.js.map +1 -0
- package/dist/trails/follow-declarations.trail.d.ts +13 -0
- package/dist/trails/follow-declarations.trail.d.ts.map +1 -0
- package/dist/trails/follow-declarations.trail.js +22 -0
- package/dist/trails/follow-declarations.trail.js.map +1 -0
- package/dist/trails/implementation-returns-result.trail.d.ts +13 -0
- package/dist/trails/implementation-returns-result.trail.d.ts.map +1 -0
- package/dist/trails/implementation-returns-result.trail.js +20 -0
- package/dist/trails/implementation-returns-result.trail.js.map +1 -0
- package/dist/trails/index.d.ts +14 -0
- package/dist/trails/index.d.ts.map +1 -0
- package/dist/trails/index.js +13 -0
- package/dist/trails/index.js.map +1 -0
- package/dist/trails/no-direct-impl-in-route.trail.d.ts +13 -0
- package/dist/trails/no-direct-impl-in-route.trail.d.ts.map +1 -0
- package/dist/trails/no-direct-impl-in-route.trail.js +22 -0
- package/dist/trails/no-direct-impl-in-route.trail.js.map +1 -0
- package/dist/trails/no-direct-implementation-call.trail.d.ts +13 -0
- package/dist/trails/no-direct-implementation-call.trail.d.ts.map +1 -0
- package/dist/trails/no-direct-implementation-call.trail.js +16 -0
- package/dist/trails/no-direct-implementation-call.trail.js.map +1 -0
- package/dist/trails/no-sync-result-assumption.trail.d.ts +13 -0
- package/dist/trails/no-sync-result-assumption.trail.d.ts.map +1 -0
- package/dist/trails/no-sync-result-assumption.trail.js +19 -0
- package/dist/trails/no-sync-result-assumption.trail.js.map +1 -0
- package/dist/trails/no-throw-in-detour-target.trail.d.ts +14 -0
- package/dist/trails/no-throw-in-detour-target.trail.d.ts.map +1 -0
- package/dist/trails/no-throw-in-detour-target.trail.js +20 -0
- package/dist/trails/no-throw-in-detour-target.trail.js.map +1 -0
- package/dist/trails/no-throw-in-implementation.trail.d.ts +13 -0
- package/dist/trails/no-throw-in-implementation.trail.d.ts.map +1 -0
- package/dist/trails/no-throw-in-implementation.trail.js +20 -0
- package/dist/trails/no-throw-in-implementation.trail.js.map +1 -0
- package/dist/trails/prefer-schema-inference.trail.d.ts +13 -0
- package/dist/trails/prefer-schema-inference.trail.d.ts.map +1 -0
- package/dist/trails/prefer-schema-inference.trail.js +21 -0
- package/dist/trails/prefer-schema-inference.trail.js.map +1 -0
- package/dist/trails/run.d.ts +16 -0
- package/dist/trails/run.d.ts.map +1 -0
- package/dist/trails/run.js +30 -0
- package/dist/trails/run.js.map +1 -0
- package/dist/trails/schema.d.ts +52 -0
- package/dist/trails/schema.d.ts.map +1 -0
- package/dist/trails/schema.js +38 -0
- package/dist/trails/schema.js.map +1 -0
- package/dist/trails/topo.d.ts +3 -0
- package/dist/trails/topo.d.ts.map +1 -0
- package/dist/trails/topo.js +5 -0
- package/dist/trails/topo.js.map +1 -0
- package/dist/trails/valid-describe-refs.trail.d.ts +14 -0
- package/dist/trails/valid-describe-refs.trail.d.ts.map +1 -0
- package/dist/trails/valid-describe-refs.trail.js +18 -0
- package/dist/trails/valid-describe-refs.trail.js.map +1 -0
- package/dist/trails/valid-detour-refs.trail.d.ts +14 -0
- package/dist/trails/valid-detour-refs.trail.d.ts.map +1 -0
- package/dist/trails/valid-detour-refs.trail.js +24 -0
- package/dist/trails/valid-detour-refs.trail.js.map +1 -0
- package/dist/trails/wrap-rule.d.ts +29 -0
- package/dist/trails/wrap-rule.d.ts.map +1 -0
- package/dist/trails/wrap-rule.js +43 -0
- package/dist/trails/wrap-rule.js.map +1 -0
- package/package.json +5 -4
- package/src/__tests__/cli.test.ts +7 -7
- package/src/__tests__/drift.test.ts +1 -1
- package/src/__tests__/follow-declarations.test.ts +303 -0
- 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__/trails.test.ts +19 -0
- package/src/__tests__/valid-describe-refs.test.ts +4 -4
- package/src/cli.ts +1 -4
- package/src/index.ts +21 -0
- package/src/rules/ast.ts +126 -57
- package/src/rules/context-no-surface-types.ts +1 -1
- package/src/rules/follow-declarations.ts +380 -0
- package/src/rules/implementation-returns-result.ts +63 -6
- package/src/rules/index.ts +4 -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
- package/src/trails/context-no-surface-types.trail.ts +21 -0
- package/src/trails/follow-declarations.trail.ts +22 -0
- package/src/trails/implementation-returns-result.trail.ts +20 -0
- package/src/trails/index.ts +14 -0
- package/src/trails/no-direct-impl-in-route.trail.ts +22 -0
- package/src/trails/no-direct-implementation-call.trail.ts +16 -0
- package/src/trails/no-sync-result-assumption.trail.ts +19 -0
- package/src/trails/no-throw-in-detour-target.trail.ts +20 -0
- package/src/trails/no-throw-in-implementation.trail.ts +20 -0
- package/src/trails/prefer-schema-inference.trail.ts +21 -0
- package/src/trails/run.ts +40 -0
- package/src/trails/schema.ts +46 -0
- package/src/trails/topo.ts +6 -0
- package/src/trails/valid-describe-refs.trail.ts +18 -0
- package/src/trails/valid-detour-refs.trail.ts +24 -0
- package/src/trails/wrap-rule.ts +84 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validates that `ctx.follow()` calls match the declared `follow` array.
|
|
3
|
+
*
|
|
4
|
+
* Statically analyzes trail run functions to find `ctx.follow('trailId', ...)`
|
|
5
|
+
* calls and compares them against the `follow: [...]` declaration in the trail
|
|
6
|
+
* config. Reports errors for undeclared follows and warnings for unused ones.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
findConfigProperty,
|
|
11
|
+
findRunBodies,
|
|
12
|
+
findTrailDefinitions,
|
|
13
|
+
offsetToLine,
|
|
14
|
+
parse,
|
|
15
|
+
walk,
|
|
16
|
+
} from './ast.js';
|
|
17
|
+
import type { AstNode } from './ast.js';
|
|
18
|
+
import { isTestFile } from './scan.js';
|
|
19
|
+
import type { WardenDiagnostic, WardenRule } from './types.js';
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Shared identifier helpers
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
/** Get the name of an Identifier node, or null. */
|
|
26
|
+
const identifierName = (node: AstNode | undefined): string | null => {
|
|
27
|
+
if (node?.type !== 'Identifier') {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
return (node as unknown as { name?: string }).name ?? null;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// String literal helpers
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
/** Check if a node is a string literal (covers `StringLiteral` and `Literal` with string value). */
|
|
38
|
+
const isStringLiteral = (node: AstNode): boolean => {
|
|
39
|
+
if (node.type === 'StringLiteral') {
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
if (node.type === 'Literal') {
|
|
43
|
+
return typeof (node as unknown as { value?: unknown }).value === 'string';
|
|
44
|
+
}
|
|
45
|
+
return false;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** Extract the string value from a string literal node. */
|
|
49
|
+
const getStringValue = (node: AstNode): string | null => {
|
|
50
|
+
const val = (node as unknown as { value?: unknown }).value;
|
|
51
|
+
return typeof val === 'string' ? val : null;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// Const identifier resolution
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Best-effort resolution of `const NAME = 'value'` declarations via regex.
|
|
60
|
+
*
|
|
61
|
+
* Returns the string value if a simple `const <name> = '...'` or `"..."` is
|
|
62
|
+
* found in the source. Returns null for anything more complex.
|
|
63
|
+
*/
|
|
64
|
+
const resolveConstString = (
|
|
65
|
+
name: string,
|
|
66
|
+
sourceCode: string
|
|
67
|
+
): string | null => {
|
|
68
|
+
const pattern = new RegExp(
|
|
69
|
+
`const\\s+${name}\\s*=\\s*(?:'([^']*)'|"([^"]*)")`
|
|
70
|
+
);
|
|
71
|
+
const match = pattern.exec(sourceCode);
|
|
72
|
+
if (!match) {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
return match[1] ?? match[2] ?? null;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/** Try to resolve an Identifier element to a string via const declaration. */
|
|
79
|
+
const resolveIdentifierElement = (
|
|
80
|
+
el: AstNode,
|
|
81
|
+
sourceCode: string
|
|
82
|
+
): string | null => {
|
|
83
|
+
const name = identifierName(el);
|
|
84
|
+
if (!name) {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
return resolveConstString(name, sourceCode);
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
// Declared follow extraction
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
/** Extract the ArrayExpression elements from a config's `follow` property. */
|
|
95
|
+
const getFollowElements = (config: AstNode): readonly AstNode[] | null => {
|
|
96
|
+
const followProp = findConfigProperty(config, 'follow');
|
|
97
|
+
if (!followProp) {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const arrayNode = followProp.value;
|
|
102
|
+
if (!arrayNode || (arrayNode as AstNode).type !== 'ArrayExpression') {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const elements = (arrayNode as AstNode)['elements'] as
|
|
107
|
+
| readonly AstNode[]
|
|
108
|
+
| undefined;
|
|
109
|
+
return elements ?? null;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
/** Collect string IDs from array elements, resolving identifiers when possible. */
|
|
113
|
+
const collectStringIds = (
|
|
114
|
+
elements: readonly AstNode[],
|
|
115
|
+
sourceCode: string
|
|
116
|
+
): Set<string> => {
|
|
117
|
+
const ids = new Set<string>();
|
|
118
|
+
for (const el of elements) {
|
|
119
|
+
if (isStringLiteral(el)) {
|
|
120
|
+
const val = getStringValue(el);
|
|
121
|
+
if (val) {
|
|
122
|
+
ids.add(val);
|
|
123
|
+
}
|
|
124
|
+
} else if (el.type === 'Identifier') {
|
|
125
|
+
const resolved = resolveIdentifierElement(el, sourceCode);
|
|
126
|
+
if (resolved) {
|
|
127
|
+
ids.add(resolved);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return ids;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
/** Extract string literal elements from a `follow: [...]` array property. */
|
|
135
|
+
const extractDeclaredFollows = (
|
|
136
|
+
config: AstNode,
|
|
137
|
+
sourceCode: string
|
|
138
|
+
): ReadonlySet<string> => {
|
|
139
|
+
const elements = getFollowElements(config);
|
|
140
|
+
return elements ? collectStringIds(elements, sourceCode) : new Set();
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
// Called follow extraction — member expression helpers
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
const MEMBER_TYPES = new Set(['StaticMemberExpression', 'MemberExpression']);
|
|
148
|
+
|
|
149
|
+
/** Extract object and property Identifier names from a MemberExpression. */
|
|
150
|
+
const extractMemberPair = (
|
|
151
|
+
callee: AstNode
|
|
152
|
+
): { objName: string; propName: string } | null => {
|
|
153
|
+
if (!MEMBER_TYPES.has(callee.type)) {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const objName = identifierName(
|
|
158
|
+
(callee as unknown as { object?: AstNode }).object
|
|
159
|
+
);
|
|
160
|
+
const propName = identifierName(
|
|
161
|
+
(callee as unknown as { property?: AstNode }).property
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
return objName && propName ? { objName, propName } : null;
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
/** Extract the first argument string from a CallExpression's arguments list. */
|
|
168
|
+
const extractFirstStringArg = (node: AstNode): string | null => {
|
|
169
|
+
const args = node['arguments'] as readonly AstNode[] | undefined;
|
|
170
|
+
if (!args || args.length === 0) {
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const [firstArg] = args;
|
|
175
|
+
if (!firstArg || !isStringLiteral(firstArg)) {
|
|
176
|
+
// Dynamic ID — cannot resolve statically
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return getStringValue(firstArg);
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Extract the second parameter name from a run function node.
|
|
185
|
+
*
|
|
186
|
+
* Handles `(input, ctx) => ...` and `async (input, context) => ...` and
|
|
187
|
+
* `function(input, ctx) { ... }` forms.
|
|
188
|
+
*/
|
|
189
|
+
const extractContextParamName = (runBody: AstNode): string | null => {
|
|
190
|
+
const params = runBody['params'] as readonly AstNode[] | undefined;
|
|
191
|
+
if (!params || params.length < 2) {
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
return identifierName(params[1]);
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
/** Check if a callee is a member-style follow call: <ctxName>.follow(...). */
|
|
198
|
+
const isMemberFollowCall = (
|
|
199
|
+
callee: AstNode,
|
|
200
|
+
ctxNames: ReadonlySet<string>
|
|
201
|
+
): boolean => {
|
|
202
|
+
const pair = extractMemberPair(callee);
|
|
203
|
+
return !!pair && ctxNames.has(pair.objName) && pair.propName === 'follow';
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Check if a node is a `<ctxName>.follow(...)` call and return the string trail ID.
|
|
208
|
+
*
|
|
209
|
+
* Also matches bare `follow(...)` calls (destructured pattern).
|
|
210
|
+
*/
|
|
211
|
+
const extractFollowCallId = (
|
|
212
|
+
node: AstNode,
|
|
213
|
+
ctxNames: ReadonlySet<string>
|
|
214
|
+
): string | null => {
|
|
215
|
+
if (node.type !== 'CallExpression') {
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const callee = node['callee'] as AstNode | undefined;
|
|
220
|
+
if (!callee) {
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (isMemberFollowCall(callee, ctxNames)) {
|
|
225
|
+
return extractFirstStringArg(node);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Match bare follow(...) — destructured pattern
|
|
229
|
+
if (identifierName(callee) === 'follow') {
|
|
230
|
+
return extractFirstStringArg(node);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return null;
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
/** Build the set of context parameter names to match against. */
|
|
237
|
+
const buildCtxNames = (body: AstNode): ReadonlySet<string> => {
|
|
238
|
+
const ctxNames = new Set(['ctx', 'context']);
|
|
239
|
+
const paramName = extractContextParamName(body);
|
|
240
|
+
if (paramName) {
|
|
241
|
+
ctxNames.add(paramName);
|
|
242
|
+
}
|
|
243
|
+
return ctxNames;
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
/** Walk run bodies and collect all statically resolvable ctx.follow() trail IDs. */
|
|
247
|
+
const extractCalledFollows = (config: AstNode): ReadonlySet<string> => {
|
|
248
|
+
const ids = new Set<string>();
|
|
249
|
+
|
|
250
|
+
for (const body of findRunBodies(config)) {
|
|
251
|
+
const ctxNames = buildCtxNames(body);
|
|
252
|
+
|
|
253
|
+
walk(body, (node) => {
|
|
254
|
+
const id = extractFollowCallId(node, ctxNames);
|
|
255
|
+
if (id) {
|
|
256
|
+
ids.add(id);
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return ids;
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
// ---------------------------------------------------------------------------
|
|
265
|
+
// Diagnostic builders
|
|
266
|
+
// ---------------------------------------------------------------------------
|
|
267
|
+
|
|
268
|
+
const buildUndeclaredDiagnostic = (
|
|
269
|
+
trailId: string,
|
|
270
|
+
followId: string,
|
|
271
|
+
filePath: string,
|
|
272
|
+
line: number
|
|
273
|
+
): WardenDiagnostic => ({
|
|
274
|
+
filePath,
|
|
275
|
+
line,
|
|
276
|
+
message: `Trail "${trailId}": ctx.follow('${followId}') called but '${followId}' is not declared in follow`,
|
|
277
|
+
rule: 'follow-declarations',
|
|
278
|
+
severity: 'error',
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
const buildUnusedDiagnostic = (
|
|
282
|
+
trailId: string,
|
|
283
|
+
followId: string,
|
|
284
|
+
filePath: string,
|
|
285
|
+
line: number
|
|
286
|
+
): WardenDiagnostic => ({
|
|
287
|
+
filePath,
|
|
288
|
+
line,
|
|
289
|
+
message: `Trail "${trailId}": '${followId}' declared in follow but ctx.follow('${followId}') never called`,
|
|
290
|
+
rule: 'follow-declarations',
|
|
291
|
+
severity: 'warn',
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
// ---------------------------------------------------------------------------
|
|
295
|
+
// Comparison
|
|
296
|
+
// ---------------------------------------------------------------------------
|
|
297
|
+
|
|
298
|
+
/** Emit error for each called ID not present in declared set. */
|
|
299
|
+
const reportUndeclared = (
|
|
300
|
+
called: ReadonlySet<string>,
|
|
301
|
+
declared: ReadonlySet<string>,
|
|
302
|
+
ctx: { trailId: string; filePath: string; line: number },
|
|
303
|
+
diagnostics: WardenDiagnostic[]
|
|
304
|
+
): void => {
|
|
305
|
+
for (const id of called) {
|
|
306
|
+
if (!declared.has(id)) {
|
|
307
|
+
diagnostics.push(
|
|
308
|
+
buildUndeclaredDiagnostic(ctx.trailId, id, ctx.filePath, ctx.line)
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
/** Emit warning for each declared ID not present in called set. */
|
|
315
|
+
const reportUnused = (
|
|
316
|
+
declared: ReadonlySet<string>,
|
|
317
|
+
called: ReadonlySet<string>,
|
|
318
|
+
ctx: { trailId: string; filePath: string; line: number },
|
|
319
|
+
diagnostics: WardenDiagnostic[]
|
|
320
|
+
): void => {
|
|
321
|
+
for (const id of declared) {
|
|
322
|
+
if (!called.has(id)) {
|
|
323
|
+
diagnostics.push(
|
|
324
|
+
buildUnusedDiagnostic(ctx.trailId, id, ctx.filePath, ctx.line)
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
const checkTrailDefinition = (
|
|
331
|
+
def: { id: string; config: AstNode; start: number },
|
|
332
|
+
filePath: string,
|
|
333
|
+
sourceCode: string,
|
|
334
|
+
diagnostics: WardenDiagnostic[]
|
|
335
|
+
): void => {
|
|
336
|
+
const declared = extractDeclaredFollows(def.config, sourceCode);
|
|
337
|
+
const called = extractCalledFollows(def.config);
|
|
338
|
+
|
|
339
|
+
if (declared.size === 0 && called.size === 0) {
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const line = offsetToLine(sourceCode, def.start);
|
|
344
|
+
const ctx = { filePath, line, trailId: def.id };
|
|
345
|
+
|
|
346
|
+
reportUndeclared(called, declared, ctx, diagnostics);
|
|
347
|
+
reportUnused(declared, called, ctx, diagnostics);
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
// ---------------------------------------------------------------------------
|
|
351
|
+
// Rule
|
|
352
|
+
// ---------------------------------------------------------------------------
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Validates that `ctx.follow()` calls align with declared `follow` arrays.
|
|
356
|
+
*/
|
|
357
|
+
export const followDeclarations: WardenRule = {
|
|
358
|
+
check(sourceCode: string, filePath: string): readonly WardenDiagnostic[] {
|
|
359
|
+
if (isTestFile(filePath)) {
|
|
360
|
+
return [];
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const ast = parse(filePath, sourceCode);
|
|
364
|
+
if (!ast) {
|
|
365
|
+
return [];
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const diagnostics: WardenDiagnostic[] = [];
|
|
369
|
+
|
|
370
|
+
for (const def of findTrailDefinitions(ast)) {
|
|
371
|
+
checkTrailDefinition(def, filePath, sourceCode, diagnostics);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
return diagnostics;
|
|
375
|
+
},
|
|
376
|
+
description:
|
|
377
|
+
'Ensure ctx.follow() calls match the declared follow array in trail definitions.',
|
|
378
|
+
name: 'follow-declarations',
|
|
379
|
+
severity: 'error',
|
|
380
|
+
};
|
|
@@ -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
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { contextNoSurfaceTypes } from './context-no-surface-types.js';
|
|
2
|
+
import { followDeclarations } from './follow-declarations.js';
|
|
2
3
|
import { implementationReturnsResult } from './implementation-returns-result.js';
|
|
3
4
|
import { noDirectImplInRoute } from './no-direct-impl-in-route.js';
|
|
4
5
|
import { noDirectImplementationCall } from './no-direct-implementation-call.js';
|
|
@@ -20,6 +21,7 @@ export type {
|
|
|
20
21
|
|
|
21
22
|
export { noThrowInImplementation } from './no-throw-in-implementation.js';
|
|
22
23
|
export { contextNoSurfaceTypes } from './context-no-surface-types.js';
|
|
24
|
+
export { followDeclarations } from './follow-declarations.js';
|
|
23
25
|
export { validDetourRefs } from './valid-detour-refs.js';
|
|
24
26
|
export { noDirectImplInRoute } from './no-direct-impl-in-route.js';
|
|
25
27
|
export { noDirectImplementationCall } from './no-direct-implementation-call.js';
|
|
@@ -29,20 +31,14 @@ export { noThrowInDetourTarget } from './no-throw-in-detour-target.js';
|
|
|
29
31
|
export { preferSchemaInference } from './prefer-schema-inference.js';
|
|
30
32
|
export { validDescribeRefs } from './valid-describe-refs.js';
|
|
31
33
|
|
|
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
|
-
*/
|
|
34
|
+
/** All built-in warden rules, keyed by rule name. */
|
|
40
35
|
export const wardenRules: ReadonlyMap<string, WardenRule> = new Map<
|
|
41
36
|
string,
|
|
42
37
|
WardenRule
|
|
43
38
|
>([
|
|
44
39
|
[noThrowInImplementation.name, noThrowInImplementation],
|
|
45
40
|
[contextNoSurfaceTypes.name, contextNoSurfaceTypes],
|
|
41
|
+
[followDeclarations.name, followDeclarations],
|
|
46
42
|
[preferSchemaInference.name, preferSchemaInference],
|
|
47
43
|
[validDescribeRefs.name, validDescribeRefs],
|
|
48
44
|
[validDetourRefs.name, validDetourRefs],
|
|
@@ -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
|
};
|