@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.
Files changed (73) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/README.md +12 -30
  3. package/bin/warden.ts +29 -1
  4. package/package.json +9 -8
  5. package/src/adapter-check.ts +136 -0
  6. package/src/cli.ts +238 -60
  7. package/src/command.ts +26 -0
  8. package/src/drift.ts +1 -1
  9. package/src/fix.ts +120 -0
  10. package/src/formatters.ts +14 -2
  11. package/src/guide.ts +11 -0
  12. package/src/index.ts +31 -1
  13. package/src/rules/ast.ts +84 -25
  14. package/src/rules/circular-refs.ts +1 -1
  15. package/src/rules/{cross-declarations.ts → composes-declarations.ts} +198 -89
  16. package/src/rules/context-no-surface-types.ts +4 -4
  17. package/src/rules/contour-exists.ts +1 -1
  18. package/src/rules/dead-internal-trail.ts +22 -9
  19. package/src/rules/fires-declarations.ts +3 -3
  20. package/src/rules/implementation-returns-result.ts +269 -76
  21. package/src/rules/index.ts +51 -3
  22. package/src/rules/intent-propagation.ts +6 -6
  23. package/src/rules/metadata.ts +117 -12
  24. package/src/rules/missing-visibility.ts +14 -14
  25. package/src/rules/no-destructured-compose.ts +192 -0
  26. package/src/rules/no-direct-implementation-call.ts +2 -2
  27. package/src/rules/no-legacy-layer-imports.ts +19 -1
  28. package/src/rules/no-redundant-result-error-wrap.ts +331 -0
  29. package/src/rules/no-sync-result-assumption.ts +2 -2
  30. package/src/rules/no-throw-in-implementation.ts +2 -3
  31. package/src/rules/no-top-level-surface.ts +389 -0
  32. package/src/rules/on-references-exist.ts +1 -1
  33. package/src/rules/reference-exists.ts +1 -1
  34. package/src/rules/registry-names.ts +28 -2
  35. package/src/rules/resolved-import-boundary.ts +2 -2
  36. package/src/rules/resource-declarations.ts +4 -4
  37. package/src/rules/resource-exists.ts +1 -1
  38. package/src/rules/resource-mock-coverage.ts +115 -0
  39. package/src/rules/scan.ts +39 -0
  40. package/src/rules/trail-versioning-source.ts +1094 -0
  41. package/src/rules/trail-versioning-topo.ts +172 -0
  42. package/src/rules/types.ts +87 -5
  43. package/src/rules/valid-detour-contract.ts +1 -1
  44. package/src/rules/warden-export-symmetry.ts +1 -1
  45. package/src/rules/warden-rules-use-ast.ts +2 -2
  46. package/src/trails/activation-orphan.trail.ts +4 -1
  47. package/src/trails/composes-declarations.trail.ts +22 -0
  48. package/src/trails/dead-internal-trail.trail.ts +4 -4
  49. package/src/trails/deprecation-without-guidance.trail.ts +21 -0
  50. package/src/trails/fork-without-preserved-blaze.trail.ts +31 -0
  51. package/src/trails/index.ts +12 -1
  52. package/src/trails/intent-propagation.trail.ts +3 -3
  53. package/src/trails/marker-schema-unsupported.trail.ts +23 -0
  54. package/src/trails/missing-visibility.trail.ts +2 -2
  55. package/src/trails/no-destructured-compose.trail.ts +44 -0
  56. package/src/trails/no-direct-implementation-call.trail.ts +2 -2
  57. package/src/trails/no-legacy-layer-imports.trail.ts +6 -0
  58. package/src/trails/no-redundant-result-error-wrap.trail.ts +55 -0
  59. package/src/trails/no-top-level-surface.trail.ts +43 -0
  60. package/src/trails/pending-force.trail.ts +21 -0
  61. package/src/trails/public-internal-deep-imports.trail.ts +1 -1
  62. package/src/trails/resolved-import-boundary.trail.ts +4 -4
  63. package/src/trails/resource-mock-coverage.trail.ts +40 -0
  64. package/src/trails/run.ts +2 -2
  65. package/src/trails/schema.ts +32 -6
  66. package/src/trails/signal-graph-coaching.trail.ts +4 -1
  67. package/src/trails/unmaterialized-activation-source.trail.ts +4 -1
  68. package/src/trails/valid-detour-contract.trail.ts +1 -1
  69. package/src/trails/version-gap.trail.ts +35 -0
  70. package/src/trails/version-pinned-compose.trail.ts +23 -0
  71. package/src/trails/version-without-examples.trail.ts +38 -0
  72. package/src/trails/wrap-rule.ts +5 -3
  73. package/src/trails/cross-declarations.trail.ts +0 -22
