@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
package/src/fix.ts ADDED
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Safe-fix execution for `warden --fix` (TRL-833).
3
+ *
4
+ * Consumes the structured {@link WardenFix} metadata a rule attaches to its
5
+ * diagnostics (TRL-831) and applies only the edits marked `safe`. Findings
6
+ * whose fix is `review`-required, or that carry no edits, are never applied —
7
+ * they stay reported so a human (or a downstream regrade) resolves them.
8
+ *
9
+ * The applicator is pure: it takes a file's source plus that file's
10
+ * diagnostics and returns the patched source plus which diagnostics were
11
+ * applied or skipped. The CLI layer owns reading and writing files.
12
+ */
13
+
14
+ import type { WardenDiagnostic, WardenFixEdit } from './rules/types.js';
15
+
16
+ /** A safe edit resolved from a diagnostic, ready to apply to a source string. */
17
+ interface ResolvedEdit {
18
+ readonly start: number;
19
+ readonly end: number;
20
+ readonly replacement: string;
21
+ }
22
+
23
+ /**
24
+ * Apply a set of edits to a source string, last-to-first.
25
+ *
26
+ * Edits are applied in descending start order so earlier offsets stay valid as
27
+ * later spans are spliced. Overlapping edits are a programming error in the
28
+ * rule that produced them; this throws rather than silently corrupt source.
29
+ */
30
+ const applyEdits = (source: string, edits: readonly ResolvedEdit[]): string => {
31
+ for (const edit of edits) {
32
+ if (!Number.isSafeInteger(edit.start) || !Number.isSafeInteger(edit.end)) {
33
+ throw new RangeError(
34
+ `Fix edit [${String(edit.start)}, ${String(edit.end)}) must use safe integer offsets.`
35
+ );
36
+ }
37
+ }
38
+
39
+ const ordered = [...edits].toSorted(
40
+ (left, right) => right.start - left.start
41
+ );
42
+ let result = source;
43
+ let lastStart = Number.POSITIVE_INFINITY;
44
+ for (const edit of ordered) {
45
+ if (edit.start < 0 || edit.end > source.length || edit.start > edit.end) {
46
+ throw new RangeError(
47
+ `Fix edit [${edit.start}, ${edit.end}) is out of bounds for source of length ${source.length}.`
48
+ );
49
+ }
50
+ if (edit.end > lastStart) {
51
+ throw new RangeError(
52
+ `Fix edit [${edit.start}, ${edit.end}) overlaps a later edit starting at ${lastStart}.`
53
+ );
54
+ }
55
+ result =
56
+ result.slice(0, edit.start) + edit.replacement + result.slice(edit.end);
57
+ lastStart = edit.start;
58
+ }
59
+ return result;
60
+ };
61
+
62
+ /** Whether a diagnostic carries an applicable safe fix with concrete edits. */
63
+ export const hasSafeFixEdits = (
64
+ diagnostic: WardenDiagnostic
65
+ ): diagnostic is WardenDiagnostic & {
66
+ readonly fix: { readonly edits: readonly WardenFixEdit[] };
67
+ } =>
68
+ diagnostic.fix?.safety === 'safe' &&
69
+ diagnostic.fix.edits !== undefined &&
70
+ diagnostic.fix.edits.length > 0;
71
+
72
+ /** Result of applying safe fixes to a single file's source. */
73
+ export interface WardenFileFixResult {
74
+ /** Source after applying every safe edit; unchanged when none applied. */
75
+ readonly patched: string;
76
+ /** Whether any edit was applied (i.e. `patched` differs from input). */
77
+ readonly changed: boolean;
78
+ /** Diagnostics whose safe fix was applied. */
79
+ readonly applied: readonly WardenDiagnostic[];
80
+ /** Diagnostics left reported (review-required, or no safe edits). */
81
+ readonly skipped: readonly WardenDiagnostic[];
82
+ }
83
+
84
+ /**
85
+ * Apply the safe fixes among a file's diagnostics to its source.
86
+ *
87
+ * Pure and filesystem-free. Only `safety: 'safe'` fixes with edits are applied;
88
+ * everything else is returned in `skipped`. Edits from all applicable
89
+ * diagnostics are pooled and applied last-to-first in one pass.
90
+ */
91
+ export const applySafeFixesToSource = (
92
+ source: string,
93
+ diagnostics: readonly WardenDiagnostic[]
94
+ ): WardenFileFixResult => {
95
+ const applied: WardenDiagnostic[] = [];
96
+ const skipped: WardenDiagnostic[] = [];
97
+ const edits: ResolvedEdit[] = [];
98
+
99
+ for (const diagnostic of diagnostics) {
100
+ if (hasSafeFixEdits(diagnostic)) {
101
+ applied.push(diagnostic);
102
+ for (const edit of diagnostic.fix.edits) {
103
+ edits.push({
104
+ end: edit.end,
105
+ replacement: edit.replacement,
106
+ start: edit.start,
107
+ });
108
+ }
109
+ } else {
110
+ skipped.push(diagnostic);
111
+ }
112
+ }
113
+
114
+ if (edits.length === 0) {
115
+ return { applied, changed: false, patched: source, skipped };
116
+ }
117
+
118
+ const patched = applyEdits(source, edits);
119
+ return { applied, changed: patched !== source, patched, skipped };
120
+ };
package/src/formatters.ts CHANGED
@@ -36,7 +36,7 @@ export const formatGitHubAnnotations = (report: WardenReport): string => {
36
36
  lines.push(`::error::drift: ${report.drift.blockedReason}`);
37
37
  } else if (report.drift?.stale) {
38
38
  lines.push(
39
- '::error::drift: trails.lock is stale (regenerate with `trails topo compile`)'
39
+ '::error::drift: trails.lock is stale (regenerate with `trails compile`)'
40
40
  );
41
41
  }
