@ontrails/warden 1.0.0-beta.4 → 1.0.0-beta.5
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 +16 -0
- package/README.md +1 -0
- 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 +195 -0
- package/dist/rules/follow-declarations.js.map +1 -0
- package/dist/rules/index.d.ts +1 -0
- package/dist/rules/index.d.ts.map +1 -1
- package/dist/rules/index.js +3 -0
- package/dist/rules/index.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/follow-declarations.test.ts +151 -0
- package/src/rules/follow-declarations.ts +283 -0
- package/src/rules/index.ts +3 -0
- package/tsconfig.tsbuildinfo +1 -1
package/.turbo/turbo-lint.log
CHANGED
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# @ontrails/warden
|
|
2
2
|
|
|
3
|
+
## 1.0.0-beta.5
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- Type utilities and follow-declarations warden rule.
|
|
8
|
+
|
|
9
|
+
**core**: Add `TrailInput<T>`, `TrailOutput<T>` utility types and `inputOf()`, `outputOf()` runtime schema accessors.
|
|
10
|
+
|
|
11
|
+
**warden**: Add `follow-declarations` rule — statically analyzes `ctx.follow()` calls against declared `follow: [...]` arrays. Errors on undeclared calls, warns on unused declarations.
|
|
12
|
+
|
|
13
|
+
### Patch Changes
|
|
14
|
+
|
|
15
|
+
- Updated dependencies
|
|
16
|
+
- @ontrails/core@1.0.0-beta.5
|
|
17
|
+
- @ontrails/schema@1.0.0-beta.5
|
|
18
|
+
|
|
3
19
|
## 1.0.0-beta.4
|
|
4
20
|
|
|
5
21
|
### Major Changes
|
package/README.md
CHANGED
|
@@ -37,6 +37,7 @@ console.log(formatWardenReport(report));
|
|
|
37
37
|
| `no-direct-implementation-call` | warn | Direct `.run()` calls bypassing `ctx.follow()` |
|
|
38
38
|
| `no-direct-impl-in-route` | warn | Direct `.run()` calls inside trail bodies with `follow` |
|
|
39
39
|
| `prefer-schema-inference` | warn | Redundant field overrides already derivable from the schema |
|
|
40
|
+
| `follow-declarations` | error/warn | `ctx.follow()` calls that drift from declared `follow: [...]` |
|
|
40
41
|
| `valid-describe-refs` | warn | `@see` refs in `.describe()` that do not resolve |
|
|
41
42
|
|
|
42
43
|
## Drift detection
|
|
@@ -0,0 +1,13 @@
|
|
|
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
|
+
import type { WardenRule } from './types.js';
|
|
9
|
+
/**
|
|
10
|
+
* Validates that `ctx.follow()` calls align with declared `follow` arrays.
|
|
11
|
+
*/
|
|
12
|
+
export declare const followDeclarations: WardenRule;
|
|
13
|
+
//# sourceMappingURL=follow-declarations.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"follow-declarations.d.ts","sourceRoot":"","sources":["../../src/rules/follow-declarations.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAYH,OAAO,KAAK,EAAoB,UAAU,EAAE,MAAM,YAAY,CAAC;AA8O/D;;GAEG;AACH,eAAO,MAAM,kBAAkB,EAAE,UAuBhC,CAAC"}
|
|
@@ -0,0 +1,195 @@
|
|
|
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
|
+
import { findConfigProperty, findRunBodies, findTrailDefinitions, offsetToLine, parse, walk, } from './ast.js';
|
|
9
|
+
import { isTestFile } from './scan.js';
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// String literal helpers
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
/** Check if a node is a string literal (covers `StringLiteral` and `Literal` with string value). */
|
|
14
|
+
const isStringLiteral = (node) => {
|
|
15
|
+
if (node.type === 'StringLiteral') {
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
18
|
+
if (node.type === 'Literal') {
|
|
19
|
+
return typeof node.value === 'string';
|
|
20
|
+
}
|
|
21
|
+
return false;
|
|
22
|
+
};
|
|
23
|
+
/** Extract the string value from a string literal node. */
|
|
24
|
+
const getStringValue = (node) => {
|
|
25
|
+
const val = node.value;
|
|
26
|
+
return typeof val === 'string' ? val : null;
|
|
27
|
+
};
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// Declared follow extraction
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
/** Extract the ArrayExpression elements from a config's `follow` property. */
|
|
32
|
+
const getFollowElements = (config) => {
|
|
33
|
+
const followProp = findConfigProperty(config, 'follow');
|
|
34
|
+
if (!followProp) {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
const arrayNode = followProp.value;
|
|
38
|
+
if (!arrayNode || arrayNode.type !== 'ArrayExpression') {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
const elements = arrayNode['elements'];
|
|
42
|
+
return elements ?? null;
|
|
43
|
+
};
|
|
44
|
+
/** Collect string IDs from array elements. */
|
|
45
|
+
const collectStringIds = (elements) => {
|
|
46
|
+
const ids = new Set();
|
|
47
|
+
for (const el of elements) {
|
|
48
|
+
if (isStringLiteral(el)) {
|
|
49
|
+
const val = getStringValue(el);
|
|
50
|
+
if (val) {
|
|
51
|
+
ids.add(val);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return ids;
|
|
56
|
+
};
|
|
57
|
+
/** Extract string literal elements from a `follow: [...]` array property. */
|
|
58
|
+
const extractDeclaredFollows = (config) => {
|
|
59
|
+
const elements = getFollowElements(config);
|
|
60
|
+
return elements ? collectStringIds(elements) : new Set();
|
|
61
|
+
};
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
// Called follow extraction — member expression helpers
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
const MEMBER_TYPES = new Set(['StaticMemberExpression', 'MemberExpression']);
|
|
66
|
+
/** Get the name of an Identifier node, or null. */
|
|
67
|
+
const identifierName = (node) => {
|
|
68
|
+
if (node?.type !== 'Identifier') {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
return node.name ?? null;
|
|
72
|
+
};
|
|
73
|
+
/** Extract object and property Identifier names from a MemberExpression. */
|
|
74
|
+
const extractMemberPair = (callee) => {
|
|
75
|
+
if (!MEMBER_TYPES.has(callee.type)) {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
const objName = identifierName(callee.object);
|
|
79
|
+
const propName = identifierName(callee.property);
|
|
80
|
+
return objName && propName ? { objName, propName } : null;
|
|
81
|
+
};
|
|
82
|
+
/** Extract the first argument string from a CallExpression's arguments list. */
|
|
83
|
+
const extractFirstStringArg = (node) => {
|
|
84
|
+
const args = node['arguments'];
|
|
85
|
+
if (!args || args.length === 0) {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
const [firstArg] = args;
|
|
89
|
+
if (!firstArg || !isStringLiteral(firstArg)) {
|
|
90
|
+
// Dynamic ID — cannot resolve statically
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
return getStringValue(firstArg);
|
|
94
|
+
};
|
|
95
|
+
/** Check if a node is a `ctx.follow(...)` call and return the string trail ID. */
|
|
96
|
+
const extractFollowCallId = (node) => {
|
|
97
|
+
if (node.type !== 'CallExpression') {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
const callee = node['callee'];
|
|
101
|
+
if (!callee) {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
const pair = extractMemberPair(callee);
|
|
105
|
+
if (!pair || pair.objName !== 'ctx' || pair.propName !== 'follow') {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
return extractFirstStringArg(node);
|
|
109
|
+
};
|
|
110
|
+
/** Walk run bodies and collect all statically resolvable ctx.follow() trail IDs. */
|
|
111
|
+
const extractCalledFollows = (config) => {
|
|
112
|
+
const ids = new Set();
|
|
113
|
+
for (const body of findRunBodies(config)) {
|
|
114
|
+
walk(body, (node) => {
|
|
115
|
+
const id = extractFollowCallId(node);
|
|
116
|
+
if (id) {
|
|
117
|
+
ids.add(id);
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
return ids;
|
|
122
|
+
};
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
// Diagnostic builders
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
const buildUndeclaredDiagnostic = (trailId, followId, filePath, line) => ({
|
|
127
|
+
filePath,
|
|
128
|
+
line,
|
|
129
|
+
message: `Trail "${trailId}": ctx.follow('${followId}') called but '${followId}' is not declared in follow`,
|
|
130
|
+
rule: 'follow-declarations',
|
|
131
|
+
severity: 'error',
|
|
132
|
+
});
|
|
133
|
+
const buildUnusedDiagnostic = (trailId, followId, filePath, line) => ({
|
|
134
|
+
filePath,
|
|
135
|
+
line,
|
|
136
|
+
message: `Trail "${trailId}": '${followId}' declared in follow but ctx.follow('${followId}') never called`,
|
|
137
|
+
rule: 'follow-declarations',
|
|
138
|
+
severity: 'warn',
|
|
139
|
+
});
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
// Comparison
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
/** Emit error for each called ID not present in declared set. */
|
|
144
|
+
const reportUndeclared = (called, declared, ctx, diagnostics) => {
|
|
145
|
+
for (const id of called) {
|
|
146
|
+
if (!declared.has(id)) {
|
|
147
|
+
diagnostics.push(buildUndeclaredDiagnostic(ctx.trailId, id, ctx.filePath, ctx.line));
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
/** Emit warning for each declared ID not present in called set. */
|
|
152
|
+
const reportUnused = (declared, called, ctx, diagnostics) => {
|
|
153
|
+
for (const id of declared) {
|
|
154
|
+
if (!called.has(id)) {
|
|
155
|
+
diagnostics.push(buildUnusedDiagnostic(ctx.trailId, id, ctx.filePath, ctx.line));
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
const checkTrailDefinition = (def, filePath, sourceCode, diagnostics) => {
|
|
160
|
+
const declared = extractDeclaredFollows(def.config);
|
|
161
|
+
const called = extractCalledFollows(def.config);
|
|
162
|
+
if (declared.size === 0 && called.size === 0) {
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
const line = offsetToLine(sourceCode, def.start);
|
|
166
|
+
const ctx = { filePath, line, trailId: def.id };
|
|
167
|
+
reportUndeclared(called, declared, ctx, diagnostics);
|
|
168
|
+
reportUnused(declared, called, ctx, diagnostics);
|
|
169
|
+
};
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
// Rule
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
/**
|
|
174
|
+
* Validates that `ctx.follow()` calls align with declared `follow` arrays.
|
|
175
|
+
*/
|
|
176
|
+
export const followDeclarations = {
|
|
177
|
+
check(sourceCode, filePath) {
|
|
178
|
+
if (isTestFile(filePath)) {
|
|
179
|
+
return [];
|
|
180
|
+
}
|
|
181
|
+
const ast = parse(filePath, sourceCode);
|
|
182
|
+
if (!ast) {
|
|
183
|
+
return [];
|
|
184
|
+
}
|
|
185
|
+
const diagnostics = [];
|
|
186
|
+
for (const def of findTrailDefinitions(ast)) {
|
|
187
|
+
checkTrailDefinition(def, filePath, sourceCode, diagnostics);
|
|
188
|
+
}
|
|
189
|
+
return diagnostics;
|
|
190
|
+
},
|
|
191
|
+
description: 'Ensure ctx.follow() calls match the declared follow array in trail definitions.',
|
|
192
|
+
name: 'follow-declarations',
|
|
193
|
+
severity: 'error',
|
|
194
|
+
};
|
|
195
|
+
//# sourceMappingURL=follow-declarations.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"follow-declarations.js","sourceRoot":"","sources":["../../src/rules/follow-declarations.ts"],"names":[],"mappings":"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"}
|
package/dist/rules/index.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { WardenRule } from './types.js';
|
|
|
2
2
|
export type { ProjectAwareWardenRule, ProjectContext, WardenDiagnostic, WardenRule, WardenSeverity, } from './types.js';
|
|
3
3
|
export { noThrowInImplementation } from './no-throw-in-implementation.js';
|
|
4
4
|
export { contextNoSurfaceTypes } from './context-no-surface-types.js';
|
|
5
|
+
export { followDeclarations } from './follow-declarations.js';
|
|
5
6
|
export { validDetourRefs } from './valid-detour-refs.js';
|
|
6
7
|
export { noDirectImplInRoute } from './no-direct-impl-in-route.js';
|
|
7
8
|
export { noDirectImplementationCall } from './no-direct-implementation-call.js';
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/rules/index.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/rules/index.ts"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAI7C,YAAY,EACV,sBAAsB,EACtB,cAAc,EACd,gBAAgB,EAChB,UAAU,EACV,cAAc,GACf,MAAM,YAAY,CAAC;AAEpB,OAAO,EAAE,uBAAuB,EAAE,MAAM,iCAAiC,CAAC;AAC1E,OAAO,EAAE,qBAAqB,EAAE,MAAM,+BAA+B,CAAC;AACtE,OAAO,EAAE,kBAAkB,EAAE,MAAM,0BAA0B,CAAC;AAC9D,OAAO,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AACzD,OAAO,EAAE,mBAAmB,EAAE,MAAM,8BAA8B,CAAC;AACnE,OAAO,EAAE,0BAA0B,EAAE,MAAM,oCAAoC,CAAC;AAChF,OAAO,EAAE,sBAAsB,EAAE,MAAM,gCAAgC,CAAC;AACxE,OAAO,EAAE,2BAA2B,EAAE,MAAM,oCAAoC,CAAC;AACjF,OAAO,EAAE,qBAAqB,EAAE,MAAM,gCAAgC,CAAC;AACvE,OAAO,EAAE,qBAAqB,EAAE,MAAM,8BAA8B,CAAC;AACrE,OAAO,EAAE,iBAAiB,EAAE,MAAM,0BAA0B,CAAC;AAE7D,qDAAqD;AACrD,eAAO,MAAM,WAAW,EAAE,WAAW,CAAC,MAAM,EAAE,UAAU,CAetD,CAAC"}
|
package/dist/rules/index.js
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';
|
|
@@ -10,6 +11,7 @@ import { validDescribeRefs } from './valid-describe-refs.js';
|
|
|
10
11
|
import { validDetourRefs } from './valid-detour-refs.js';
|
|
11
12
|
export { noThrowInImplementation } from './no-throw-in-implementation.js';
|
|
12
13
|
export { contextNoSurfaceTypes } from './context-no-surface-types.js';
|
|
14
|
+
export { followDeclarations } from './follow-declarations.js';
|
|
13
15
|
export { validDetourRefs } from './valid-detour-refs.js';
|
|
14
16
|
export { noDirectImplInRoute } from './no-direct-impl-in-route.js';
|
|
15
17
|
export { noDirectImplementationCall } from './no-direct-implementation-call.js';
|
|
@@ -22,6 +24,7 @@ export { validDescribeRefs } from './valid-describe-refs.js';
|
|
|
22
24
|
export const wardenRules = new Map([
|
|
23
25
|
[noThrowInImplementation.name, noThrowInImplementation],
|
|
24
26
|
[contextNoSurfaceTypes.name, contextNoSurfaceTypes],
|
|
27
|
+
[followDeclarations.name, followDeclarations],
|
|
25
28
|
[preferSchemaInference.name, preferSchemaInference],
|
|
26
29
|
[validDescribeRefs.name, validDescribeRefs],
|
|
27
30
|
[validDetourRefs.name, validDetourRefs],
|
package/dist/rules/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/rules/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,qBAAqB,EAAE,MAAM,+BAA+B,CAAC;AACtE,OAAO,EAAE,2BAA2B,EAAE,MAAM,oCAAoC,CAAC;AACjF,OAAO,EAAE,mBAAmB,EAAE,MAAM,8BAA8B,CAAC;AACnE,OAAO,EAAE,0BAA0B,EAAE,MAAM,oCAAoC,CAAC;AAChF,OAAO,EAAE,sBAAsB,EAAE,MAAM,gCAAgC,CAAC;AACxE,OAAO,EAAE,qBAAqB,EAAE,MAAM,gCAAgC,CAAC;AACvE,OAAO,EAAE,uBAAuB,EAAE,MAAM,iCAAiC,CAAC;AAC1E,OAAO,EAAE,qBAAqB,EAAE,MAAM,8BAA8B,CAAC;AAErE,OAAO,EAAE,iBAAiB,EAAE,MAAM,0BAA0B,CAAC;AAC7D,OAAO,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AAUzD,OAAO,EAAE,uBAAuB,EAAE,MAAM,iCAAiC,CAAC;AAC1E,OAAO,EAAE,qBAAqB,EAAE,MAAM,+BAA+B,CAAC;AACtE,OAAO,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AACzD,OAAO,EAAE,mBAAmB,EAAE,MAAM,8BAA8B,CAAC;AACnE,OAAO,EAAE,0BAA0B,EAAE,MAAM,oCAAoC,CAAC;AAChF,OAAO,EAAE,sBAAsB,EAAE,MAAM,gCAAgC,CAAC;AACxE,OAAO,EAAE,2BAA2B,EAAE,MAAM,oCAAoC,CAAC;AACjF,OAAO,EAAE,qBAAqB,EAAE,MAAM,gCAAgC,CAAC;AACvE,OAAO,EAAE,qBAAqB,EAAE,MAAM,8BAA8B,CAAC;AACrE,OAAO,EAAE,iBAAiB,EAAE,MAAM,0BAA0B,CAAC;AAE7D,qDAAqD;AACrD,MAAM,CAAC,MAAM,WAAW,GAAoC,IAAI,GAAG,CAGjE;IACA,CAAC,uBAAuB,CAAC,IAAI,EAAE,uBAAuB,CAAC;IACvD,CAAC,qBAAqB,CAAC,IAAI,EAAE,qBAAqB,CAAC;IACnD,CAAC,qBAAqB,CAAC,IAAI,EAAE,qBAAqB,CAAC;IACnD,CAAC,iBAAiB,CAAC,IAAI,EAAE,iBAAiB,CAAC;IAC3C,CAAC,eAAe,CAAC,IAAI,EAAE,eAAe,CAAC;IACvC,CAAC,0BAA0B,CAAC,IAAI,EAAE,0BAA0B,CAAC;IAC7D,CAAC,sBAAsB,CAAC,IAAI,EAAE,sBAAsB,CAAC;IACrD,CAAC,2BAA2B,CAAC,IAAI,EAAE,2BAA2B,CAAC;IAC/D,CAAC,qBAAqB,CAAC,IAAI,EAAE,qBAAqB,CAAC;IACnD,CAAC,mBAAmB,CAAC,IAAI,EAAE,mBAAmB,CAAC;CAChD,CAAC,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/rules/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,qBAAqB,EAAE,MAAM,+BAA+B,CAAC;AACtE,OAAO,EAAE,kBAAkB,EAAE,MAAM,0BAA0B,CAAC;AAC9D,OAAO,EAAE,2BAA2B,EAAE,MAAM,oCAAoC,CAAC;AACjF,OAAO,EAAE,mBAAmB,EAAE,MAAM,8BAA8B,CAAC;AACnE,OAAO,EAAE,0BAA0B,EAAE,MAAM,oCAAoC,CAAC;AAChF,OAAO,EAAE,sBAAsB,EAAE,MAAM,gCAAgC,CAAC;AACxE,OAAO,EAAE,qBAAqB,EAAE,MAAM,gCAAgC,CAAC;AACvE,OAAO,EAAE,uBAAuB,EAAE,MAAM,iCAAiC,CAAC;AAC1E,OAAO,EAAE,qBAAqB,EAAE,MAAM,8BAA8B,CAAC;AAErE,OAAO,EAAE,iBAAiB,EAAE,MAAM,0BAA0B,CAAC;AAC7D,OAAO,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AAUzD,OAAO,EAAE,uBAAuB,EAAE,MAAM,iCAAiC,CAAC;AAC1E,OAAO,EAAE,qBAAqB,EAAE,MAAM,+BAA+B,CAAC;AACtE,OAAO,EAAE,kBAAkB,EAAE,MAAM,0BAA0B,CAAC;AAC9D,OAAO,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AACzD,OAAO,EAAE,mBAAmB,EAAE,MAAM,8BAA8B,CAAC;AACnE,OAAO,EAAE,0BAA0B,EAAE,MAAM,oCAAoC,CAAC;AAChF,OAAO,EAAE,sBAAsB,EAAE,MAAM,gCAAgC,CAAC;AACxE,OAAO,EAAE,2BAA2B,EAAE,MAAM,oCAAoC,CAAC;AACjF,OAAO,EAAE,qBAAqB,EAAE,MAAM,gCAAgC,CAAC;AACvE,OAAO,EAAE,qBAAqB,EAAE,MAAM,8BAA8B,CAAC;AACrE,OAAO,EAAE,iBAAiB,EAAE,MAAM,0BAA0B,CAAC;AAE7D,qDAAqD;AACrD,MAAM,CAAC,MAAM,WAAW,GAAoC,IAAI,GAAG,CAGjE;IACA,CAAC,uBAAuB,CAAC,IAAI,EAAE,uBAAuB,CAAC;IACvD,CAAC,qBAAqB,CAAC,IAAI,EAAE,qBAAqB,CAAC;IACnD,CAAC,kBAAkB,CAAC,IAAI,EAAE,kBAAkB,CAAC;IAC7C,CAAC,qBAAqB,CAAC,IAAI,EAAE,qBAAqB,CAAC;IACnD,CAAC,iBAAiB,CAAC,IAAI,EAAE,iBAAiB,CAAC;IAC3C,CAAC,eAAe,CAAC,IAAI,EAAE,eAAe,CAAC;IACvC,CAAC,0BAA0B,CAAC,IAAI,EAAE,0BAA0B,CAAC;IAC7D,CAAC,sBAAsB,CAAC,IAAI,EAAE,sBAAsB,CAAC;IACrD,CAAC,2BAA2B,CAAC,IAAI,EAAE,2BAA2B,CAAC;IAC/D,CAAC,qBAAqB,CAAC,IAAI,EAAE,qBAAqB,CAAC;IACnD,CAAC,mBAAmB,CAAC,IAAI,EAAE,mBAAmB,CAAC;CAChD,CAAC,CAAC"}
|
package/package.json
CHANGED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
import { followDeclarations } from '../rules/follow-declarations.js';
|
|
4
|
+
|
|
5
|
+
const TEST_FILE = 'test.ts';
|
|
6
|
+
|
|
7
|
+
describe('follow-declarations', () => {
|
|
8
|
+
describe('clean cases', () => {
|
|
9
|
+
test('declared and called match exactly', () => {
|
|
10
|
+
const code = `
|
|
11
|
+
import { trail, Result } from '@ontrails/core';
|
|
12
|
+
const t = trail('onboard', {
|
|
13
|
+
follow: ['entity.add', 'search'],
|
|
14
|
+
input: z.object({ name: z.string() }),
|
|
15
|
+
run: async (input, ctx) => {
|
|
16
|
+
await ctx.follow('entity.add', { name: input.name });
|
|
17
|
+
await ctx.follow('search', { query: input.name });
|
|
18
|
+
return Result.ok({});
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
`;
|
|
22
|
+
|
|
23
|
+
const diagnostics = followDeclarations.check(code, TEST_FILE);
|
|
24
|
+
|
|
25
|
+
expect(diagnostics.length).toBe(0);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('no follow declaration and no ctx.follow() calls', () => {
|
|
29
|
+
const code = `
|
|
30
|
+
trail('simple', {
|
|
31
|
+
input: z.object({ name: z.string() }),
|
|
32
|
+
run: async (input, ctx) => {
|
|
33
|
+
return Result.ok({ greeting: 'hello ' + input.name });
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
`;
|
|
37
|
+
|
|
38
|
+
const diagnostics = followDeclarations.check(code, TEST_FILE);
|
|
39
|
+
|
|
40
|
+
expect(diagnostics.length).toBe(0);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe('error cases', () => {
|
|
45
|
+
test('called but not declared produces error', () => {
|
|
46
|
+
const code = `
|
|
47
|
+
trail('onboard', {
|
|
48
|
+
input: z.object({ name: z.string() }),
|
|
49
|
+
run: async (input, ctx) => {
|
|
50
|
+
await ctx.follow('entity.add', { name: input.name });
|
|
51
|
+
return Result.ok({});
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
`;
|
|
55
|
+
|
|
56
|
+
const diagnostics = followDeclarations.check(code, TEST_FILE);
|
|
57
|
+
|
|
58
|
+
expect(diagnostics.length).toBe(1);
|
|
59
|
+
expect(diagnostics[0]?.severity).toBe('error');
|
|
60
|
+
expect(diagnostics[0]?.rule).toBe('follow-declarations');
|
|
61
|
+
expect(diagnostics[0]?.message).toContain("ctx.follow('entity.add')");
|
|
62
|
+
expect(diagnostics[0]?.message).toContain('not declared in follow');
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe('warn cases', () => {
|
|
67
|
+
test('declared but not called produces warning', () => {
|
|
68
|
+
const code = `
|
|
69
|
+
trail('onboard', {
|
|
70
|
+
follow: ['entity.add', 'search'],
|
|
71
|
+
run: async (input, ctx) => {
|
|
72
|
+
await ctx.follow('entity.add', { name: input.name });
|
|
73
|
+
return Result.ok({});
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
`;
|
|
77
|
+
|
|
78
|
+
const diagnostics = followDeclarations.check(code, TEST_FILE);
|
|
79
|
+
|
|
80
|
+
expect(diagnostics.length).toBe(1);
|
|
81
|
+
expect(diagnostics[0]?.severity).toBe('warn');
|
|
82
|
+
expect(diagnostics[0]?.rule).toBe('follow-declarations');
|
|
83
|
+
expect(diagnostics[0]?.message).toContain("'search' declared in follow");
|
|
84
|
+
expect(diagnostics[0]?.message).toContain('never called');
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe('edge cases', () => {
|
|
89
|
+
test('dynamic follow IDs are skipped', () => {
|
|
90
|
+
const code = `
|
|
91
|
+
trail('dispatch', {
|
|
92
|
+
follow: ['entity.add'],
|
|
93
|
+
run: async (input, ctx) => {
|
|
94
|
+
const trailId = input.target;
|
|
95
|
+
await ctx.follow(trailId, input);
|
|
96
|
+
await ctx.follow('entity.add', input);
|
|
97
|
+
return Result.ok({});
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
`;
|
|
101
|
+
|
|
102
|
+
const diagnostics = followDeclarations.check(code, TEST_FILE);
|
|
103
|
+
|
|
104
|
+
expect(diagnostics.length).toBe(0);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('multiple trails in one file are validated independently', () => {
|
|
108
|
+
const code = `
|
|
109
|
+
trail('alpha', {
|
|
110
|
+
follow: ['shared'],
|
|
111
|
+
run: async (input, ctx) => {
|
|
112
|
+
await ctx.follow('shared', input);
|
|
113
|
+
return Result.ok({});
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
trail('beta', {
|
|
118
|
+
run: async (input, ctx) => {
|
|
119
|
+
await ctx.follow('undeclared', input);
|
|
120
|
+
return Result.ok({});
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
`;
|
|
124
|
+
|
|
125
|
+
const diagnostics = followDeclarations.check(code, TEST_FILE);
|
|
126
|
+
|
|
127
|
+
expect(diagnostics.length).toBe(1);
|
|
128
|
+
expect(diagnostics[0]?.message).toContain('Trail "beta"');
|
|
129
|
+
expect(diagnostics[0]?.message).toContain("'undeclared'");
|
|
130
|
+
expect(diagnostics[0]?.severity).toBe('error');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('skips test files', () => {
|
|
134
|
+
const code = `
|
|
135
|
+
trail('onboard', {
|
|
136
|
+
run: async (input, ctx) => {
|
|
137
|
+
await ctx.follow('entity.add', input);
|
|
138
|
+
return Result.ok({});
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
`;
|
|
142
|
+
|
|
143
|
+
const diagnostics = followDeclarations.check(
|
|
144
|
+
code,
|
|
145
|
+
'src/__tests__/trails.test.ts'
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
expect(diagnostics.length).toBe(0);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
});
|
|
@@ -0,0 +1,283 @@
|
|
|
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
|
+
// String literal helpers
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
/** Check if a node is a string literal (covers `StringLiteral` and `Literal` with string value). */
|
|
26
|
+
const isStringLiteral = (node: AstNode): boolean => {
|
|
27
|
+
if (node.type === 'StringLiteral') {
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
if (node.type === 'Literal') {
|
|
31
|
+
return typeof (node as unknown as { value?: unknown }).value === 'string';
|
|
32
|
+
}
|
|
33
|
+
return false;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** Extract the string value from a string literal node. */
|
|
37
|
+
const getStringValue = (node: AstNode): string | null => {
|
|
38
|
+
const val = (node as unknown as { value?: unknown }).value;
|
|
39
|
+
return typeof val === 'string' ? val : null;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// Declared follow extraction
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
/** Extract the ArrayExpression elements from a config's `follow` property. */
|
|
47
|
+
const getFollowElements = (config: AstNode): readonly AstNode[] | null => {
|
|
48
|
+
const followProp = findConfigProperty(config, 'follow');
|
|
49
|
+
if (!followProp) {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const arrayNode = followProp.value;
|
|
54
|
+
if (!arrayNode || (arrayNode as AstNode).type !== 'ArrayExpression') {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const elements = (arrayNode as AstNode)['elements'] as
|
|
59
|
+
| readonly AstNode[]
|
|
60
|
+
| undefined;
|
|
61
|
+
return elements ?? null;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** Collect string IDs from array elements. */
|
|
65
|
+
const collectStringIds = (elements: readonly AstNode[]): Set<string> => {
|
|
66
|
+
const ids = new Set<string>();
|
|
67
|
+
for (const el of elements) {
|
|
68
|
+
if (isStringLiteral(el)) {
|
|
69
|
+
const val = getStringValue(el);
|
|
70
|
+
if (val) {
|
|
71
|
+
ids.add(val);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return ids;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/** Extract string literal elements from a `follow: [...]` array property. */
|
|
79
|
+
const extractDeclaredFollows = (config: AstNode): ReadonlySet<string> => {
|
|
80
|
+
const elements = getFollowElements(config);
|
|
81
|
+
return elements ? collectStringIds(elements) : new Set();
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
// Called follow extraction — member expression helpers
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
const MEMBER_TYPES = new Set(['StaticMemberExpression', 'MemberExpression']);
|
|
89
|
+
|
|
90
|
+
/** Get the name of an Identifier node, or null. */
|
|
91
|
+
const identifierName = (node: AstNode | undefined): string | null => {
|
|
92
|
+
if (node?.type !== 'Identifier') {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
return (node as unknown as { name?: string }).name ?? null;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/** Extract object and property Identifier names from a MemberExpression. */
|
|
99
|
+
const extractMemberPair = (
|
|
100
|
+
callee: AstNode
|
|
101
|
+
): { objName: string; propName: string } | null => {
|
|
102
|
+
if (!MEMBER_TYPES.has(callee.type)) {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const objName = identifierName(
|
|
107
|
+
(callee as unknown as { object?: AstNode }).object
|
|
108
|
+
);
|
|
109
|
+
const propName = identifierName(
|
|
110
|
+
(callee as unknown as { property?: AstNode }).property
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
return objName && propName ? { objName, propName } : null;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
/** Extract the first argument string from a CallExpression's arguments list. */
|
|
117
|
+
const extractFirstStringArg = (node: AstNode): string | null => {
|
|
118
|
+
const args = node['arguments'] as readonly AstNode[] | undefined;
|
|
119
|
+
if (!args || args.length === 0) {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const [firstArg] = args;
|
|
124
|
+
if (!firstArg || !isStringLiteral(firstArg)) {
|
|
125
|
+
// Dynamic ID — cannot resolve statically
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return getStringValue(firstArg);
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
/** Check if a node is a `ctx.follow(...)` call and return the string trail ID. */
|
|
133
|
+
const extractFollowCallId = (node: AstNode): string | null => {
|
|
134
|
+
if (node.type !== 'CallExpression') {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const callee = node['callee'] as AstNode | undefined;
|
|
139
|
+
if (!callee) {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const pair = extractMemberPair(callee);
|
|
144
|
+
if (!pair || pair.objName !== 'ctx' || pair.propName !== 'follow') {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return extractFirstStringArg(node);
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
/** Walk run bodies and collect all statically resolvable ctx.follow() trail IDs. */
|
|
152
|
+
const extractCalledFollows = (config: AstNode): ReadonlySet<string> => {
|
|
153
|
+
const ids = new Set<string>();
|
|
154
|
+
|
|
155
|
+
for (const body of findRunBodies(config)) {
|
|
156
|
+
walk(body, (node) => {
|
|
157
|
+
const id = extractFollowCallId(node);
|
|
158
|
+
if (id) {
|
|
159
|
+
ids.add(id);
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return ids;
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
// Diagnostic builders
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
const buildUndeclaredDiagnostic = (
|
|
172
|
+
trailId: string,
|
|
173
|
+
followId: string,
|
|
174
|
+
filePath: string,
|
|
175
|
+
line: number
|
|
176
|
+
): WardenDiagnostic => ({
|
|
177
|
+
filePath,
|
|
178
|
+
line,
|
|
179
|
+
message: `Trail "${trailId}": ctx.follow('${followId}') called but '${followId}' is not declared in follow`,
|
|
180
|
+
rule: 'follow-declarations',
|
|
181
|
+
severity: 'error',
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
const buildUnusedDiagnostic = (
|
|
185
|
+
trailId: string,
|
|
186
|
+
followId: string,
|
|
187
|
+
filePath: string,
|
|
188
|
+
line: number
|
|
189
|
+
): WardenDiagnostic => ({
|
|
190
|
+
filePath,
|
|
191
|
+
line,
|
|
192
|
+
message: `Trail "${trailId}": '${followId}' declared in follow but ctx.follow('${followId}') never called`,
|
|
193
|
+
rule: 'follow-declarations',
|
|
194
|
+
severity: 'warn',
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// ---------------------------------------------------------------------------
|
|
198
|
+
// Comparison
|
|
199
|
+
// ---------------------------------------------------------------------------
|
|
200
|
+
|
|
201
|
+
/** Emit error for each called ID not present in declared set. */
|
|
202
|
+
const reportUndeclared = (
|
|
203
|
+
called: ReadonlySet<string>,
|
|
204
|
+
declared: ReadonlySet<string>,
|
|
205
|
+
ctx: { trailId: string; filePath: string; line: number },
|
|
206
|
+
diagnostics: WardenDiagnostic[]
|
|
207
|
+
): void => {
|
|
208
|
+
for (const id of called) {
|
|
209
|
+
if (!declared.has(id)) {
|
|
210
|
+
diagnostics.push(
|
|
211
|
+
buildUndeclaredDiagnostic(ctx.trailId, id, ctx.filePath, ctx.line)
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
/** Emit warning for each declared ID not present in called set. */
|
|
218
|
+
const reportUnused = (
|
|
219
|
+
declared: ReadonlySet<string>,
|
|
220
|
+
called: ReadonlySet<string>,
|
|
221
|
+
ctx: { trailId: string; filePath: string; line: number },
|
|
222
|
+
diagnostics: WardenDiagnostic[]
|
|
223
|
+
): void => {
|
|
224
|
+
for (const id of declared) {
|
|
225
|
+
if (!called.has(id)) {
|
|
226
|
+
diagnostics.push(
|
|
227
|
+
buildUnusedDiagnostic(ctx.trailId, id, ctx.filePath, ctx.line)
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
const checkTrailDefinition = (
|
|
234
|
+
def: { id: string; config: AstNode; start: number },
|
|
235
|
+
filePath: string,
|
|
236
|
+
sourceCode: string,
|
|
237
|
+
diagnostics: WardenDiagnostic[]
|
|
238
|
+
): void => {
|
|
239
|
+
const declared = extractDeclaredFollows(def.config);
|
|
240
|
+
const called = extractCalledFollows(def.config);
|
|
241
|
+
|
|
242
|
+
if (declared.size === 0 && called.size === 0) {
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const line = offsetToLine(sourceCode, def.start);
|
|
247
|
+
const ctx = { filePath, line, trailId: def.id };
|
|
248
|
+
|
|
249
|
+
reportUndeclared(called, declared, ctx, diagnostics);
|
|
250
|
+
reportUnused(declared, called, ctx, diagnostics);
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
// ---------------------------------------------------------------------------
|
|
254
|
+
// Rule
|
|
255
|
+
// ---------------------------------------------------------------------------
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Validates that `ctx.follow()` calls align with declared `follow` arrays.
|
|
259
|
+
*/
|
|
260
|
+
export const followDeclarations: WardenRule = {
|
|
261
|
+
check(sourceCode: string, filePath: string): readonly WardenDiagnostic[] {
|
|
262
|
+
if (isTestFile(filePath)) {
|
|
263
|
+
return [];
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const ast = parse(filePath, sourceCode);
|
|
267
|
+
if (!ast) {
|
|
268
|
+
return [];
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const diagnostics: WardenDiagnostic[] = [];
|
|
272
|
+
|
|
273
|
+
for (const def of findTrailDefinitions(ast)) {
|
|
274
|
+
checkTrailDefinition(def, filePath, sourceCode, diagnostics);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return diagnostics;
|
|
278
|
+
},
|
|
279
|
+
description:
|
|
280
|
+
'Ensure ctx.follow() calls match the declared follow array in trail definitions.',
|
|
281
|
+
name: 'follow-declarations',
|
|
282
|
+
severity: 'error',
|
|
283
|
+
};
|
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';
|
|
@@ -36,6 +38,7 @@ export const wardenRules: ReadonlyMap<string, WardenRule> = new Map<
|
|
|
36
38
|
>([
|
|
37
39
|
[noThrowInImplementation.name, noThrowInImplementation],
|
|
38
40
|
[contextNoSurfaceTypes.name, contextNoSurfaceTypes],
|
|
41
|
+
[followDeclarations.name, followDeclarations],
|
|
39
42
|
[preferSchemaInference.name, preferSchemaInference],
|
|
40
43
|
[validDescribeRefs.name, validDescribeRefs],
|
|
41
44
|
[validDetourRefs.name, validDetourRefs],
|
package/tsconfig.tsbuildinfo
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"root":["./src/cli.ts","./src/drift.ts","./src/formatters.ts","./src/index.ts","./src/rules/ast.ts","./src/rules/context-no-surface-types.ts","./src/rules/implementation-returns-result.ts","./src/rules/index.ts","./src/rules/no-direct-impl-in-route.ts","./src/rules/no-direct-implementation-call.ts","./src/rules/no-sync-result-assumption.ts","./src/rules/no-throw-in-detour-target.ts","./src/rules/no-throw-in-implementation.ts","./src/rules/prefer-schema-inference.ts","./src/rules/scan.ts","./src/rules/specs.ts","./src/rules/structure.ts","./src/rules/types.ts","./src/rules/valid-describe-refs.ts","./src/rules/valid-detour-refs.ts"],"version":"5.9.3"}
|
|
1
|
+
{"root":["./src/cli.ts","./src/drift.ts","./src/formatters.ts","./src/index.ts","./src/rules/ast.ts","./src/rules/context-no-surface-types.ts","./src/rules/follow-declarations.ts","./src/rules/implementation-returns-result.ts","./src/rules/index.ts","./src/rules/no-direct-impl-in-route.ts","./src/rules/no-direct-implementation-call.ts","./src/rules/no-sync-result-assumption.ts","./src/rules/no-throw-in-detour-target.ts","./src/rules/no-throw-in-implementation.ts","./src/rules/prefer-schema-inference.ts","./src/rules/scan.ts","./src/rules/specs.ts","./src/rules/structure.ts","./src/rules/types.ts","./src/rules/valid-describe-refs.ts","./src/rules/valid-detour-refs.ts"],"version":"5.9.3"}
|