@ontrails/warden 1.0.0-beta.24 → 1.0.0-beta.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +102 -0
- package/README.md +35 -1
- package/package.json +10 -9
- package/src/ast.ts +109 -0
- package/src/cli.ts +44 -4
- package/src/command.ts +24 -34
- package/src/drift.ts +24 -8
- package/src/index.ts +9 -0
- package/src/project-rules.ts +290 -0
- package/src/resolve.ts +8 -7
- package/src/rules/ast.ts +837 -6
- package/src/rules/cli-command-route-coherence.ts +177 -0
- package/src/rules/composes-declarations.ts +210 -77
- package/src/rules/context-no-surface-types.ts +15 -12
- package/src/rules/contour-exists.ts +7 -7
- package/src/rules/dead-internal-trail.ts +11 -4
- package/src/rules/dead-public-trail.ts +258 -0
- package/src/rules/duplicate-public-contract.ts +91 -0
- package/src/rules/error-mapping-completeness.ts +5 -3
- package/src/rules/example-valid.ts +6 -12
- package/src/rules/fires-declarations.ts +41 -64
- package/src/rules/implementation-returns-result.ts +208 -76
- package/src/rules/incomplete-crud.ts +20 -19
- package/src/rules/index.ts +15 -0
- package/src/rules/layer-field-name-drift.ts +12 -6
- package/src/rules/library-projection-coherence.ts +100 -0
- package/src/rules/metadata.ts +102 -1
- package/src/rules/no-destructured-compose.ts +16 -14
- package/src/rules/no-native-error-result.ts +15 -8
- package/src/rules/no-redundant-result-error-wrap.ts +82 -29
- package/src/rules/no-sync-result-assumption.ts +70 -68
- package/src/rules/no-top-level-surface.ts +46 -64
- package/src/rules/on-references-exist.ts +1 -1
- package/src/rules/owner-projection-parity.ts +10 -13
- package/src/rules/public-export-example-coverage.ts +29 -20
- package/src/rules/public-internal-deep-imports.ts +2 -2
- package/src/rules/read-intent-fires.ts +8 -8
- package/src/rules/registry-names.ts +10 -0
- package/src/rules/resource-declarations.ts +20 -31
- package/src/rules/resource-exists.ts +2 -2
- package/src/rules/resource-id-grammar.ts +2 -2
- package/src/rules/resource-mock-coverage.ts +2 -2
- package/src/rules/static-resource-accessor-preference.ts +26 -34
- package/src/rules/surface-facet-coherence.ts +21 -29
- package/src/rules/trail-fork-coaching.ts +616 -0
- package/src/rules/trail-versioning-source.ts +56 -78
- package/src/rules/types.ts +2 -0
- package/src/rules/unreachable-detour-shadowing.ts +16 -21
- package/src/rules/valid-describe-refs.ts +14 -12
- package/src/rules/warden-export-symmetry.ts +42 -35
- package/src/rules/warden-rules-use-ast.ts +168 -50
- package/src/trails/cli-command-route-coherence.trail.ts +47 -0
- package/src/trails/dead-public-trail.trail.ts +31 -0
- package/src/trails/duplicate-public-contract.trail.ts +47 -0
- package/src/trails/error-mapping-completeness.trail.ts +1 -0
- package/src/trails/index.ts +5 -0
- package/src/trails/library-projection-coherence.trail.ts +43 -0
- package/src/trails/schema.ts +4 -0
- package/src/trails/trail-fork-coaching.trail.ts +42 -0
- package/src/trails/warden-rules-use-ast.trail.ts +19 -0
- package/src/trails/wrap-rule.ts +1 -0
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { deriveTrailCliCommandProjection } from '@ontrails/core';
|
|
2
|
+
import type { CliCommandRoute, Topo } from '@ontrails/core';
|
|
3
|
+
import type { TopoGraph } from '@ontrails/topographer';
|
|
4
|
+
|
|
5
|
+
import type { TopoAwareWardenRule, WardenDiagnostic } from './types.js';
|
|
6
|
+
|
|
7
|
+
const RULE_NAME = 'cli-command-route-coherence';
|
|
8
|
+
const TOPO_FILE = '<topo>';
|
|
9
|
+
|
|
10
|
+
interface RouteClaim {
|
|
11
|
+
readonly kind: CliCommandRoute['kind'];
|
|
12
|
+
readonly path: readonly string[];
|
|
13
|
+
readonly source: CliCommandRoute['source'];
|
|
14
|
+
readonly trailId: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const routeKey = (path: readonly string[]): string => path.join('\0');
|
|
18
|
+
|
|
19
|
+
const renderPath = (path: readonly string[]): string => path.join(' ');
|
|
20
|
+
|
|
21
|
+
const claimLabel = (claim: RouteClaim): string =>
|
|
22
|
+
`${claim.kind} route for trail "${claim.trailId}" (${claim.source})`;
|
|
23
|
+
|
|
24
|
+
const buildProjectionDiagnostic = (
|
|
25
|
+
trailId: string,
|
|
26
|
+
error: unknown
|
|
27
|
+
): WardenDiagnostic => ({
|
|
28
|
+
filePath: TOPO_FILE,
|
|
29
|
+
line: 1,
|
|
30
|
+
message: `CLI command route projection for trail "${trailId}" is invalid: ${
|
|
31
|
+
error instanceof Error ? error.message : String(error)
|
|
32
|
+
}`,
|
|
33
|
+
rule: RULE_NAME,
|
|
34
|
+
severity: 'error',
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const buildCollisionDiagnostic = (
|
|
38
|
+
path: readonly string[],
|
|
39
|
+
claims: readonly RouteClaim[]
|
|
40
|
+
): WardenDiagnostic => ({
|
|
41
|
+
filePath: TOPO_FILE,
|
|
42
|
+
line: 1,
|
|
43
|
+
message: `CLI command route collision on "${renderPath(path)}": ${claims
|
|
44
|
+
.toSorted((a, b) => a.trailId.localeCompare(b.trailId))
|
|
45
|
+
.map(claimLabel)
|
|
46
|
+
.join(
|
|
47
|
+
', '
|
|
48
|
+
)}. Rename or remove one CLI alias so every accepted command path normalizes into exactly one trail contract.`,
|
|
49
|
+
rule: RULE_NAME,
|
|
50
|
+
severity: 'error',
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const buildGraphTargetDiagnostic = (
|
|
54
|
+
entryId: string,
|
|
55
|
+
route: CliCommandRoute,
|
|
56
|
+
knownTrailIds: ReadonlySet<string>
|
|
57
|
+
): WardenDiagnostic => ({
|
|
58
|
+
filePath: TOPO_FILE,
|
|
59
|
+
line: 1,
|
|
60
|
+
message: knownTrailIds.has(route.target)
|
|
61
|
+
? `Serialized CLI command route "${renderPath(route.path)}" is stored under trail "${entryId}" but targets "${route.target}". Route facts must stay attached to their owning trail.`
|
|
62
|
+
: `Serialized CLI command route "${renderPath(route.path)}" targets unknown trail "${route.target}". Surface-owned aliases must target existing trail IDs.`,
|
|
63
|
+
rule: RULE_NAME,
|
|
64
|
+
severity: 'error',
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const collectTopoClaims = (
|
|
68
|
+
topo: Topo
|
|
69
|
+
): {
|
|
70
|
+
readonly claims: readonly RouteClaim[];
|
|
71
|
+
readonly diagnostics: readonly WardenDiagnostic[];
|
|
72
|
+
} => {
|
|
73
|
+
const claims: RouteClaim[] = [];
|
|
74
|
+
const diagnostics: WardenDiagnostic[] = [];
|
|
75
|
+
|
|
76
|
+
for (const trail of topo.list()) {
|
|
77
|
+
try {
|
|
78
|
+
const projection = deriveTrailCliCommandProjection(trail);
|
|
79
|
+
for (const route of projection.routes) {
|
|
80
|
+
claims.push({
|
|
81
|
+
kind: route.kind,
|
|
82
|
+
path: route.path,
|
|
83
|
+
source: route.source,
|
|
84
|
+
trailId: trail.id,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
} catch (error: unknown) {
|
|
88
|
+
diagnostics.push(buildProjectionDiagnostic(trail.id, error));
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return { claims, diagnostics };
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const collectGraphClaims = (
|
|
96
|
+
graph: TopoGraph | undefined
|
|
97
|
+
): readonly RouteClaim[] => {
|
|
98
|
+
if (graph === undefined) {
|
|
99
|
+
return [];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const claims: RouteClaim[] = [];
|
|
103
|
+
for (const entry of graph.entries) {
|
|
104
|
+
if (entry.kind !== 'trail') {
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
for (const route of entry.cli?.routes ?? []) {
|
|
108
|
+
claims.push({
|
|
109
|
+
kind: route.kind,
|
|
110
|
+
path: route.path,
|
|
111
|
+
source: route.source,
|
|
112
|
+
trailId: entry.id,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return claims;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const collectCollisionDiagnostics = (
|
|
120
|
+
claims: readonly RouteClaim[]
|
|
121
|
+
): readonly WardenDiagnostic[] => {
|
|
122
|
+
const claimsByRoute = new Map<string, RouteClaim[]>();
|
|
123
|
+
for (const claim of claims) {
|
|
124
|
+
const key = routeKey(claim.path);
|
|
125
|
+
const current = claimsByRoute.get(key) ?? [];
|
|
126
|
+
current.push(claim);
|
|
127
|
+
claimsByRoute.set(key, current);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return [...claimsByRoute.values()]
|
|
131
|
+
.filter((grouped) => grouped.length > 1)
|
|
132
|
+
.map((grouped) =>
|
|
133
|
+
buildCollisionDiagnostic(grouped[0]?.path ?? [], grouped)
|
|
134
|
+
);
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const collectGraphTargetDiagnostics = (
|
|
138
|
+
graph: TopoGraph | undefined,
|
|
139
|
+
knownTrailIds: ReadonlySet<string>
|
|
140
|
+
): readonly WardenDiagnostic[] => {
|
|
141
|
+
if (graph === undefined) {
|
|
142
|
+
return [];
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const diagnostics: WardenDiagnostic[] = [];
|
|
146
|
+
for (const entry of graph.entries) {
|
|
147
|
+
if (entry.kind !== 'trail') {
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
for (const route of entry.cli?.routes ?? []) {
|
|
151
|
+
if (route.target !== entry.id || !knownTrailIds.has(route.target)) {
|
|
152
|
+
diagnostics.push(
|
|
153
|
+
buildGraphTargetDiagnostic(entry.id, route, knownTrailIds)
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return diagnostics;
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
export const cliCommandRouteCoherence: TopoAwareWardenRule = {
|
|
162
|
+
checkTopo(topo, context) {
|
|
163
|
+
const { claims, diagnostics } = collectTopoClaims(topo);
|
|
164
|
+
const knownTrailIds = new Set(topo.trails.keys());
|
|
165
|
+
const routeClaims =
|
|
166
|
+
context?.graph === undefined ? claims : collectGraphClaims(context.graph);
|
|
167
|
+
return [
|
|
168
|
+
...diagnostics,
|
|
169
|
+
...collectCollisionDiagnostics(routeClaims),
|
|
170
|
+
...collectGraphTargetDiagnostics(context?.graph, knownTrailIds),
|
|
171
|
+
];
|
|
172
|
+
},
|
|
173
|
+
description:
|
|
174
|
+
'Ensure CLI command routes and aliases resolve to one coherent trail contract.',
|
|
175
|
+
name: RULE_NAME,
|
|
176
|
+
severity: 'error',
|
|
177
|
+
};
|
|
@@ -7,12 +7,31 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import {
|
|
10
|
-
findConfigProperty,
|
|
11
10
|
findBlazeBodies,
|
|
11
|
+
findConfigProperty,
|
|
12
12
|
findTrailDefinitions,
|
|
13
|
+
getNodeArguments,
|
|
14
|
+
getNodeBodyNode,
|
|
15
|
+
getNodeBodyStatements,
|
|
16
|
+
getNodeCallee,
|
|
17
|
+
getNodeDeclaration,
|
|
18
|
+
getNodeDeclarations,
|
|
19
|
+
getNodeId,
|
|
20
|
+
getNodeInit,
|
|
21
|
+
getNodeKey,
|
|
22
|
+
getNodeKind,
|
|
23
|
+
getNodeLeft,
|
|
24
|
+
getNodeName,
|
|
25
|
+
getNodeObject,
|
|
26
|
+
getNodeParams,
|
|
27
|
+
getNodeProperties,
|
|
28
|
+
getNodeProperty,
|
|
29
|
+
getNodeValue,
|
|
30
|
+
getNodeValueNode,
|
|
31
|
+
isShadowed,
|
|
13
32
|
offsetToLine,
|
|
14
33
|
parse,
|
|
15
|
-
|
|
34
|
+
walkWithScopes,
|
|
16
35
|
} from './ast.js';
|
|
17
36
|
import type { AstNode } from './ast.js';
|
|
18
37
|
import { isTestFile } from './scan.js';
|
|
@@ -27,7 +46,7 @@ const identifierName = (node: AstNode | undefined): string | null => {
|
|
|
27
46
|
if (node?.type !== 'Identifier') {
|
|
28
47
|
return null;
|
|
29
48
|
}
|
|
30
|
-
return (node
|
|
49
|
+
return getNodeName(node) ?? null;
|
|
31
50
|
};
|
|
32
51
|
|
|
33
52
|
// ---------------------------------------------------------------------------
|
|
@@ -40,14 +59,14 @@ const isStringLiteral = (node: AstNode): boolean => {
|
|
|
40
59
|
return true;
|
|
41
60
|
}
|
|
42
61
|
if (node.type === 'Literal') {
|
|
43
|
-
return typeof (node
|
|
62
|
+
return typeof getNodeValue(node) === 'string';
|
|
44
63
|
}
|
|
45
64
|
return false;
|
|
46
65
|
};
|
|
47
66
|
|
|
48
67
|
/** Extract the string value from a string literal node. */
|
|
49
68
|
const getStringValue = (node: AstNode): string | null => {
|
|
50
|
-
const val = (node
|
|
69
|
+
const val = getNodeValue(node);
|
|
51
70
|
return typeof val === 'string' ? val : null;
|
|
52
71
|
};
|
|
53
72
|
|
|
@@ -194,12 +213,8 @@ const extractMemberPair = (
|
|
|
194
213
|
return null;
|
|
195
214
|
}
|
|
196
215
|
|
|
197
|
-
const objName = identifierName(
|
|
198
|
-
|
|
199
|
-
);
|
|
200
|
-
const propName = identifierName(
|
|
201
|
-
(callee as unknown as { property?: AstNode }).property
|
|
202
|
-
);
|
|
216
|
+
const objName = identifierName(getNodeObject(callee));
|
|
217
|
+
const propName = identifierName(getNodeProperty(callee));
|
|
203
218
|
|
|
204
219
|
return objName && propName ? { objName, propName } : null;
|
|
205
220
|
};
|
|
@@ -219,7 +234,7 @@ const extractContextParamName = (blazeBody: AstNode): string | null => {
|
|
|
219
234
|
}
|
|
220
235
|
const [, param] = params;
|
|
221
236
|
if (param?.type === 'AssignmentPattern') {
|
|
222
|
-
const
|
|
237
|
+
const left = getNodeLeft(param);
|
|
223
238
|
return identifierName(left);
|
|
224
239
|
}
|
|
225
240
|
return identifierName(param);
|
|
@@ -230,10 +245,8 @@ const extractComposeLocalName = (prop: AstNode): string | null => {
|
|
|
230
245
|
if (prop.type !== 'Property') {
|
|
231
246
|
return null;
|
|
232
247
|
}
|
|
233
|
-
const
|
|
234
|
-
|
|
235
|
-
readonly value?: AstNode;
|
|
236
|
-
};
|
|
248
|
+
const key = getNodeKey(prop);
|
|
249
|
+
const value = getNodeValueNode(prop);
|
|
237
250
|
const keyName = identifierName(key);
|
|
238
251
|
if (keyName !== 'compose') {
|
|
239
252
|
return null;
|
|
@@ -246,9 +259,7 @@ const collectComposeNamesFromPattern = (
|
|
|
246
259
|
pattern: AstNode,
|
|
247
260
|
names: Set<string>
|
|
248
261
|
): void => {
|
|
249
|
-
const
|
|
250
|
-
readonly properties?: readonly AstNode[];
|
|
251
|
-
};
|
|
262
|
+
const properties = getNodeProperties(pattern);
|
|
252
263
|
for (const prop of properties ?? []) {
|
|
253
264
|
const localName = extractComposeLocalName(prop);
|
|
254
265
|
if (localName) {
|
|
@@ -436,10 +447,8 @@ const getCtxDestructurePattern = (
|
|
|
436
447
|
if (node.type !== 'VariableDeclarator') {
|
|
437
448
|
return null;
|
|
438
449
|
}
|
|
439
|
-
const
|
|
440
|
-
|
|
441
|
-
readonly init?: AstNode;
|
|
442
|
-
};
|
|
450
|
+
const id = getNodeId(node);
|
|
451
|
+
const init = getNodeInit(node);
|
|
443
452
|
if (!id || id.type !== 'ObjectPattern' || !init) {
|
|
444
453
|
return null;
|
|
445
454
|
}
|
|
@@ -448,11 +457,14 @@ const getCtxDestructurePattern = (
|
|
|
448
457
|
};
|
|
449
458
|
|
|
450
459
|
const getTopLevelStatements = (body: AstNode): readonly AstNode[] => {
|
|
451
|
-
|
|
460
|
+
if (body.type === 'BlockStatement') {
|
|
461
|
+
return getNodeBodyStatements(body);
|
|
462
|
+
}
|
|
463
|
+
const blockBody = getNodeBodyNode(body);
|
|
452
464
|
if (!blockBody || blockBody.type !== 'BlockStatement') {
|
|
453
465
|
return [];
|
|
454
466
|
}
|
|
455
|
-
return (blockBody
|
|
467
|
+
return getNodeBodyStatements(blockBody);
|
|
456
468
|
};
|
|
457
469
|
|
|
458
470
|
const collectComposeNamesFromDeclaration = (
|
|
@@ -463,13 +475,11 @@ const collectComposeNamesFromDeclaration = (
|
|
|
463
475
|
if (stmt.type !== 'VariableDeclaration') {
|
|
464
476
|
return;
|
|
465
477
|
}
|
|
466
|
-
const
|
|
478
|
+
const kind = getNodeKind(stmt);
|
|
467
479
|
if (kind !== 'const') {
|
|
468
480
|
return;
|
|
469
481
|
}
|
|
470
|
-
const declarations =
|
|
471
|
-
(stmt as unknown as { readonly declarations?: readonly AstNode[] })
|
|
472
|
-
.declarations ?? [];
|
|
482
|
+
const declarations = getNodeDeclarations(stmt);
|
|
473
483
|
for (const decl of declarations) {
|
|
474
484
|
const pattern = getCtxDestructurePattern(decl, ctxNames);
|
|
475
485
|
if (pattern) {
|
|
@@ -489,6 +499,109 @@ const collectDestructuredComposeNames = (
|
|
|
489
499
|
return names;
|
|
490
500
|
};
|
|
491
501
|
|
|
502
|
+
interface ComposeHelper {
|
|
503
|
+
readonly body: AstNode;
|
|
504
|
+
readonly params: readonly AstNode[];
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
const isFunctionLikeNode = (
|
|
508
|
+
node: AstNode | null | undefined
|
|
509
|
+
): node is AstNode =>
|
|
510
|
+
node !== null &&
|
|
511
|
+
node !== undefined &&
|
|
512
|
+
(node.type === 'ArrowFunctionExpression' ||
|
|
513
|
+
node.type === 'FunctionDeclaration' ||
|
|
514
|
+
node.type === 'FunctionExpression');
|
|
515
|
+
|
|
516
|
+
const helperFromFunctionNode = (
|
|
517
|
+
node: AstNode | null | undefined
|
|
518
|
+
): ComposeHelper | null => {
|
|
519
|
+
if (!isFunctionLikeNode(node)) {
|
|
520
|
+
return null;
|
|
521
|
+
}
|
|
522
|
+
const body = getNodeBodyNode(node);
|
|
523
|
+
return body ? { body, params: getNodeParams(node) } : null;
|
|
524
|
+
};
|
|
525
|
+
|
|
526
|
+
const addFunctionHelper = (
|
|
527
|
+
node: AstNode,
|
|
528
|
+
helpers: Map<string, ComposeHelper>
|
|
529
|
+
): void => {
|
|
530
|
+
if (node.type !== 'FunctionDeclaration') {
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
const name = identifierName(getNodeId(node));
|
|
534
|
+
const helper = helperFromFunctionNode(node);
|
|
535
|
+
if (name && helper) {
|
|
536
|
+
helpers.set(name, helper);
|
|
537
|
+
}
|
|
538
|
+
};
|
|
539
|
+
|
|
540
|
+
const addVariableHelpers = (
|
|
541
|
+
node: AstNode,
|
|
542
|
+
helpers: Map<string, ComposeHelper>
|
|
543
|
+
): void => {
|
|
544
|
+
if (node.type !== 'VariableDeclaration' || getNodeKind(node) !== 'const') {
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
for (const declaration of getNodeDeclarations(node)) {
|
|
548
|
+
const name = identifierName(getNodeId(declaration));
|
|
549
|
+
const helper = helperFromFunctionNode(getNodeInit(declaration));
|
|
550
|
+
if (name && helper) {
|
|
551
|
+
helpers.set(name, helper);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
};
|
|
555
|
+
|
|
556
|
+
const unwrapTopLevelDeclaration = (node: AstNode): AstNode =>
|
|
557
|
+
node.type === 'ExportNamedDeclaration'
|
|
558
|
+
? (getNodeDeclaration(node) ?? node)
|
|
559
|
+
: node;
|
|
560
|
+
|
|
561
|
+
const collectComposeHelpers = (
|
|
562
|
+
ast: AstNode
|
|
563
|
+
): ReadonlyMap<string, ComposeHelper> => {
|
|
564
|
+
const helpers = new Map<string, ComposeHelper>();
|
|
565
|
+
for (const statement of getNodeBodyStatements(ast)) {
|
|
566
|
+
const declaration = unwrapTopLevelDeclaration(statement);
|
|
567
|
+
addFunctionHelper(declaration, helpers);
|
|
568
|
+
addVariableHelpers(declaration, helpers);
|
|
569
|
+
}
|
|
570
|
+
return helpers;
|
|
571
|
+
};
|
|
572
|
+
|
|
573
|
+
const collectHelperCtxNames = (
|
|
574
|
+
call: AstNode,
|
|
575
|
+
helper: ComposeHelper,
|
|
576
|
+
ctxNames: ReadonlySet<string>
|
|
577
|
+
): ReadonlySet<string> => {
|
|
578
|
+
const names = new Set<string>();
|
|
579
|
+
const args = getNodeArguments(call);
|
|
580
|
+
for (const [index, param] of helper.params.entries()) {
|
|
581
|
+
const argName = identifierName(args[index]);
|
|
582
|
+
const paramName = identifierName(param);
|
|
583
|
+
if (argName && paramName && ctxNames.has(argName)) {
|
|
584
|
+
names.add(paramName);
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
return names;
|
|
588
|
+
};
|
|
589
|
+
|
|
590
|
+
const findHelperCall = (
|
|
591
|
+
node: AstNode | null | undefined,
|
|
592
|
+
helpers: ReadonlyMap<string, ComposeHelper>
|
|
593
|
+
): { readonly helper: ComposeHelper; readonly name: string } | null => {
|
|
594
|
+
if (node?.type !== 'CallExpression') {
|
|
595
|
+
return null;
|
|
596
|
+
}
|
|
597
|
+
const calleeName = identifierName(getNodeCallee(node));
|
|
598
|
+
if (!calleeName) {
|
|
599
|
+
return null;
|
|
600
|
+
}
|
|
601
|
+
const helper = helpers.get(calleeName);
|
|
602
|
+
return helper ? { helper, name: calleeName } : null;
|
|
603
|
+
};
|
|
604
|
+
|
|
492
605
|
interface CalledComposes {
|
|
493
606
|
/** Statically resolved trail IDs from string literal arguments. */
|
|
494
607
|
readonly ids: ReadonlySet<string>;
|
|
@@ -504,31 +617,67 @@ interface CalledComposes {
|
|
|
504
617
|
const collectComposeCallsFromBody = (
|
|
505
618
|
body: AstNode,
|
|
506
619
|
ids: Set<string>,
|
|
507
|
-
sourceCode: string
|
|
620
|
+
sourceCode: string,
|
|
621
|
+
helpers: ReadonlyMap<string, ComposeHelper>,
|
|
622
|
+
inheritedCtxNames?: ReadonlySet<string>,
|
|
623
|
+
visitedHelpers = new Set<string>()
|
|
508
624
|
): boolean => {
|
|
509
|
-
const ctxNames = buildCtxNames(body);
|
|
625
|
+
const ctxNames = inheritedCtxNames ?? buildCtxNames(body);
|
|
510
626
|
const composeLocalNames = collectDestructuredComposeNames(body, ctxNames);
|
|
627
|
+
const helperNames = new Set(helpers.keys());
|
|
511
628
|
let foundUnresolved = false;
|
|
512
629
|
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
630
|
+
walkWithScopes(
|
|
631
|
+
body,
|
|
632
|
+
(node, scopeStack) => {
|
|
633
|
+
const extracted = extractComposeCall(
|
|
634
|
+
node,
|
|
635
|
+
ctxNames,
|
|
636
|
+
composeLocalNames,
|
|
637
|
+
sourceCode
|
|
638
|
+
);
|
|
639
|
+
if (extracted) {
|
|
640
|
+
if (extracted.hasUnresolved) {
|
|
641
|
+
foundUnresolved = true;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
for (const id of extracted.ids) {
|
|
645
|
+
ids.add(id);
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
const helperCall = findHelperCall(node, helpers);
|
|
650
|
+
if (
|
|
651
|
+
!helperCall ||
|
|
652
|
+
visitedHelpers.has(helperCall.name) ||
|
|
653
|
+
isShadowed(helperCall.name, scopeStack)
|
|
654
|
+
) {
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
const helperCtxNames = collectHelperCtxNames(
|
|
658
|
+
node,
|
|
659
|
+
helperCall.helper,
|
|
660
|
+
ctxNames
|
|
661
|
+
);
|
|
662
|
+
if (helperCtxNames.size === 0) {
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
visitedHelpers.add(helperCall.name);
|
|
666
|
+
if (
|
|
667
|
+
collectComposeCallsFromBody(
|
|
668
|
+
helperCall.helper.body,
|
|
669
|
+
ids,
|
|
670
|
+
sourceCode,
|
|
671
|
+
helpers,
|
|
672
|
+
helperCtxNames,
|
|
673
|
+
visitedHelpers
|
|
674
|
+
)
|
|
675
|
+
) {
|
|
676
|
+
foundUnresolved = true;
|
|
677
|
+
}
|
|
678
|
+
},
|
|
679
|
+
{ initialScopes: [helperNames] }
|
|
680
|
+
);
|
|
532
681
|
|
|
533
682
|
return foundUnresolved;
|
|
534
683
|
};
|
|
@@ -536,13 +685,15 @@ const collectComposeCallsFromBody = (
|
|
|
536
685
|
/** Walk blaze bodies and collect all statically resolvable ctx.compose() trail IDs. */
|
|
537
686
|
const extractCalledComposes = (
|
|
538
687
|
config: AstNode,
|
|
688
|
+
ast: AstNode,
|
|
539
689
|
sourceCode: string
|
|
540
690
|
): CalledComposes => {
|
|
541
691
|
const ids = new Set<string>();
|
|
542
692
|
let hasUnresolved = false;
|
|
693
|
+
const helpers = collectComposeHelpers(ast);
|
|
543
694
|
|
|
544
695
|
for (const body of findBlazeBodies(config)) {
|
|
545
|
-
if (collectComposeCallsFromBody(body, ids, sourceCode)) {
|
|
696
|
+
if (collectComposeCallsFromBody(body, ids, sourceCode, helpers)) {
|
|
546
697
|
hasUnresolved = true;
|
|
547
698
|
}
|
|
548
699
|
}
|
|
@@ -558,16 +709,13 @@ const buildUndeclaredDiagnostic = (
|
|
|
558
709
|
trailId: string,
|
|
559
710
|
composedId: string,
|
|
560
711
|
filePath: string,
|
|
561
|
-
line: number
|
|
562
|
-
softened = false
|
|
712
|
+
line: number
|
|
563
713
|
): WardenDiagnostic => ({
|
|
564
714
|
filePath,
|
|
565
715
|
line,
|
|
566
|
-
message:
|
|
567
|
-
? `Trail "${trailId}": ctx.compose('${composedId}') called but '${composedId}' is not declared in composes (may be declared via trail object references). Add the string id to composes, or use the same trail object form in both composes and ctx.compose(...).`
|
|
568
|
-
: `Trail "${trailId}": ctx.compose('${composedId}') called but '${composedId}' is not declared in composes. Add it to the trail composes array: composes: ['${composedId}', ...].`,
|
|
716
|
+
message: `Trail "${trailId}": ctx.compose('${composedId}') called but '${composedId}' is not declared in composes. Add it to the trail composes array: composes: ['${composedId}', ...].`,
|
|
569
717
|
rule: 'composes-declarations',
|
|
570
|
-
severity:
|
|
718
|
+
severity: 'error',
|
|
571
719
|
});
|
|
572
720
|
|
|
573
721
|
const buildUnusedDiagnostic = (
|
|
@@ -595,20 +743,13 @@ const reportUndeclared = (
|
|
|
595
743
|
trailId: string;
|
|
596
744
|
filePath: string;
|
|
597
745
|
line: number;
|
|
598
|
-
softened?: boolean;
|
|
599
746
|
},
|
|
600
747
|
diagnostics: WardenDiagnostic[]
|
|
601
748
|
): void => {
|
|
602
749
|
for (const id of called) {
|
|
603
750
|
if (!declared.has(id)) {
|
|
604
751
|
diagnostics.push(
|
|
605
|
-
buildUndeclaredDiagnostic(
|
|
606
|
-
ctx.trailId,
|
|
607
|
-
id,
|
|
608
|
-
ctx.filePath,
|
|
609
|
-
ctx.line,
|
|
610
|
-
ctx.softened
|
|
611
|
-
)
|
|
752
|
+
buildUndeclaredDiagnostic(ctx.trailId, id, ctx.filePath, ctx.line)
|
|
612
753
|
);
|
|
613
754
|
}
|
|
614
755
|
}
|
|
@@ -632,12 +773,13 @@ const reportUnused = (
|
|
|
632
773
|
|
|
633
774
|
const checkTrailDefinition = (
|
|
634
775
|
def: { id: string; config: AstNode; start: number },
|
|
776
|
+
ast: AstNode,
|
|
635
777
|
filePath: string,
|
|
636
778
|
sourceCode: string,
|
|
637
779
|
diagnostics: WardenDiagnostic[]
|
|
638
780
|
): void => {
|
|
639
781
|
const declared = extractDeclaredComposes(def.config, sourceCode);
|
|
640
|
-
const called = extractCalledComposes(def.config, sourceCode);
|
|
782
|
+
const called = extractCalledComposes(def.config, ast, sourceCode);
|
|
641
783
|
|
|
642
784
|
if (
|
|
643
785
|
declared.ids.size === 0 &&
|
|
@@ -651,16 +793,7 @@ const checkTrailDefinition = (
|
|
|
651
793
|
const line = offsetToLine(sourceCode, def.start);
|
|
652
794
|
const ctx = { filePath, line, trailId: def.id };
|
|
653
795
|
|
|
654
|
-
|
|
655
|
-
// downgrade "undeclared" diagnostics from error to warn. The developer still
|
|
656
|
-
// sees genuinely undeclared calls, but we can't statically prove the call
|
|
657
|
-
// isn't covered by a trail object entry the runtime will normalize.
|
|
658
|
-
reportUndeclared(
|
|
659
|
-
called.ids,
|
|
660
|
-
declared.ids,
|
|
661
|
-
{ ...ctx, softened: declared.hasUnresolved },
|
|
662
|
-
diagnostics
|
|
663
|
-
);
|
|
796
|
+
reportUndeclared(called.ids, declared.ids, ctx, diagnostics);
|
|
664
797
|
|
|
665
798
|
// When all ctx.compose() calls are statically resolved, report unused
|
|
666
799
|
// declarations. When some calls use trail object references (unresolved),
|
|
@@ -692,7 +825,7 @@ export const composesDeclarations: WardenRule = {
|
|
|
692
825
|
const diagnostics: WardenDiagnostic[] = [];
|
|
693
826
|
|
|
694
827
|
for (const def of findTrailDefinitions(ast)) {
|
|
695
|
-
checkTrailDefinition(def, filePath, sourceCode, diagnostics);
|
|
828
|
+
checkTrailDefinition(def, ast, filePath, sourceCode, diagnostics);
|
|
696
829
|
}
|
|
697
830
|
|
|
698
831
|
return diagnostics;
|
|
@@ -5,7 +5,16 @@
|
|
|
5
5
|
* imports in comments or strings.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
getNodeCallee,
|
|
10
|
+
getNodeComputed,
|
|
11
|
+
getNodeName,
|
|
12
|
+
getNodeProperty,
|
|
13
|
+
offsetToLine,
|
|
14
|
+
parse,
|
|
15
|
+
walk,
|
|
16
|
+
} from './ast.js';
|
|
17
|
+
import type { AstNode } from './ast.js';
|
|
9
18
|
import type { WardenDiagnostic, WardenRule } from './types.js';
|
|
10
19
|
|
|
11
20
|
const SURFACE_MODULES = new Set([
|
|
@@ -29,12 +38,6 @@ const SURFACE_TYPE_NAMES = new Set([
|
|
|
29
38
|
'ServerResponse',
|
|
30
39
|
]);
|
|
31
40
|
|
|
32
|
-
interface AstNode {
|
|
33
|
-
readonly type: string;
|
|
34
|
-
readonly start: number;
|
|
35
|
-
readonly [key: string]: unknown;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
41
|
interface ImportSpecifier {
|
|
39
42
|
readonly local?: { readonly name?: string };
|
|
40
43
|
readonly imported?: { readonly name?: string };
|
|
@@ -44,7 +47,7 @@ const isBareTrailCallee = (callee: AstNode): boolean => {
|
|
|
44
47
|
if (callee.type !== 'Identifier') {
|
|
45
48
|
return false;
|
|
46
49
|
}
|
|
47
|
-
return (callee
|
|
50
|
+
return getNodeName(callee) === 'trail';
|
|
48
51
|
};
|
|
49
52
|
|
|
50
53
|
const isNamespacedTrailCallee = (callee: AstNode): boolean => {
|
|
@@ -57,14 +60,14 @@ const isNamespacedTrailCallee = (callee: AstNode): boolean => {
|
|
|
57
60
|
// Skip computed access like `ns[trail]()` — the bracketed expression may
|
|
58
61
|
// resolve to any runtime value, not the `trail` primitive, even when it
|
|
59
62
|
// happens to be an identifier literally named `trail`.
|
|
60
|
-
if ((callee
|
|
63
|
+
if (getNodeComputed(callee) === true) {
|
|
61
64
|
return false;
|
|
62
65
|
}
|
|
63
|
-
const prop = (callee
|
|
66
|
+
const prop = getNodeProperty(callee);
|
|
64
67
|
if (prop?.type !== 'Identifier') {
|
|
65
68
|
return false;
|
|
66
69
|
}
|
|
67
|
-
return (prop
|
|
70
|
+
return getNodeName(prop) === 'trail';
|
|
68
71
|
};
|
|
69
72
|
|
|
70
73
|
/**
|
|
@@ -90,7 +93,7 @@ const hasTrailCall = (ast: AstNode): boolean => {
|
|
|
90
93
|
if (found || node.type !== 'CallExpression') {
|
|
91
94
|
return;
|
|
92
95
|
}
|
|
93
|
-
const
|
|
96
|
+
const callee = getNodeCallee(node);
|
|
94
97
|
if (!callee) {
|
|
95
98
|
return;
|
|
96
99
|
}
|