42
42
 
@@ -60,6 +60,7 @@ export const formatJson = (report: WardenReport): string => {
60
60
  {
61
61
  diagnostics: report.diagnostics,
62
62
  drift: report.drift,
63
+ fixes: report.fixes,
63
64
  passed: report.passed,
64
65
  summary,
65
66
  },
@@ -146,7 +147,17 @@ const driftSection = (drift: WardenReport['drift']): readonly string[] => {
146
147
  return [
147
148
  '',
148
149
  '### Drift',
149
- '- trails.lock is stale (regenerate with `trails topo compile`)',
150
+ '- trails.lock is stale (regenerate with `trails compile`)',
151
+ ];
152
+ };
153
+
154
+ /** Render safe-fix counts when a fix pass was requested. */
155
+ const fixSummaryLine = (fixes: WardenReport['fixes']): readonly string[] => {
156
+ if (fixes === undefined) {
157
+ return [];
158
+ }
159
+ return [
160
+ `**Fixes:** ${String(fixes.applied)} applied, ${String(fixes.filesChanged)} files changed, ${String(fixes.skipped)} skipped`,
150
161
  ];
151
162
  };
152
163
 
@@ -164,6 +175,7 @@ export const formatSummary = (report: WardenReport): string => {
164
175
  '## Warden Report',
165
176
  '',
166
177
  `**Result: ${result}** | ${String(report.errorCount)} errors, ${String(report.warnCount)} warnings`,
178
+ ...fixSummaryLine(report.fixes),
167
179
  ...severitySection('Errors', errors),
168
180
  ...severitySection('Warnings', warnings),
169
181
  ...driftSection(report.drift),
package/src/guide.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type {
2
+ WardenFixCapability,
2
3
  WardenGuidance,
3
4
  WardenGuidanceLink,
4
5
  WardenRuleConcern,
@@ -27,6 +28,7 @@ export interface WardenRuleGuideEntry {
27
28
  readonly depth: WardenDepth;
28
29
  readonly description: string;
29
30
  readonly docs: readonly WardenGuidanceLink[];
31
+ readonly fix?: WardenFixCapability | undefined;
30
32
  readonly guidance?: WardenGuidance | undefined;
31
33
  readonly id: string;
32
34
  readonly invariant: string;
@@ -55,6 +57,7 @@ interface WardenAgentRuleGuide {
55
57
  readonly tier: WardenRuleTier;
56
58
  };
57
59
  readonly concern: WardenRuleConcern;
60
+ readonly fix?: WardenFixCapability | undefined;
58
61
  readonly guidance?: WardenGuidance | undefined;
59
62
  readonly id: string;
60
63
  readonly invariant: string;
@@ -87,6 +90,7 @@ export const buildWardenGuideManifest = (): WardenGuideManifest => {
87
90
  depth: metadata.depth,
88
91
  description: rule?.description ?? '',
89
92
  docs,
93
+ fix: metadata.fix,
90
94
  guidance: metadata.guidance,
91
95
  id,
92
96
  invariant: metadata.invariant,
@@ -153,6 +157,12 @@ const renderRuleMarkdown = (rule: WardenRuleGuideEntry): readonly string[] => {
153
157
  lines.push(`- Retire when: ${rule.lifecycle.retireWhen}`);
154
158
  }
155
159
 
160
+ if (rule.fix) {
161
+ lines.push(
162
+ `- Fix: \`${rule.fix.class}\` (${rule.fix.safety === 'safe' ? 'safe, applied by `warden --fix`' : 'review-required'})`
163
+ );
164
+ }
165
+
156
166
  if (rule.guidance) {
157
167
  lines.push('', `Guidance: ${rule.guidance.summary}`);
158
168
  renderOptionalList(lines, 'Steps', rule.guidance.steps);
@@ -210,6 +220,7 @@ export const buildWardenAgentGuide = (
210
220
  tier: rule.tier,
211
221
  },
212
222
  concern: rule.concern,
223
+ fix: rule.fix,
213
224
  guidance: rule.guidance,
214
225
  id: rule.id,
215
226
  invariant: rule.invariant,
package/src/index.ts CHANGED
@@ -14,6 +14,11 @@ export type {
14
14
  ProjectContext,
15
15
  TopoAwareWardenRule,
16
16
  WardenDiagnostic,
17
+ WardenFix,
18
+ WardenFixCapability,
19
+ WardenFixClass,
20
+ WardenFixEdit,
21
+ WardenFixSafety,
17
22
  WardenGuidance,
18
23
  WardenGuidanceLink,
19
24
  WardenRule,
@@ -31,6 +36,8 @@ export {
31
36
  builtinWardenRuleMetadata,
32
37
  getWardenRuleMetadata,
33
38
  listWardenRuleMetadata,
39
+ wardenFixClasses,
40
+ wardenFixSafeties,
34
41
  wardenRuleConcerns,
35
42
  wardenRuleLifecycleStates,
36
43
  wardenRuleScopes,
@@ -41,6 +48,12 @@ export {
41
48
 
42
49
  // Rule-scoped cache controls for long-lived consumers (watch mode, LSPs).
43
50
  export { clearImplementationReturnsResultCache } from './rules/implementation-returns-result.js';
51
+ export {
52
+ isWardenDevPermitTestScanTarget,
53
+ isWardenInfrastructureScanTarget,
54
+ isWardenSourceScanTarget,
55
+ isWardenTestScanTarget,
56
+ } from './rules/scan.js';
44
57
 
45
58
  // CLI runner
46
59
  export type {
@@ -51,6 +64,12 @@ export type {
51
64
  } from './cli.js';
52
65
  export { formatWardenReport, runWarden } from './cli.js';
53
66
 
67
+ // Adapter authoring checks
68
+ export {
69
+ adapterCheckRuleName,
70
+ runWardenAdapterChecks,
71
+ } from './adapter-check.js';
72
+
54
73
  // CLI command surface
55
74
  export type {
56
75
  ParsedWardenCommand,
@@ -145,31 +164,38 @@ export {
145
164
  circularRefsTrail,
146
165
  contourExistsTrail,
147
166
  contextNoSurfaceTypesTrail,
148
- crossDeclarationsTrail,
167
+ composesDeclarationsTrail,
149
168
  deadInternalTrailTrail,
169
+ deprecationWithoutGuidanceTrail,
150
170
  diagnosticSchema,
151
171
  draftFileMarkingTrail,
152
172
  draftVisibleDebtTrail,
153
173
  errorMappingCompletenessTrail,
154
174
  exampleValidTrail,
155
175
  firesDeclarationsTrail,
176
+ forkWithoutPreservedBlazeTrail,
156
177
  implementationReturnsResultTrail,
157
178
  incompleteAccessorForStandardOpTrail,
158
179
  incompleteCrudTrail,
159
180
  intentPropagationTrail,
160
181
  layerFieldNameDriftTrail,
182
+ markerSchemaUnsupportedTrail,
161
183
  missingVisibilityTrail,
162
184
  missingReconcileTrail,
163
185
  noDevPermitInSourceTrail,
186
+ noDestructuredComposeTrail,
164
187
  noDirectImplementationCallTrail,
165
188
  noLegacyLayerImportsTrail,
166
189
  noNativeErrorResultTrail,
190
+ noRedundantResultErrorWrapTrail,
167
191
  noSyncResultAssumptionTrail,
168
192
  noThrowInDetourRecoverTrail,
169
193
  noThrowInImplementationTrail,
194
+ noTopLevelSurfaceTrail,
170
195
  onReferencesExistTrail,
171
196
  orphanedSignalTrail,
172
197
  ownerProjectionParityTrail,
198
+ pendingForceTrail,
173
199
  permitGovernanceTrail,
174
200
  preferSchemaInferenceTrail,
175
201
  projectAwareRuleInput,
@@ -184,6 +210,7 @@ export {
184
210
  resourceDeclarationsTrail,
185
211
  resourceIdGrammarTrail,
186
212
  resourceExistsTrail,
213
+ resourceMockCoverageTrail,
187
214
  scheduledDestroyIntentTrail,
188
215
  signalGraphCoachingTrail,
189
216
  staticResourceAccessorPreferenceTrail,
@@ -192,6 +219,9 @@ export {
192
219
  unreachableDetourShadowingTrail,
193
220
  validDetourContractTrail,
194
221
  validDescribeRefsTrail,
222
+ versionGapTrail,
223
+ versionPinnedComposeTrail,
224
+ versionWithoutExamplesTrail,
195
225
  wardenExportSymmetryTrail,
196
226
  wardenRulesUseAstTrail,
197
227
  webhookRouteCollisionTrail,
package/src/rules/ast.ts CHANGED
@@ -2,10 +2,11 @@
2
2
  * Shared AST utilities for warden rules.
3
3
  *
4
4
  * Uses oxc-parser for native-speed TypeScript parsing. Provides a lightweight
5
- * walker and helpers for finding trail implementation bodies.
5
+ * walker and helpers for finding blaze bodies.
6
6
  */
7
7
 
8
- import { resolve } from 'node:path';
8
+ import { existsSync, readFileSync } from 'node:fs';
9
+ import { basename, dirname, join, resolve } from 'node:path';
9
10
  import { fileURLToPath } from 'node:url';
10
11
 
11
12
  import { DRAFT_ID_PREFIX, intentValues } from '@ontrails/core';
@@ -260,6 +261,60 @@ export const FRAMEWORK_DRAFT_PREFIX_CONSTANT_NAMES: ReadonlySet<string> =
260
261
  */
261
262
  const FRAMEWORK_DRAFT_PREFIX_LITERAL = DRAFT_ID_PREFIX;
262
263
 
264
+ interface PackageJsonWithName {
265
+ readonly name: string;
266
+ }
267
+
268
+ const FRAMEWORK_DRAFT_PREFIX_PACKAGES: ReadonlySet<string> = new Set([
269
+ '@ontrails/core',
270
+ '@ontrails/warden',
271
+ ]);
272
+
273
+ const isPackageJsonWithName = (value: unknown): value is PackageJsonWithName =>
274
+ typeof value === 'object' &&
275
+ value !== null &&
276
+ typeof (value as { name?: unknown }).name === 'string';
277
+
278
+ const readPackageJsonName = (packageJsonPath: string): string | null => {
279
+ try {
280
+ const parsed = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
281
+ return isPackageJsonWithName(parsed) ? parsed.name : null;
282
+ } catch {
283
+ return null;
284
+ }
285
+ };
286
+
287
+ const frameworkDraftPackageRoot = (filePath: string): string | null => {
288
+ const resolvedPath = resolve(filePath);
289
+ if (basename(resolvedPath) !== 'draft.ts') {
290
+ return null;
291
+ }
292
+
293
+ const sourceDir = dirname(resolvedPath);
294
+ if (basename(sourceDir) !== 'src') {
295
+ return null;
296
+ }
297
+
298
+ const packageRoot = dirname(sourceDir);
299
+ if (!existsSync(join(packageRoot, 'package.json'))) {
300
+ return null;
301
+ }
302
+
303
+ return packageRoot;
304
+ };
305
+
306
+ /** Fallback exemption when framework files are consumed from a different install path. */
307
+ const isFrameworkDraftPrefixSourceFile = (filePath: string): boolean => {
308
+ const root = frameworkDraftPackageRoot(filePath);
309
+ if (!root) {
310
+ return false;
311
+ }
312
+ const packageName = readPackageJsonName(join(root, 'package.json'));
313
+ return (
314
+ packageName !== null && FRAMEWORK_DRAFT_PREFIX_PACKAGES.has(packageName)
315
+ );
316
+ };
317
+
263
318
  /**
264
319
  * Absolute paths of the two framework files allowed to declare the
265
320
  * draft-prefix constants. Anchored against the rule module's own URL so the
@@ -285,8 +340,8 @@ const FRAMEWORK_DRAFT_CONSTANT_FILES: ReadonlySet<string> = new Set([
285
340
  * constants.
286
341
  *
287
342
  * Exemption is gated on all three of:
288
- * 1. The file's absolute path matches one of the two framework files that
289
- * actually define these constants.
343
+ * 1. The file is one of the two known framework draft files, or its package
344
+ * root `package.json` name is `@ontrails/core` or `@ontrails/warden`.
290
345
  * 2. The declaration name is `DRAFT_ID_PREFIX` or `DRAFT_FILE_PREFIX`.
291
346
  * 3. The string literal value is exactly `'_draft.'`.
292
347
  *
@@ -299,7 +354,11 @@ export const collectFrameworkDraftPrefixConstantOffsets = (
299
354
  ): ReadonlySet<number> => {
300
355
  const offsets = new Set<number>();
301
356
 
302
- if (!FRAMEWORK_DRAFT_CONSTANT_FILES.has(resolve(filePath))) {
357
+ const resolvedPath = resolve(filePath);
358
+ if (
359
+ !FRAMEWORK_DRAFT_CONSTANT_FILES.has(resolvedPath) &&
360
+ !isFrameworkDraftPrefixSourceFile(resolvedPath)
361
+ ) {
303
362
  return offsets;
304
363
  }
305
364
 
@@ -919,7 +978,7 @@ const visitForHoisted = (
919
978
 
920
979
  /**
921
980
  * Collect `var` declarations and `function` declarations hoisted to the
922
- * nearest function scope from anywhere inside `root`, without crossing a
981
+ * nearest function scope from anywhere inside `root`, without composing a
923
982
  * nested function or static-block boundary.
924
983
  */
925
984
  const collectHoistedVarAndFunctionBindings = (
@@ -1100,7 +1159,7 @@ export const walkWithScopes = (
1100
1159
  walkNode(root, true);
1101
1160
  };
1102
1161
 
1103
- const isShadowed = (
1162
+ export const isShadowed = (
1104
1163
  receiverName: string,
1105
1164
  scopeStack: readonly ReadonlySet<string>[]
1106
1165
  ): boolean => {
@@ -1296,7 +1355,7 @@ const isNamespacedCallAllowed = (
1296
1355
  *
1297
1356
  * When `context` is `undefined`, this falls back to permissive matching
1298
1357
  * (any `ns.trail(...)` shape resolves). Inline resolution paths that do
1299
- * not have the surrounding AST available (e.g. `crosses: [core.trail(...)]`
1358
+ * not have the surrounding AST available (e.g. `composes: [core.trail(...)]`
1300
1359
  * or `on: [core.signal(...)]`) rely on this fallback. Scope-aware call
1301
1360
  * sites always pass a context, so this only affects inline contexts where
1302
1361
  * a best-effort name match is the intended behavior.
@@ -1790,7 +1849,7 @@ const extractImportSpecifierAlias = (
1790
1849
  }
1791
1850
 
1792
1851
  // Default imports bind the default export of the source module to the local
1793
- // name. We cannot statically recover the exported name without cross-file
1852
+ // name. We cannot statically recover the exported name without compose-file
1794
1853
  // analysis, so the local name is the best identifier we have for resolving
1795
1854
  // against `knownContourIds`. Treat the alias as an identity mapping; the
1796
1855
  // downstream resolver will fall through to `knownContourIds` on the binding
@@ -2655,14 +2714,14 @@ export const collectNamedTrailIds = (
2655
2714
  return ids;
2656
2715
  };
2657
2716
 
2658
- /** Extract the raw `crosses: [...]` array elements from a trail config. */
2659
- export const getCrossElements = (config: AstNode): readonly AstNode[] => {
2660
- const crossesProp = findConfigProperty(config, 'crosses');
2661
- if (!crossesProp) {
2717
+ /** Extract the raw `composes: [...]` array elements from a trail config. */
2718
+ export const getComposeElements = (config: AstNode): readonly AstNode[] => {
2719
+ const composesProp = findConfigProperty(config, 'composes');
2720
+ if (!composesProp) {
2662
2721
  return [];
2663
2722
  }
2664
2723
 
2665
- const arrayNode = crossesProp.value;
2724
+ const arrayNode = composesProp.value;
2666
2725
  if (!arrayNode || (arrayNode as AstNode).type !== 'ArrayExpression') {
2667
2726
  return [];
2668
2727
  }
@@ -2674,12 +2733,12 @@ export const getCrossElements = (config: AstNode): readonly AstNode[] => {
2674
2733
  };
2675
2734
 
2676
2735
  /**
2677
- * Resolve a single `crosses: [...]` element to its target trail ID.
2736
+ * Resolve a single `composes: [...]` element to its target trail ID.
2678
2737
  *
2679
2738
  * Handles string literals, identifier references (via `namedTrailIds` map or
2680
2739
  * `const NAME = '...'` resolution), and inline `trail(...)` call expressions.
2681
2740
  */
2682
- export const deriveCrossElementId = (
2741
+ export const deriveComposeElementId = (
2683
2742
  element: AstNode,
2684
2743
  sourceCode: string,
2685
2744
  namedTrailIds: ReadonlyMap<string, string>
@@ -2701,23 +2760,23 @@ export const deriveCrossElementId = (
2701
2760
 
2702
2761
  /**
2703
2762
  * Collect all trail IDs referenced by a single trail definition's
2704
- * `crosses: [...]` array, deduplicated.
2763
+ * `composes: [...]` array, deduplicated.
2705
2764
  */
2706
- export const extractDefinitionCrossTargetIds = (
2765
+ export const extractDefinitionComposeTargetIds = (
2707
2766
  config: AstNode,
2708
2767
  sourceCode: string,
2709
2768
  namedTrailIds: ReadonlyMap<string, string>
2710
2769
  ): readonly string[] => [
2711
2770
  ...new Set(
2712
- getCrossElements(config).flatMap((element) => {
2713
- const id = deriveCrossElementId(element, sourceCode, namedTrailIds);
2771
+ getComposeElements(config).flatMap((element) => {
2772
+ const id = deriveComposeElementId(element, sourceCode, namedTrailIds);
2714
2773
  return id ? [id] : [];
2715
2774
  })
2716
2775
  ),
2717
2776
  ];
2718
2777
 
2719
- /** Collect all trail IDs referenced by declared `crosses: [...]` arrays. */
2720
- export const collectCrossTargetTrailIds = (
2778
+ /** Collect all trail IDs referenced by declared `composes: [...]` arrays. */
2779
+ export const collectComposeTargetTrailIds = (
2721
2780
  ast: AstNode,
2722
2781
  sourceCode: string
2723
2782
  ): ReadonlySet<string> => {
@@ -2729,7 +2788,7 @@ export const collectCrossTargetTrailIds = (
2729
2788
  continue;
2730
2789
  }
2731
2790
 
2732
- for (const id of extractDefinitionCrossTargetIds(
2791
+ for (const id of extractDefinitionComposeTargetIds(
2733
2792
  def.config,
2734
2793
  sourceCode,
2735
2794
  namedTrailIds
@@ -2788,7 +2847,7 @@ export interface StoreTableDefinition {
2788
2847
  /**
2789
2848
  * Stable composite key for this table in the form `${storeBinding}:${name}`,
2790
2849
  * falling back to the bare `name` when the store is anonymous. Use this for
2791
- * cross-rule / cross-file keying so two stores with the same table name
2850
+ * compose-rule / compose-file keying so two stores with the same table name
2792
2851
  * never collide.
2793
2852
  */
2794
2853
  readonly key: string;
@@ -2804,7 +2863,7 @@ export interface StoreTableDefinition {
2804
2863
  * binding. Centralized so rule keying stays stable.
2805
2864
  *
2806
2865
  * @remarks
2807
- * The key is intentionally file-local (no module path prefix). Cross-file
2866
+ * The key is intentionally file-local (no module path prefix). Compose-file
2808
2867
  * aggregation in `ProjectContext` merges keys from all files, so two files
2809
2868
  * with `const db = store({ notes: ... })` both produce `db:notes` — this is
2810
2869
  * the desired behavior because the warden checks for *pattern completeness*
@@ -82,7 +82,7 @@ const buildCircularReferenceDiagnostic = (
82
82
  ): WardenDiagnostic => ({
83
83
  filePath,
84
84
  line,
85
- message: `Contour "${contourName}" participates in circular contour references: ${cyclePath.join(' -> ')}.`,
85
+ message: `Contour "${contourName}" participates in circular contour references: ${cyclePath.join(' -> ')}. Break the cycle by removing one contour reference, or extract the shared shape into a new contour neither side depends on.`,
86
86
  rule: 'circular-refs',
87
87
  severity: 'warn',
88
88
  });