@ontrails/warden 1.0.0-beta.18 → 1.0.0-beta.19
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 +79 -0
- package/README.md +12 -30
- package/bin/warden.ts +29 -1
- package/package.json +9 -8
- package/src/adapter-check.ts +136 -0
- package/src/cli.ts +238 -60
- package/src/command.ts +26 -0
- package/src/drift.ts +1 -1
- package/src/fix.ts +120 -0
- package/src/formatters.ts +14 -2
- package/src/guide.ts +11 -0
- package/src/index.ts +31 -1
- package/src/rules/ast.ts +84 -25
- package/src/rules/circular-refs.ts +1 -1
- package/src/rules/{cross-declarations.ts → composes-declarations.ts} +198 -89
- package/src/rules/context-no-surface-types.ts +4 -4
- package/src/rules/contour-exists.ts +1 -1
- package/src/rules/dead-internal-trail.ts +22 -9
- package/src/rules/fires-declarations.ts +3 -3
- package/src/rules/implementation-returns-result.ts +269 -76
- package/src/rules/index.ts +51 -3
- package/src/rules/intent-propagation.ts +6 -6
- package/src/rules/metadata.ts +117 -12
- package/src/rules/missing-visibility.ts +14 -14
- package/src/rules/no-destructured-compose.ts +192 -0
- package/src/rules/no-direct-implementation-call.ts +2 -2
- package/src/rules/no-legacy-layer-imports.ts +19 -1
- package/src/rules/no-redundant-result-error-wrap.ts +331 -0
- package/src/rules/no-sync-result-assumption.ts +2 -2
- package/src/rules/no-throw-in-implementation.ts +2 -3
- package/src/rules/no-top-level-surface.ts +389 -0
- package/src/rules/on-references-exist.ts +1 -1
- package/src/rules/reference-exists.ts +1 -1
- package/src/rules/registry-names.ts +28 -2
- package/src/rules/resolved-import-boundary.ts +2 -2
- package/src/rules/resource-declarations.ts +4 -4
- package/src/rules/resource-exists.ts +1 -1
- package/src/rules/resource-mock-coverage.ts +115 -0
- package/src/rules/scan.ts +39 -0
- package/src/rules/trail-versioning-source.ts +1094 -0
- package/src/rules/trail-versioning-topo.ts +172 -0
- package/src/rules/types.ts +87 -5
- package/src/rules/valid-detour-contract.ts +1 -1
- package/src/rules/warden-export-symmetry.ts +1 -1
- package/src/rules/warden-rules-use-ast.ts +2 -2
- package/src/trails/activation-orphan.trail.ts +4 -1
- package/src/trails/composes-declarations.trail.ts +22 -0
- package/src/trails/dead-internal-trail.trail.ts +4 -4
- package/src/trails/deprecation-without-guidance.trail.ts +21 -0
- package/src/trails/fork-without-preserved-blaze.trail.ts +31 -0
- package/src/trails/index.ts +12 -1
- package/src/trails/intent-propagation.trail.ts +3 -3
- package/src/trails/marker-schema-unsupported.trail.ts +23 -0
- package/src/trails/missing-visibility.trail.ts +2 -2
- package/src/trails/no-destructured-compose.trail.ts +44 -0
- package/src/trails/no-direct-implementation-call.trail.ts +2 -2
- package/src/trails/no-legacy-layer-imports.trail.ts +6 -0
- package/src/trails/no-redundant-result-error-wrap.trail.ts +55 -0
- package/src/trails/no-top-level-surface.trail.ts +43 -0
- package/src/trails/pending-force.trail.ts +21 -0
- package/src/trails/public-internal-deep-imports.trail.ts +1 -1
- package/src/trails/resolved-import-boundary.trail.ts +4 -4
- package/src/trails/resource-mock-coverage.trail.ts +40 -0
- package/src/trails/run.ts +2 -2
- package/src/trails/schema.ts +32 -6
- package/src/trails/signal-graph-coaching.trail.ts +4 -1
- package/src/trails/unmaterialized-activation-source.trail.ts +4 -1
- package/src/trails/valid-detour-contract.trail.ts +1 -1
- package/src/trails/version-gap.trail.ts +35 -0
- package/src/trails/version-pinned-compose.trail.ts +23 -0
- package/src/trails/version-without-examples.trail.ts +38 -0
- package/src/trails/wrap-rule.ts +5 -3
- package/src/trails/cross-declarations.trail.ts +0 -22
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Validates that `ctx.
|
|
2
|
+
* Validates that `ctx.compose()` calls match the declared `composes` array.
|
|
3
3
|
*
|
|
4
|
-
* Statically analyzes trail `blaze` functions to find `ctx.
|
|
5
|
-
* calls and compares them against the `
|
|
6
|
-
* config. Reports errors for undeclared
|
|
4
|
+
* Statically analyzes trail `blaze` functions to find `ctx.compose('trailId', ...)`
|
|
5
|
+
* calls and compares them against the `composes: [...]` declaration in the trail
|
|
6
|
+
* config. Reports errors for undeclared compositions and warnings for unused ones.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import {
|
|
@@ -85,7 +85,7 @@ const resolveIdentifierElement = (
|
|
|
85
85
|
};
|
|
86
86
|
|
|
87
87
|
/** Resolve an array element to a static trail ID when possible. */
|
|
88
|
-
const
|
|
88
|
+
const deriveComposeElementId = (
|
|
89
89
|
element: AstNode,
|
|
90
90
|
sourceCode: string
|
|
91
91
|
): string | null => {
|
|
@@ -101,17 +101,17 @@ const deriveCrossElementId = (
|
|
|
101
101
|
};
|
|
102
102
|
|
|
103
103
|
// ---------------------------------------------------------------------------
|
|
104
|
-
// Declared
|
|
104
|
+
// Declared composing extraction
|
|
105
105
|
// ---------------------------------------------------------------------------
|
|
106
106
|
|
|
107
|
-
/** Extract the ArrayExpression elements from a config's `
|
|
108
|
-
const
|
|
109
|
-
const
|
|
110
|
-
if (!
|
|
107
|
+
/** Extract the ArrayExpression elements from a config's `composes` property. */
|
|
108
|
+
const getComposeElements = (config: AstNode): readonly AstNode[] | null => {
|
|
109
|
+
const composesProp = findConfigProperty(config, 'composes');
|
|
110
|
+
if (!composesProp) {
|
|
111
111
|
return null;
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
-
const arrayNode =
|
|
114
|
+
const arrayNode = composesProp.value;
|
|
115
115
|
if (!arrayNode || (arrayNode as AstNode).type !== 'ArrayExpression') {
|
|
116
116
|
return null;
|
|
117
117
|
}
|
|
@@ -122,12 +122,12 @@ const getCrossElements = (config: AstNode): readonly AstNode[] | null => {
|
|
|
122
122
|
return elements ?? null;
|
|
123
123
|
};
|
|
124
124
|
|
|
125
|
-
interface
|
|
125
|
+
interface DeclaredComposes {
|
|
126
126
|
/** Statically resolved trail IDs from string literals / const identifiers. */
|
|
127
127
|
readonly ids: ReadonlySet<string>;
|
|
128
128
|
/**
|
|
129
129
|
* True if any element could not be statically resolved (e.g. trail object
|
|
130
|
-
* reference like `
|
|
130
|
+
* reference like `composes: [showGist]`). When true, "undeclared" diagnostics
|
|
131
131
|
* are softened from error to warn since the declared set is incomplete.
|
|
132
132
|
*/
|
|
133
133
|
readonly hasUnresolved: boolean;
|
|
@@ -136,17 +136,17 @@ interface DeclaredCrosses {
|
|
|
136
136
|
/**
|
|
137
137
|
* Collect string IDs from array elements, resolving identifiers when possible.
|
|
138
138
|
*
|
|
139
|
-
* Trail-object references (`
|
|
139
|
+
* Trail-object references (`composes: [showGist]`) cannot be resolved at lint
|
|
140
140
|
* time; they're normalized at runtime by `trail()`. When any entry is
|
|
141
141
|
* unresolved, `hasUnresolved` is set so callers can soften diagnostics.
|
|
142
142
|
*/
|
|
143
143
|
/** Classify a single element and accumulate into the id set. */
|
|
144
|
-
const
|
|
144
|
+
const classifyComposeElement = (
|
|
145
145
|
element: AstNode,
|
|
146
146
|
sourceCode: string,
|
|
147
147
|
ids: Set<string>
|
|
148
148
|
): boolean => {
|
|
149
|
-
const resolved =
|
|
149
|
+
const resolved = deriveComposeElementId(element, sourceCode);
|
|
150
150
|
if (!resolved) {
|
|
151
151
|
// Element could not be statically resolved
|
|
152
152
|
return true;
|
|
@@ -155,33 +155,33 @@ const classifyCrossElement = (
|
|
|
155
155
|
return false;
|
|
156
156
|
};
|
|
157
157
|
|
|
158
|
-
const
|
|
158
|
+
const resolveDeclaredComposeElements = (
|
|
159
159
|
elements: readonly AstNode[],
|
|
160
160
|
sourceCode: string
|
|
161
|
-
):
|
|
161
|
+
): DeclaredComposes => {
|
|
162
162
|
const ids = new Set<string>();
|
|
163
163
|
let hasUnresolved = false;
|
|
164
164
|
for (const element of elements) {
|
|
165
|
-
if (
|
|
165
|
+
if (classifyComposeElement(element, sourceCode, ids)) {
|
|
166
166
|
hasUnresolved = true;
|
|
167
167
|
}
|
|
168
168
|
}
|
|
169
169
|
return { hasUnresolved, ids };
|
|
170
170
|
};
|
|
171
171
|
|
|
172
|
-
/** Extract declared
|
|
173
|
-
const
|
|
172
|
+
/** Extract declared composes from a `composes: [...]` array. */
|
|
173
|
+
const extractDeclaredComposes = (
|
|
174
174
|
config: AstNode,
|
|
175
175
|
sourceCode: string
|
|
176
|
-
):
|
|
177
|
-
const elements =
|
|
176
|
+
): DeclaredComposes => {
|
|
177
|
+
const elements = getComposeElements(config);
|
|
178
178
|
return elements
|
|
179
|
-
?
|
|
179
|
+
? resolveDeclaredComposeElements(elements, sourceCode)
|
|
180
180
|
: { hasUnresolved: false, ids: new Set() };
|
|
181
181
|
};
|
|
182
182
|
|
|
183
183
|
// ---------------------------------------------------------------------------
|
|
184
|
-
// Called
|
|
184
|
+
// Called composing extraction — member expression helpers
|
|
185
185
|
// ---------------------------------------------------------------------------
|
|
186
186
|
|
|
187
187
|
const MEMBER_TYPES = new Set(['StaticMemberExpression', 'MemberExpression']);
|
|
@@ -225,26 +225,58 @@ const extractContextParamName = (blazeBody: AstNode): string | null => {
|
|
|
225
225
|
return identifierName(param);
|
|
226
226
|
};
|
|
227
227
|
|
|
228
|
-
/**
|
|
229
|
-
const
|
|
228
|
+
/** Extract the local name bound to `compose` inside an ObjectPattern Property. */
|
|
229
|
+
const extractComposeLocalName = (prop: AstNode): string | null => {
|
|
230
|
+
if (prop.type !== 'Property') {
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
const { key, value } = prop as unknown as {
|
|
234
|
+
readonly key?: AstNode;
|
|
235
|
+
readonly value?: AstNode;
|
|
236
|
+
};
|
|
237
|
+
const keyName = identifierName(key);
|
|
238
|
+
if (keyName !== 'compose') {
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
return identifierName(value) ?? keyName;
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
/** Collect `compose` local names from an ObjectPattern's properties. */
|
|
245
|
+
const collectComposeNamesFromPattern = (
|
|
246
|
+
pattern: AstNode,
|
|
247
|
+
names: Set<string>
|
|
248
|
+
): void => {
|
|
249
|
+
const { properties } = pattern as unknown as {
|
|
250
|
+
readonly properties?: readonly AstNode[];
|
|
251
|
+
};
|
|
252
|
+
for (const prop of properties ?? []) {
|
|
253
|
+
const localName = extractComposeLocalName(prop);
|
|
254
|
+
if (localName) {
|
|
255
|
+
names.add(localName);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
/** Check if a callee is a member-style compose call: <ctxName>.compose(...). */
|
|
261
|
+
const isMemberComposeCall = (
|
|
230
262
|
callee: AstNode,
|
|
231
263
|
ctxNames: ReadonlySet<string>
|
|
232
264
|
): boolean => {
|
|
233
265
|
const pair = extractMemberPair(callee);
|
|
234
|
-
return !!pair && ctxNames.has(pair.objName) && pair.propName === '
|
|
266
|
+
return !!pair && ctxNames.has(pair.objName) && pair.propName === 'compose';
|
|
235
267
|
};
|
|
236
268
|
|
|
237
|
-
interface
|
|
269
|
+
interface ExtractedComposeCall {
|
|
238
270
|
readonly ids: readonly string[];
|
|
239
271
|
readonly hasUnresolved: boolean;
|
|
240
272
|
}
|
|
241
273
|
|
|
242
|
-
const
|
|
274
|
+
const unresolvedCompose = (): ExtractedComposeCall => ({
|
|
243
275
|
hasUnresolved: true,
|
|
244
276
|
ids: [],
|
|
245
277
|
});
|
|
246
278
|
|
|
247
|
-
const
|
|
279
|
+
const resolveBatchComposeTupleTarget = (
|
|
248
280
|
element: AstNode,
|
|
249
281
|
sourceCode: string
|
|
250
282
|
): string | null => {
|
|
@@ -254,15 +286,15 @@ const resolveBatchCrossTupleTarget = (
|
|
|
254
286
|
|
|
255
287
|
const tupleElements = element['elements'] as readonly AstNode[] | undefined;
|
|
256
288
|
const [target] = tupleElements ?? [];
|
|
257
|
-
return target ?
|
|
289
|
+
return target ? deriveComposeElementId(target, sourceCode) : null;
|
|
258
290
|
};
|
|
259
291
|
|
|
260
|
-
const
|
|
292
|
+
const collectBatchComposeId = (
|
|
261
293
|
element: AstNode,
|
|
262
294
|
sourceCode: string,
|
|
263
295
|
ids: string[]
|
|
264
296
|
): boolean => {
|
|
265
|
-
const resolved =
|
|
297
|
+
const resolved = resolveBatchComposeTupleTarget(element, sourceCode);
|
|
266
298
|
if (!resolved) {
|
|
267
299
|
return true;
|
|
268
300
|
}
|
|
@@ -270,11 +302,11 @@ const collectBatchCrossId = (
|
|
|
270
302
|
return false;
|
|
271
303
|
};
|
|
272
304
|
|
|
273
|
-
/** Extract statically-resolved trail IDs from `ctx.
|
|
274
|
-
const
|
|
305
|
+
/** Extract statically-resolved trail IDs from `ctx.compose([[trail, input], ...])`. */
|
|
306
|
+
const extractBatchComposeIds = (
|
|
275
307
|
firstArg: AstNode | undefined,
|
|
276
308
|
sourceCode: string
|
|
277
|
-
):
|
|
309
|
+
): ExtractedComposeCall | null => {
|
|
278
310
|
if (firstArg?.type !== 'ArrayExpression') {
|
|
279
311
|
return null;
|
|
280
312
|
}
|
|
@@ -284,7 +316,7 @@ const extractBatchCrossIds = (
|
|
|
284
316
|
let hasUnresolved = false;
|
|
285
317
|
|
|
286
318
|
for (const element of elements ?? []) {
|
|
287
|
-
if (
|
|
319
|
+
if (collectBatchComposeId(element, sourceCode, ids)) {
|
|
288
320
|
hasUnresolved = true;
|
|
289
321
|
}
|
|
290
322
|
}
|
|
@@ -292,77 +324,88 @@ const extractBatchCrossIds = (
|
|
|
292
324
|
return { hasUnresolved, ids };
|
|
293
325
|
};
|
|
294
326
|
|
|
295
|
-
const
|
|
327
|
+
const extractDirectComposeIds = (
|
|
296
328
|
firstArg: AstNode | undefined
|
|
297
|
-
):
|
|
329
|
+
): ExtractedComposeCall | null => {
|
|
298
330
|
if (!firstArg || !isStringLiteral(firstArg)) {
|
|
299
331
|
return null;
|
|
300
332
|
}
|
|
301
333
|
|
|
302
334
|
const value = getStringValue(firstArg);
|
|
303
|
-
return value ? { hasUnresolved: false, ids: [value] } :
|
|
335
|
+
return value ? { hasUnresolved: false, ids: [value] } : unresolvedCompose();
|
|
304
336
|
};
|
|
305
337
|
|
|
306
|
-
const
|
|
338
|
+
const isComposeCallExpression = (
|
|
307
339
|
callee: AstNode,
|
|
308
|
-
ctxNames: ReadonlySet<string
|
|
340
|
+
ctxNames: ReadonlySet<string>,
|
|
341
|
+
composeLocalNames: ReadonlySet<string>
|
|
309
342
|
): boolean =>
|
|
310
|
-
|
|
343
|
+
isMemberComposeCall(callee, ctxNames) ||
|
|
344
|
+
composeLocalNames.has(identifierName(callee) ?? '');
|
|
311
345
|
|
|
312
|
-
const
|
|
346
|
+
const extractComposeFirstArg = (node: AstNode): AstNode | undefined => {
|
|
313
347
|
const args = node['arguments'] as readonly AstNode[] | undefined;
|
|
314
348
|
return args?.[0];
|
|
315
349
|
};
|
|
316
350
|
|
|
317
|
-
const
|
|
351
|
+
const resolveComposeCallNode = (
|
|
318
352
|
node: AstNode,
|
|
319
|
-
ctxNames: ReadonlySet<string
|
|
353
|
+
ctxNames: ReadonlySet<string>,
|
|
354
|
+
composeLocalNames: ReadonlySet<string>
|
|
320
355
|
): AstNode | null => {
|
|
321
356
|
if (node.type !== 'CallExpression') {
|
|
322
357
|
return null;
|
|
323
358
|
}
|
|
324
359
|
|
|
325
360
|
const callee = node['callee'] as AstNode | undefined;
|
|
326
|
-
if (
|
|
361
|
+
if (
|
|
362
|
+
!callee ||
|
|
363
|
+
!isComposeCallExpression(callee, ctxNames, composeLocalNames)
|
|
364
|
+
) {
|
|
327
365
|
return null;
|
|
328
366
|
}
|
|
329
367
|
|
|
330
368
|
return node;
|
|
331
369
|
};
|
|
332
370
|
|
|
333
|
-
const
|
|
371
|
+
const resolveComposeCallTargets = (
|
|
334
372
|
firstArg: AstNode | undefined,
|
|
335
373
|
sourceCode: string
|
|
336
|
-
):
|
|
337
|
-
const direct =
|
|
374
|
+
): ExtractedComposeCall => {
|
|
375
|
+
const direct = extractDirectComposeIds(firstArg);
|
|
338
376
|
if (direct) {
|
|
339
377
|
return direct;
|
|
340
378
|
}
|
|
341
379
|
|
|
342
|
-
const batch =
|
|
343
|
-
return batch ??
|
|
380
|
+
const batch = extractBatchComposeIds(firstArg, sourceCode);
|
|
381
|
+
return batch ?? unresolvedCompose();
|
|
344
382
|
};
|
|
345
383
|
|
|
346
384
|
/**
|
|
347
|
-
* Check if a node is a `<ctxName>.
|
|
385
|
+
* Check if a node is a `<ctxName>.compose(...)` call and return any statically
|
|
348
386
|
* resolvable target IDs.
|
|
349
387
|
*
|
|
350
|
-
* Also matches bare `
|
|
351
|
-
*
|
|
352
|
-
*
|
|
353
|
-
*
|
|
388
|
+
* Also matches bare `compose(...)` calls only when `compose` was verifiably
|
|
389
|
+
* destructured from the trail context. When the first argument is a non-string
|
|
390
|
+
* expression (e.g. a trail object identifier like `ctx.compose(showGist,
|
|
391
|
+
* input)`), marks the call as unresolved so callers can track that a compose
|
|
392
|
+
* call exists but its target cannot be statically resolved.
|
|
354
393
|
*/
|
|
355
|
-
const
|
|
394
|
+
const extractComposeCall = (
|
|
356
395
|
node: AstNode,
|
|
357
396
|
ctxNames: ReadonlySet<string>,
|
|
397
|
+
composeLocalNames: ReadonlySet<string>,
|
|
358
398
|
sourceCode: string
|
|
359
|
-
):
|
|
360
|
-
const
|
|
361
|
-
if (!
|
|
399
|
+
): ExtractedComposeCall | null => {
|
|
400
|
+
const composeCall = resolveComposeCallNode(node, ctxNames, composeLocalNames);
|
|
401
|
+
if (!composeCall) {
|
|
362
402
|
return null;
|
|
363
403
|
}
|
|
364
404
|
|
|
365
|
-
return
|
|
405
|
+
return resolveComposeCallTargets(
|
|
406
|
+
extractComposeFirstArg(composeCall),
|
|
407
|
+
sourceCode
|
|
408
|
+
);
|
|
366
409
|
};
|
|
367
410
|
|
|
368
411
|
/**
|
|
@@ -370,7 +413,7 @@ const extractCrossCall = (
|
|
|
370
413
|
*
|
|
371
414
|
* Returns ONLY the actual second-parameter name from the blaze signature.
|
|
372
415
|
* No seeded defaults: if the blaze has no second parameter, the returned set
|
|
373
|
-
* is empty and no `ctx.
|
|
416
|
+
* is empty and no `ctx.compose(...)` / `context.compose(...)` calls are tracked
|
|
374
417
|
* for that blaze. An unrelated closure-scoped `ctx` identifier is not the
|
|
375
418
|
* trail context and must not be treated as one.
|
|
376
419
|
*
|
|
@@ -386,28 +429,94 @@ const buildCtxNames = (body: AstNode): ReadonlySet<string> => {
|
|
|
386
429
|
return ctxNames;
|
|
387
430
|
};
|
|
388
431
|
|
|
389
|
-
|
|
432
|
+
const getCtxDestructurePattern = (
|
|
433
|
+
node: AstNode,
|
|
434
|
+
ctxNames: ReadonlySet<string>
|
|
435
|
+
): AstNode | null => {
|
|
436
|
+
if (node.type !== 'VariableDeclarator') {
|
|
437
|
+
return null;
|
|
438
|
+
}
|
|
439
|
+
const { id, init } = node as unknown as {
|
|
440
|
+
readonly id?: AstNode;
|
|
441
|
+
readonly init?: AstNode;
|
|
442
|
+
};
|
|
443
|
+
if (!id || id.type !== 'ObjectPattern' || !init) {
|
|
444
|
+
return null;
|
|
445
|
+
}
|
|
446
|
+
const initName = identifierName(init);
|
|
447
|
+
return initName && ctxNames.has(initName) ? id : null;
|
|
448
|
+
};
|
|
449
|
+
|
|
450
|
+
const getTopLevelStatements = (body: AstNode): readonly AstNode[] => {
|
|
451
|
+
const blockBody = (body as unknown as { body?: AstNode }).body;
|
|
452
|
+
if (!blockBody || blockBody.type !== 'BlockStatement') {
|
|
453
|
+
return [];
|
|
454
|
+
}
|
|
455
|
+
return (blockBody as unknown as { body?: readonly AstNode[] }).body ?? [];
|
|
456
|
+
};
|
|
457
|
+
|
|
458
|
+
const collectComposeNamesFromDeclaration = (
|
|
459
|
+
stmt: AstNode,
|
|
460
|
+
ctxNames: ReadonlySet<string>,
|
|
461
|
+
names: Set<string>
|
|
462
|
+
): void => {
|
|
463
|
+
if (stmt.type !== 'VariableDeclaration') {
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
const { kind } = stmt as unknown as { readonly kind?: string };
|
|
467
|
+
if (kind !== 'const') {
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
const declarations =
|
|
471
|
+
(stmt as unknown as { readonly declarations?: readonly AstNode[] })
|
|
472
|
+
.declarations ?? [];
|
|
473
|
+
for (const decl of declarations) {
|
|
474
|
+
const pattern = getCtxDestructurePattern(decl, ctxNames);
|
|
475
|
+
if (pattern) {
|
|
476
|
+
collectComposeNamesFromPattern(pattern, names);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
};
|
|
480
|
+
|
|
481
|
+
const collectDestructuredComposeNames = (
|
|
482
|
+
body: AstNode,
|
|
483
|
+
ctxNames: ReadonlySet<string>
|
|
484
|
+
): ReadonlySet<string> => {
|
|
485
|
+
const names = new Set<string>();
|
|
486
|
+
for (const stmt of getTopLevelStatements(body)) {
|
|
487
|
+
collectComposeNamesFromDeclaration(stmt, ctxNames, names);
|
|
488
|
+
}
|
|
489
|
+
return names;
|
|
490
|
+
};
|
|
491
|
+
|
|
492
|
+
interface CalledComposes {
|
|
390
493
|
/** Statically resolved trail IDs from string literal arguments. */
|
|
391
494
|
readonly ids: ReadonlySet<string>;
|
|
392
495
|
/**
|
|
393
|
-
* True if any `ctx.
|
|
394
|
-
* `ctx.
|
|
496
|
+
* True if any `ctx.compose()` call used a non-string first argument (e.g.
|
|
497
|
+
* `ctx.compose(showGist, input)`). When true, "unused declaration"
|
|
395
498
|
* diagnostics are softened since the call may target a declared entry.
|
|
396
499
|
*/
|
|
397
500
|
readonly hasUnresolved: boolean;
|
|
398
501
|
}
|
|
399
502
|
|
|
400
|
-
/** Collect
|
|
401
|
-
const
|
|
503
|
+
/** Collect compose call results from a single blaze body. */
|
|
504
|
+
const collectComposeCallsFromBody = (
|
|
402
505
|
body: AstNode,
|
|
403
506
|
ids: Set<string>,
|
|
404
507
|
sourceCode: string
|
|
405
508
|
): boolean => {
|
|
406
509
|
const ctxNames = buildCtxNames(body);
|
|
510
|
+
const composeLocalNames = collectDestructuredComposeNames(body, ctxNames);
|
|
407
511
|
let foundUnresolved = false;
|
|
408
512
|
|
|
409
513
|
walk(body, (node) => {
|
|
410
|
-
const extracted =
|
|
514
|
+
const extracted = extractComposeCall(
|
|
515
|
+
node,
|
|
516
|
+
ctxNames,
|
|
517
|
+
composeLocalNames,
|
|
518
|
+
sourceCode
|
|
519
|
+
);
|
|
411
520
|
if (!extracted) {
|
|
412
521
|
return;
|
|
413
522
|
}
|
|
@@ -424,16 +533,16 @@ const collectCrossCallsFromBody = (
|
|
|
424
533
|
return foundUnresolved;
|
|
425
534
|
};
|
|
426
535
|
|
|
427
|
-
/** Walk blaze bodies and collect all statically resolvable ctx.
|
|
428
|
-
const
|
|
536
|
+
/** Walk blaze bodies and collect all statically resolvable ctx.compose() trail IDs. */
|
|
537
|
+
const extractCalledComposes = (
|
|
429
538
|
config: AstNode,
|
|
430
539
|
sourceCode: string
|
|
431
|
-
):
|
|
540
|
+
): CalledComposes => {
|
|
432
541
|
const ids = new Set<string>();
|
|
433
542
|
let hasUnresolved = false;
|
|
434
543
|
|
|
435
544
|
for (const body of findBlazeBodies(config)) {
|
|
436
|
-
if (
|
|
545
|
+
if (collectComposeCallsFromBody(body, ids, sourceCode)) {
|
|
437
546
|
hasUnresolved = true;
|
|
438
547
|
}
|
|
439
548
|
}
|
|
@@ -447,7 +556,7 @@ const extractCalledCrosses = (
|
|
|
447
556
|
|
|
448
557
|
const buildUndeclaredDiagnostic = (
|
|
449
558
|
trailId: string,
|
|
450
|
-
|
|
559
|
+
composedId: string,
|
|
451
560
|
filePath: string,
|
|
452
561
|
line: number,
|
|
453
562
|
softened = false
|
|
@@ -455,22 +564,22 @@ const buildUndeclaredDiagnostic = (
|
|
|
455
564
|
filePath,
|
|
456
565
|
line,
|
|
457
566
|
message: softened
|
|
458
|
-
? `Trail "${trailId}": ctx.
|
|
459
|
-
: `Trail "${trailId}": ctx.
|
|
460
|
-
rule: '
|
|
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}', ...].`,
|
|
569
|
+
rule: 'composes-declarations',
|
|
461
570
|
severity: softened ? 'warn' : 'error',
|
|
462
571
|
});
|
|
463
572
|
|
|
464
573
|
const buildUnusedDiagnostic = (
|
|
465
574
|
trailId: string,
|
|
466
|
-
|
|
575
|
+
composedId: string,
|
|
467
576
|
filePath: string,
|
|
468
577
|
line: number
|
|
469
578
|
): WardenDiagnostic => ({
|
|
470
579
|
filePath,
|
|
471
580
|
line,
|
|
472
|
-
message: `Trail "${trailId}": '${
|
|
473
|
-
rule: '
|
|
581
|
+
message: `Trail "${trailId}": '${composedId}' declared in composes but ctx.compose('${composedId}') never called`,
|
|
582
|
+
rule: 'composes-declarations',
|
|
474
583
|
severity: 'warn',
|
|
475
584
|
});
|
|
476
585
|
|
|
@@ -527,8 +636,8 @@ const checkTrailDefinition = (
|
|
|
527
636
|
sourceCode: string,
|
|
528
637
|
diagnostics: WardenDiagnostic[]
|
|
529
638
|
): void => {
|
|
530
|
-
const declared =
|
|
531
|
-
const called =
|
|
639
|
+
const declared = extractDeclaredComposes(def.config, sourceCode);
|
|
640
|
+
const called = extractCalledComposes(def.config, sourceCode);
|
|
532
641
|
|
|
533
642
|
if (
|
|
534
643
|
declared.ids.size === 0 &&
|
|
@@ -553,10 +662,10 @@ const checkTrailDefinition = (
|
|
|
553
662
|
diagnostics
|
|
554
663
|
);
|
|
555
664
|
|
|
556
|
-
// When all ctx.
|
|
665
|
+
// When all ctx.compose() calls are statically resolved, report unused
|
|
557
666
|
// declarations. When some calls use trail object references (unresolved),
|
|
558
667
|
// skip — a declared string like 'gist.show' might be the target of an
|
|
559
|
-
// unresolved `ctx.
|
|
668
|
+
// unresolved `ctx.compose(showGist)` call, producing false positives.
|
|
560
669
|
if (!called.hasUnresolved) {
|
|
561
670
|
reportUnused(declared.ids, called.ids, ctx, diagnostics);
|
|
562
671
|
}
|
|
@@ -567,9 +676,9 @@ const checkTrailDefinition = (
|
|
|
567
676
|
// ---------------------------------------------------------------------------
|
|
568
677
|
|
|
569
678
|
/**
|
|
570
|
-
* Validates that `ctx.
|
|
679
|
+
* Validates that `ctx.compose()` calls align with declared `composes` arrays.
|
|
571
680
|
*/
|
|
572
|
-
export const
|
|
681
|
+
export const composesDeclarations: WardenRule = {
|
|
573
682
|
check(sourceCode: string, filePath: string): readonly WardenDiagnostic[] {
|
|
574
683
|
if (isTestFile(filePath)) {
|
|
575
684
|
return [];
|
|
@@ -589,7 +698,7 @@ export const crossDeclarations: WardenRule = {
|
|
|
589
698
|
return diagnostics;
|
|
590
699
|
},
|
|
591
700
|
description:
|
|
592
|
-
'Ensure ctx.
|
|
593
|
-
name: '
|
|
701
|
+
'Ensure ctx.compose() calls match the declared composes array in trail definitions.',
|
|
702
|
+
name: 'composes-declarations',
|
|
594
703
|
severity: 'error',
|
|
595
704
|
};
|
|
@@ -153,7 +153,7 @@ const checkSpecifiersForSurfaceTypes = (
|
|
|
153
153
|
filePath,
|
|
154
154
|
sourceCode,
|
|
155
155
|
node,
|
|
156
|
-
`Do not import surface type "${typeName}" in trail
|
|
156
|
+
`Do not import surface type "${typeName}" in trail files.`
|
|
157
157
|
);
|
|
158
158
|
};
|
|
159
159
|
|
|
@@ -172,7 +172,7 @@ const classifyImport = (
|
|
|
172
172
|
filePath,
|
|
173
173
|
sourceCode,
|
|
174
174
|
node,
|
|
175
|
-
`Do not import from surface module "${moduleName}" in trail
|
|
175
|
+
`Do not import from surface module "${moduleName}" in trail files.`
|
|
176
176
|
);
|
|
177
177
|
}
|
|
178
178
|
|
|
@@ -180,7 +180,7 @@ const classifyImport = (
|
|
|
180
180
|
};
|
|
181
181
|
|
|
182
182
|
/**
|
|
183
|
-
* Detects imports of surface-specific types in trail
|
|
183
|
+
* Detects imports of surface-specific types in trail files.
|
|
184
184
|
*/
|
|
185
185
|
export const contextNoSurfaceTypes: WardenRule = {
|
|
186
186
|
check(sourceCode: string, filePath: string): readonly WardenDiagnostic[] {
|
|
@@ -203,7 +203,7 @@ export const contextNoSurfaceTypes: WardenRule = {
|
|
|
203
203
|
return diagnostics;
|
|
204
204
|
},
|
|
205
205
|
description:
|
|
206
|
-
'Disallow surface-specific type imports (Request, Response, McpSession, etc.) in trail
|
|
206
|
+
'Disallow surface-specific type imports (Request, Response, McpSession, etc.) in trail files.',
|
|
207
207
|
name: 'context-no-surface-types',
|
|
208
208
|
|
|
209
209
|
severity: 'error',
|
|
@@ -133,7 +133,7 @@ const buildMissingContourDiagnostic = (
|
|
|
133
133
|
): WardenDiagnostic => ({
|
|
134
134
|
filePath,
|
|
135
135
|
line,
|
|
136
|
-
message: `Trail "${trailId}" declares contour "${contourName}" which is not defined in the project.`,
|
|
136
|
+
message: `Trail "${trailId}" declares contour "${contourName}" which is not defined in the project. Define it with contour('${contourName}', ...) and include it in the topo, or fix the contours entry if this is a typo.`,
|
|
137
137
|
rule: 'contour-exists',
|
|
138
138
|
severity: 'error',
|
|
139
139
|
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
-
|
|
2
|
+
collectComposeTargetTrailIds,
|
|
3
3
|
findConfigProperty,
|
|
4
4
|
findTrailDefinitions,
|
|
5
5
|
getStringValue,
|
|
@@ -70,7 +70,7 @@ const buildDeadInternalTrailDiagnostic = (
|
|
|
70
70
|
): WardenDiagnostic => ({
|
|
71
71
|
filePath,
|
|
72
72
|
line,
|
|
73
|
-
message: `Trail "${trailId}" is marked visibility: 'internal' but nothing
|
|
73
|
+
message: `Trail "${trailId}" is marked visibility: 'internal' but nothing composes it and it has no on: activation. Internal trails should stay reachable through ctx.compose() or reactive activation.`,
|
|
74
74
|
rule: 'dead-internal-trail',
|
|
75
75
|
severity: 'warn',
|
|
76
76
|
});
|
|
@@ -79,7 +79,7 @@ const checkDeadInternalTrails = (
|
|
|
79
79
|
ast: AstNode | null,
|
|
80
80
|
sourceCode: string,
|
|
81
81
|
filePath: string,
|
|
82
|
-
|
|
82
|
+
composedTrailIds: ReadonlySet<string>
|
|
83
83
|
): readonly WardenDiagnostic[] => {
|
|
84
84
|
if (isTestFile(filePath) || !ast) {
|
|
85
85
|
return [];
|
|
@@ -92,7 +92,7 @@ const checkDeadInternalTrails = (
|
|
|
92
92
|
continue;
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
-
if (hasOnActivation(def.config) ||
|
|
95
|
+
if (hasOnActivation(def.config) || composedTrailIds.has(def.id)) {
|
|
96
96
|
continue;
|
|
97
97
|
}
|
|
98
98
|
|
|
@@ -115,7 +115,7 @@ export const deadInternalTrail: ProjectAwareWardenRule = {
|
|
|
115
115
|
ast,
|
|
116
116
|
sourceCode,
|
|
117
117
|
filePath,
|
|
118
|
-
ast ?
|
|
118
|
+
ast ? collectComposeTargetTrailIds(ast, sourceCode) : new Set<string>()
|
|
119
119
|
);
|
|
120
120
|
},
|
|
121
121
|
checkWithContext(
|
|
@@ -124,18 +124,31 @@ export const deadInternalTrail: ProjectAwareWardenRule = {
|
|
|
124
124
|
context: ProjectContext
|
|
125
125
|
): readonly WardenDiagnostic[] {
|
|
126
126
|
const ast = parse(filePath, sourceCode);
|
|
127
|
-
const
|
|
128
|
-
?
|
|
127
|
+
const localComposeTargetTrailIds = ast
|
|
128
|
+
? collectComposeTargetTrailIds(ast, sourceCode)
|
|
129
129
|
: new Set<string>();
|
|
130
|
+
// Union project-wide compose evidence with the file-local evidence rather
|
|
131
|
+
// than preferring one over the other. The project context only collects
|
|
132
|
+
// compose edges from registered app topos, so a trail defined in a package
|
|
133
|
+
// that is scanned but not part of any registered topo (e.g. an internal
|
|
134
|
+
// child composed in its own module) would be absent from the context set
|
|
135
|
+
// yet present in the local set. Preferring the context set alone produced a
|
|
136
|
+
// false dead-internal-trail warning for those same-file compositions.
|
|
137
|
+
const composeTargetTrailIds = context.composeTargetTrailIds
|
|
138
|
+
? new Set<string>([
|
|
139
|
+
...context.composeTargetTrailIds,
|
|
140
|
+
...localComposeTargetTrailIds,
|
|
141
|
+
])
|
|
142
|
+
: localComposeTargetTrailIds;
|
|
130
143
|
return checkDeadInternalTrails(
|
|
131
144
|
ast,
|
|
132
145
|
sourceCode,
|
|
133
146
|
filePath,
|
|
134
|
-
|
|
147
|
+
composeTargetTrailIds
|
|
135
148
|
);
|
|
136
149
|
},
|
|
137
150
|
description:
|
|
138
|
-
'Warn when an internal trail has no
|
|
151
|
+
'Warn when an internal trail has no compositions anywhere in the project and no on: activation.',
|
|
139
152
|
name: 'dead-internal-trail',
|
|
140
153
|
severity: 'warn',
|
|
141
154
|
};
|