@@ -1,9 +1,9 @@
1
1
  /**
2
- * Validates that `ctx.cross()` calls match the declared `crosses` array.
2
+ * Validates that `ctx.compose()` calls match the declared `composes` array.
3
3
  *
4
- * Statically analyzes trail `blaze` functions to find `ctx.cross('trailId', ...)`
5
- * calls and compares them against the `crosses: [...]` declaration in the trail
6
- * config. Reports errors for undeclared crossings and warnings for unused ones.
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 deriveCrossElementId = (
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 crossing extraction
104
+ // Declared composing extraction
105
105
  // ---------------------------------------------------------------------------
106
106
 
107
- /** Extract the ArrayExpression elements from a config's `crosses` property. */
108
- const getCrossElements = (config: AstNode): readonly AstNode[] | null => {
109
- const crossesProp = findConfigProperty(config, 'crosses');
110
- if (!crossesProp) {
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 = crossesProp.value;
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 DeclaredCrosses {
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 `crosses: [showGist]`). When true, "undeclared" diagnostics
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 (`crosses: [showGist]`) cannot be resolved at lint
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 classifyCrossElement = (
144
+ const classifyComposeElement = (
145
145
  element: AstNode,
146
146
  sourceCode: string,
147
147
  ids: Set<string>
148
148
  ): boolean => {
149
- const resolved = deriveCrossElementId(element, sourceCode);
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 resolveDeclaredCrossElements = (
158
+ const resolveDeclaredComposeElements = (
159
159
  elements: readonly AstNode[],
160
160
  sourceCode: string
161
- ): DeclaredCrosses => {
161
+ ): DeclaredComposes => {
162
162
  const ids = new Set<string>();
163
163
  let hasUnresolved = false;
164
164
  for (const element of elements) {
165
- if (classifyCrossElement(element, sourceCode, ids)) {
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 crosses from a `crosses: [...]` array. */
173
- const extractDeclaredCrosses = (
172
+ /** Extract declared composes from a `composes: [...]` array. */
173
+ const extractDeclaredComposes = (
174
174
  config: AstNode,
175
175
  sourceCode: string
176
- ): DeclaredCrosses => {
177
- const elements = getCrossElements(config);
176
+ ): DeclaredComposes => {
177
+ const elements = getComposeElements(config);
178
178
  return elements
179
- ? resolveDeclaredCrossElements(elements, sourceCode)
179
+ ? resolveDeclaredComposeElements(elements, sourceCode)
180
180
  : { hasUnresolved: false, ids: new Set() };
181
181
  };
182
182
 
183
183
  // ---------------------------------------------------------------------------
184
- // Called crossing extraction — member expression helpers
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
- /** Check if a callee is a member-style cross call: <ctxName>.cross(...). */
229
- const isMemberCrossCall = (
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 === 'cross';
266
+ return !!pair && ctxNames.has(pair.objName) && pair.propName === 'compose';
235
267
  };
236
268
 
237
- interface ExtractedCrossCall {
269
+ interface ExtractedComposeCall {
238
270
  readonly ids: readonly string[];
239
271
  readonly hasUnresolved: boolean;
240
272
  }
241
273
 
242
- const unresolvedCross = (): ExtractedCrossCall => ({
274
+ const unresolvedCompose = (): ExtractedComposeCall => ({
243
275
  hasUnresolved: true,
244
276
  ids: [],
245
277
  });
246
278
 
247
- const resolveBatchCrossTupleTarget = (
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 ? deriveCrossElementId(target, sourceCode) : null;
289
+ return target ? deriveComposeElementId(target, sourceCode) : null;
258
290
  };
259
291
 
260
- const collectBatchCrossId = (
292
+ const collectBatchComposeId = (
261
293
  element: AstNode,
262
294
  sourceCode: string,
263
295
  ids: string[]
264
296
  ): boolean => {
265
- const resolved = resolveBatchCrossTupleTarget(element, sourceCode);
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.cross([[trail, input], ...])`. */
274
- const extractBatchCrossIds = (
305
+ /** Extract statically-resolved trail IDs from `ctx.compose([[trail, input], ...])`. */
306
+ const extractBatchComposeIds = (
275
307
  firstArg: AstNode | undefined,
276
308
  sourceCode: string
277
- ): ExtractedCrossCall | null => {
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 (collectBatchCrossId(element, sourceCode, ids)) {
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 extractDirectCrossIds = (
327
+ const extractDirectComposeIds = (
296
328
  firstArg: AstNode | undefined
297
- ): ExtractedCrossCall | null => {
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] } : unresolvedCross();
335
+ return value ? { hasUnresolved: false, ids: [value] } : unresolvedCompose();
304
336
  };
305
337
 
306
- const isCrossCallExpression = (
338
+ const isComposeCallExpression = (
307
339
  callee: AstNode,
308
- ctxNames: ReadonlySet<string>
340
+ ctxNames: ReadonlySet<string>,
341
+ composeLocalNames: ReadonlySet<string>
309
342
  ): boolean =>
310
- isMemberCrossCall(callee, ctxNames) || identifierName(callee) === 'cross';
343
+ isMemberComposeCall(callee, ctxNames) ||
344
+ composeLocalNames.has(identifierName(callee) ?? '');
311
345
 
312
- const extractCrossFirstArg = (node: AstNode): AstNode | undefined => {
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 resolveCrossCallNode = (
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 (!callee || !isCrossCallExpression(callee, ctxNames)) {
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 resolveCrossCallTargets = (
371
+ const resolveComposeCallTargets = (
334
372
  firstArg: AstNode | undefined,
335
373
  sourceCode: string
336
- ): ExtractedCrossCall => {
337
- const direct = extractDirectCrossIds(firstArg);
374
+ ): ExtractedComposeCall => {
375
+ const direct = extractDirectComposeIds(firstArg);
338
376
  if (direct) {
339
377
  return direct;
340
378
  }
341
379
 
342
- const batch = extractBatchCrossIds(firstArg, sourceCode);
343
- return batch ?? unresolvedCross();
380
+ const batch = extractBatchComposeIds(firstArg, sourceCode);
381
+ return batch ?? unresolvedCompose();
344
382
  };
345
383
 
346
384
  /**
347
- * Check if a node is a `<ctxName>.cross(...)` call and return any statically
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 `cross(...)` calls from destructuring. When the first
351
- * argument is a non-string expression (e.g. a trail object identifier like
352
- * `ctx.cross(showGist, input)`), marks the call as unresolved so callers can
353
- * track that a cross call exists but its target cannot be statically resolved.
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 extractCrossCall = (
394
+ const extractComposeCall = (
356
395
  node: AstNode,
357
396
  ctxNames: ReadonlySet<string>,
397
+ composeLocalNames: ReadonlySet<string>,
358
398
  sourceCode: string
359
- ): ExtractedCrossCall | null => {
360
- const crossCall = resolveCrossCallNode(node, ctxNames);
361
- if (!crossCall) {
399
+ ): ExtractedComposeCall | null => {
400
+ const composeCall = resolveComposeCallNode(node, ctxNames, composeLocalNames);
401
+ if (!composeCall) {
362
402
  return null;
363
403
  }
364
404
 
365
- return resolveCrossCallTargets(extractCrossFirstArg(crossCall), sourceCode);
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.cross(...)` / `context.cross(...)` calls are tracked
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
- interface CalledCrosses {
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.cross()` call used a non-string first argument (e.g.
394
- * `ctx.cross(showGist, input)`). When true, "unused declaration"
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 cross call results from a single blaze body. */
401
- const collectCrossCallsFromBody = (
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 = extractCrossCall(node, ctxNames, sourceCode);
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.cross() trail IDs. */
428
- const extractCalledCrosses = (
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
- ): CalledCrosses => {
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 (collectCrossCallsFromBody(body, ids, sourceCode)) {
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
- crossedId: string,
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.cross('${crossedId}') called but '${crossedId}' is not declared in crosses (may be declared via trail object references)`
459
- : `Trail "${trailId}": ctx.cross('${crossedId}') called but '${crossedId}' is not declared in crosses`,
460
- rule: 'cross-declarations',
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
- crossedId: string,
575
+ composedId: string,
467
576
  filePath: string,
468
577
  line: number
469
578
  ): WardenDiagnostic => ({
470
579
  filePath,
471
580
  line,
472
- message: `Trail "${trailId}": '${crossedId}' declared in crosses but ctx.cross('${crossedId}') never called`,
473
- rule: 'cross-declarations',
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 = extractDeclaredCrosses(def.config, sourceCode);
531
- const called = extractCalledCrosses(def.config, sourceCode);
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.cross() calls are statically resolved, report unused
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.cross(showGist)` call, producing false positives.
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.cross()` calls align with declared `crosses` arrays.
679
+ * Validates that `ctx.compose()` calls align with declared `composes` arrays.
571
680
  */
572
- export const crossDeclarations: WardenRule = {
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.cross() calls match the declared crosses array in trail definitions.',
593
- name: 'cross-declarations',
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 implementation files.`
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 implementation files.`
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 implementation files.
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 implementation files.',
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
- collectCrossTargetTrailIds,
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 crosses it and it has no on: activation. Internal trails should stay reachable through ctx.cross() or reactive activation.`,
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
- crossedTrailIds: ReadonlySet<string>
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) || crossedTrailIds.has(def.id)) {
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 ? collectCrossTargetTrailIds(ast, sourceCode) : new Set<string>()
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 localCrossTargetTrailIds = ast
128
- ? collectCrossTargetTrailIds(ast, sourceCode)
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
- context.crossTargetTrailIds ?? localCrossTargetTrailIds
147
+ composeTargetTrailIds
135
148
  );
136
149
  },
137
150
  description:
138
- 'Warn when an internal trail has no crossings anywhere in the project and no on: activation.',
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
  };