@ontrails/warden 1.0.0-beta.42 → 1.0.0-beta.45

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 (36) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/package.json +15 -10
  3. package/src/adapter-check.ts +1 -1
  4. package/src/cli.ts +81 -5
  5. package/src/index.ts +17 -4
  6. package/src/regrade-history.ts +599 -0
  7. package/src/rules/cli-command-route-coherence.ts +6 -6
  8. package/src/rules/draft-visible-debt.ts +1 -1
  9. package/src/rules/duplicate-exported-symbol.ts +4 -43
  10. package/src/rules/governed-symbol-residue.ts +146 -53
  11. package/src/rules/governed-vocabulary-permutation-watch.ts +76 -0
  12. package/src/rules/implementation-returns-result.ts +99 -9
  13. package/src/rules/index.ts +17 -6
  14. package/src/rules/layer-field-name-drift.ts +2 -2
  15. package/src/rules/{library-projection-coherence.ts → library-render-coherence.ts} +11 -14
  16. package/src/rules/metadata.ts +51 -14
  17. package/src/rules/no-redundant-result-error-wrap.ts +15 -17
  18. package/src/rules/no-retired-cross-vocabulary.ts +0 -1
  19. package/src/rules/{owner-projection-parity.ts → owner-render-parity.ts} +18 -18
  20. package/src/rules/public-internal-deep-imports.ts +1 -3
  21. package/src/rules/public-output-schema.ts +1 -1
  22. package/src/rules/registry-names.ts +6 -4
  23. package/src/rules/retired-vocabulary.ts +775 -41
  24. package/src/rules/surface-overlay-coherence.ts +1 -1
  25. package/src/rules/trail-versioning-source.ts +1 -1
  26. package/src/rules/trailhead-override-divergence.ts +4 -4
  27. package/src/rules/types.ts +51 -5
  28. package/src/trails/governed-vocabulary-permutation-watch.trail.ts +16 -0
  29. package/src/trails/index.ts +3 -2
  30. package/src/trails/layer-field-name-drift.trail.ts +1 -1
  31. package/src/trails/{library-projection-coherence.trail.ts → library-render-coherence.trail.ts} +6 -6
  32. package/src/trails/{owner-projection-parity.trail.ts → owner-render-parity.trail.ts} +4 -4
  33. package/src/trails/public-output-schema.trail.ts +1 -1
  34. package/src/trails/run.ts +44 -2
  35. package/src/trails/schema.ts +41 -0
  36. package/src/trails/wrap-rule.ts +44 -2
@@ -0,0 +1,599 @@
1
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
2
+ import { join, relative } from 'node:path';
3
+ import { createHash } from 'node:crypto';
4
+ import { z } from 'zod';
5
+
6
+ import {
7
+ getGovernedVocabularyTransition,
8
+ governedVocabularyHistoryProvenanceSchema,
9
+ listGovernedVocabularyTransitions,
10
+ } from './rules/retired-vocabulary.js';
11
+ import type {
12
+ GovernedVocabularyHistoryEvidence,
13
+ GovernedVocabularyHistoryIssue,
14
+ } from './rules/types.js';
15
+
16
+ const planBodySchema = z.discriminatedUnion('kind', [
17
+ z
18
+ .object({
19
+ caseSensitive: z.boolean().optional(),
20
+ from: z.string().min(1),
21
+ id: z.string().optional(),
22
+ kind: z.literal('vocabulary'),
23
+ to: z.string().min(1),
24
+ })
25
+ .passthrough(),
26
+ z
27
+ .object({
28
+ classIds: z.array(z.string().min(1)).min(1),
29
+ id: z.string().min(1),
30
+ kind: z.literal('class'),
31
+ })
32
+ .passthrough(),
33
+ ]);
34
+
35
+ const planArtifactSchema = z
36
+ .object({
37
+ derivation: z.unknown().optional(),
38
+ expansion: z.unknown().optional(),
39
+ kind: z.literal('regrade-plan'),
40
+ path: z.string().min(1),
41
+ plan: planBodySchema,
42
+ provenance: z.object({
43
+ fields: z.record(z.string(), z.enum(['authored', 'derived'])),
44
+ }),
45
+ schemaVersion: z.literal(1),
46
+ sourceHash: z.string().min(1),
47
+ transitionId: z.string().min(1).optional(),
48
+ })
49
+ .strict();
50
+
51
+ const historyRunSchema = z
52
+ .object({
53
+ completionReport: z.unknown().optional(),
54
+ completionReportHash: z.string().optional(),
55
+ lockHashAtRun: z.string().min(1),
56
+ plan: planArtifactSchema,
57
+ planContentHash: z.string().min(1),
58
+ provenance: governedVocabularyHistoryProvenanceSchema.optional(),
59
+ report: z.unknown(),
60
+ })
61
+ .strict();
62
+
63
+ const historyArtifactSchema = z
64
+ .object({
65
+ id: z.string().min(1),
66
+ kind: z.literal('regrade-history'),
67
+ path: z.string().min(1),
68
+ runs: z.array(historyRunSchema).min(1),
69
+ schemaVersion: z.literal(2),
70
+ })
71
+ .strict();
72
+
73
+ const reportEntrySchema = z
74
+ .object({
75
+ classId: z.string().optional(),
76
+ notes: z.array(z.string()).optional(),
77
+ outcome: z.enum(['needs-review', 'no-op', 'rewrite', 'skip']),
78
+ path: z.string(),
79
+ reason: z.string().optional(),
80
+ reviewDetails: z.unknown().optional(),
81
+ })
82
+ .passthrough();
83
+
84
+ const vocabularyDispositionSchema = z.enum([
85
+ 'code-context-out-of-engine',
86
+ 'docs-only',
87
+ 'explicit-preserve',
88
+ 'forward-pointer',
89
+ 'historical-by-policy',
90
+ 'ignored-by-scope',
91
+ 'in-family-modified',
92
+ 'in-family-unresolved',
93
+ 'out-of-family',
94
+ 'preserve-current-live-api',
95
+ ]);
96
+
97
+ const historyOccurrenceSchema = z
98
+ .object({
99
+ disposition: vocabularyDispositionSchema.optional(),
100
+ form: z.string(),
101
+ line: z.number().int().positive().optional(),
102
+ path: z.string(),
103
+ reason: z.string().optional(),
104
+ scopeTier: z.string().nullish(),
105
+ verdict: z.enum(['applied', 'deferred', 'modified', 'skipped']).optional(),
106
+ })
107
+ .passthrough()
108
+ .superRefine((occurrence, ctx) => {
109
+ const modernOccurrence =
110
+ occurrence.disposition !== undefined &&
111
+ occurrence.line !== undefined &&
112
+ occurrence.reason !== undefined &&
113
+ occurrence.verdict !== undefined;
114
+ if (
115
+ modernOccurrence &&
116
+ occurrence.scopeTier !== undefined &&
117
+ occurrence.scopeTier !== null &&
118
+ occurrence.scopeTier !== 'in-scope' &&
119
+ occurrence.scopeTier !== 'policy-classified'
120
+ ) {
121
+ ctx.addIssue({
122
+ code: 'custom',
123
+ message: 'Modern Regrade occurrences use a recognized scope tier.',
124
+ path: ['scopeTier'],
125
+ });
126
+ }
127
+ });
128
+
129
+ const governedObservationSchema = historyOccurrenceSchema.safeExtend({
130
+ disposition: vocabularyDispositionSchema,
131
+ line: z.number().int().positive(),
132
+ reason: z.string(),
133
+ scopeTier: z.enum(['in-scope', 'policy-classified']).nullish(),
134
+ verdict: z.enum(['applied', 'deferred', 'modified', 'skipped']),
135
+ });
136
+
137
+ const reportSchema = z
138
+ .object({
139
+ apply: z.object({ applied: z.number() }).passthrough().optional(),
140
+ entries: z.array(reportEntrySchema),
141
+ matched: z.number(),
142
+ review: z.number(),
143
+ rewritten: z.number(),
144
+ root: z.string(),
145
+ run: z
146
+ .object({
147
+ ledger: z
148
+ .object({
149
+ cycle: z.unknown(),
150
+ forms: z.record(z.string(), z.unknown()),
151
+ occurrences: z.array(historyOccurrenceSchema),
152
+ })
153
+ .passthrough(),
154
+ report: z
155
+ .object({
156
+ fileRenames: z
157
+ .array(
158
+ z
159
+ .object({
160
+ deferred: z.number(),
161
+ from: z.string(),
162
+ historical: z.number().optional(),
163
+ preserved: z.number().optional(),
164
+ rewritten: z.number(),
165
+ skipped: z.number().optional(),
166
+ to: z.string(),
167
+ })
168
+ .passthrough()
169
+ )
170
+ .optional(),
171
+ })
172
+ .passthrough(),
173
+ })
174
+ .passthrough()
175
+ .optional(),
176
+ scan: z.object({
177
+ byDirectory: z.array(z.unknown()),
178
+ byExtension: z.array(z.unknown()),
179
+ files: z.object({
180
+ matched: z.number(),
181
+ scanned: z.number(),
182
+ skipped: z.number(),
183
+ }),
184
+ skippedByReason: z.record(z.string(), z.number()),
185
+ }),
186
+ scanned: z.number(),
187
+ selectedClassIds: z.array(z.string()),
188
+ skipped: z.number(),
189
+ skipsByReason: z.record(z.string(), z.number()),
190
+ unknownClassIds: z.array(z.string()),
191
+ })
192
+ .passthrough();
193
+
194
+ const governedHistoryRunSchema = historyRunSchema.extend({
195
+ completionReport: reportSchema,
196
+ completionReportHash: z.string().regex(/^[0-9a-f]{64}$/),
197
+ lockHashAtRun: z.string().regex(/^[0-9a-f]{64}$/),
198
+ planContentHash: z.string().regex(/^[0-9a-f]{64}$/),
199
+ report: reportSchema,
200
+ });
201
+
202
+ type GovernedHistoryRun = z.infer<typeof governedHistoryRunSchema>;
203
+ type HistoryRun = z.infer<typeof historyRunSchema>;
204
+
205
+ const isPlainObject = (value: unknown): value is Record<string, unknown> =>
206
+ typeof value === 'object' && value !== null && !Array.isArray(value);
207
+
208
+ const canonicalizeJsonValue = (value: unknown): unknown => {
209
+ if (Array.isArray(value)) {
210
+ return value.map(canonicalizeJsonValue);
211
+ }
212
+ if (isPlainObject(value)) {
213
+ return Object.fromEntries(
214
+ Object.keys(value)
215
+ .toSorted()
216
+ .map((key) => [key, canonicalizeJsonValue(value[key])])
217
+ );
218
+ }
219
+ return value;
220
+ };
221
+
222
+ const hash = (value: string): string =>
223
+ createHash('sha256').update(value).digest('hex');
224
+
225
+ const isGeneratedRegradeArtifact = (path: string): boolean =>
226
+ /(?:^|\/)\.trails\/regrade\/.+\.json$/u.test(path);
227
+
228
+ const sourceHashFacts = (
229
+ report: z.infer<typeof reportSchema>,
230
+ policyMode: 'current' | 'legacy' = 'current',
231
+ fileEvidenceMode: 'current' | 'legacy' = 'current'
232
+ ): unknown => {
233
+ const ledger = report.run?.ledger;
234
+ const occurrences = ledger?.occurrences.filter(
235
+ (occurrence) =>
236
+ occurrence.scopeTier !== 'policy-classified' ||
237
+ (policyMode === 'current' && !isGeneratedRegradeArtifact(occurrence.path))
238
+ );
239
+ const forms = new Set(occurrences?.map((occurrence) => occurrence.form));
240
+ return {
241
+ entries: report.entries
242
+ .filter(
243
+ (entry) =>
244
+ entry.outcome === 'rewrite' || entry.outcome === 'needs-review'
245
+ )
246
+ .map(({ classId, notes, outcome, path, reason, reviewDetails }) => ({
247
+ ...(classId === undefined ? {} : { classId }),
248
+ ...(notes === undefined ? {} : { notes }),
249
+ outcome,
250
+ path,
251
+ ...(reason === undefined ? {} : { reason }),
252
+ ...(reviewDetails === undefined ? {} : { reviewDetails }),
253
+ })),
254
+ fileRenames: report.run?.report.fileRenames?.map((rename) => ({
255
+ deferred: rename.deferred,
256
+ from: rename.from,
257
+ ...(fileEvidenceMode === 'current'
258
+ ? {
259
+ historical: rename.historical ?? 0,
260
+ preserved: rename.preserved ?? 0,
261
+ }
262
+ : {}),
263
+ rewritten: rename.rewritten,
264
+ ...(fileEvidenceMode === 'current'
265
+ ? { skipped: rename.skipped ?? 0 }
266
+ : {}),
267
+ to: rename.to,
268
+ })),
269
+ ledger:
270
+ ledger === undefined || occurrences === undefined || forms === undefined
271
+ ? undefined
272
+ : {
273
+ cycle: ledger.cycle,
274
+ forms: Object.fromEntries(
275
+ Object.entries(ledger.forms).filter(([form]) => forms.has(form))
276
+ ),
277
+ occurrences,
278
+ },
279
+ selectedClassIds: report.selectedClassIds,
280
+ };
281
+ };
282
+
283
+ const sourceHashes = (report: z.infer<typeof reportSchema>): Set<string> => {
284
+ const facts = [
285
+ sourceHashFacts(report),
286
+ sourceHashFacts(report, 'current', 'legacy'),
287
+ sourceHashFacts(report, 'legacy'),
288
+ sourceHashFacts(report, 'legacy', 'legacy'),
289
+ ];
290
+ return new Set(
291
+ facts.flatMap((value) => [
292
+ hash(JSON.stringify(canonicalizeJsonValue(value))),
293
+ hash(JSON.stringify(value)),
294
+ ])
295
+ );
296
+ };
297
+
298
+ interface RawGovernedHistoryRun {
299
+ readonly completionReport?: z.infer<typeof reportSchema>;
300
+ readonly report: z.infer<typeof reportSchema>;
301
+ }
302
+
303
+ const rawEvidenceReports = (
304
+ run: GovernedHistoryRun,
305
+ rawRun: RawGovernedHistoryRun | undefined
306
+ ): {
307
+ readonly completionReport: z.infer<typeof reportSchema>;
308
+ readonly report: z.infer<typeof reportSchema>;
309
+ } => ({
310
+ completionReport:
311
+ rawRun?.completionReport ?? rawRun?.report ?? run.completionReport,
312
+ report: rawRun?.report ?? run.report,
313
+ });
314
+
315
+ const validatesDeterministicEvidence = (
316
+ run: GovernedHistoryRun,
317
+ rawRun: RawGovernedHistoryRun | undefined,
318
+ transitionId: string
319
+ ): boolean => {
320
+ const { provenance } = run;
321
+ const { completionReport, report } = rawEvidenceReports(run, rawRun);
322
+ const provenanceValid =
323
+ provenance === undefined ||
324
+ (provenance.disposition ===
325
+ (run.report.review > 0 ? 'review-follow-up' : 'applied-clean') &&
326
+ provenance.kind === 'governed-vocabulary' &&
327
+ provenance.planContentHash === run.planContentHash &&
328
+ provenance.reviewPending === run.report.review &&
329
+ provenance.safeApplied ===
330
+ (run.report.apply?.applied ?? run.report.rewritten) &&
331
+ sourceHashes(completionReport).has(provenance.sourceHashAfter) &&
332
+ sourceHashes(report).has(provenance.sourceHashBefore) &&
333
+ provenance.transitionId === transitionId);
334
+ return (
335
+ hash(JSON.stringify(canonicalizeJsonValue(run.plan.plan))) ===
336
+ run.planContentHash &&
337
+ sourceHashes(report).has(run.lockHashAtRun) &&
338
+ sourceHashes(completionReport).has(run.completionReportHash) &&
339
+ provenanceValid
340
+ );
341
+ };
342
+
343
+ export interface GovernedVocabularyHistoryIndex {
344
+ readonly byTransitionId: ReadonlyMap<
345
+ string,
346
+ GovernedVocabularyHistoryEvidence
347
+ >;
348
+ readonly issues: readonly GovernedVocabularyHistoryIssue[];
349
+ }
350
+
351
+ const normalizePath = (value: string): string => value.replaceAll('\\', '/');
352
+
353
+ const latestRunFacts = (
354
+ run: HistoryRun | undefined
355
+ ): Pick<
356
+ GovernedVocabularyHistoryEvidence,
357
+ 'caseSensitive' | 'latestFormObservations'
358
+ > => {
359
+ if (run === undefined || run.plan.plan.kind !== 'vocabulary') {
360
+ return { caseSensitive: false, latestFormObservations: [] };
361
+ }
362
+ const report = reportSchema.safeParse(run.completionReport ?? run.report);
363
+ if (!report.success) {
364
+ return {
365
+ caseSensitive: run.plan.plan.caseSensitive === true,
366
+ latestFormObservations: [],
367
+ };
368
+ }
369
+ return {
370
+ caseSensitive: run.plan.plan.caseSensitive === true,
371
+ latestFormObservations: (report.data.run?.ledger.occurrences ?? []).flatMap(
372
+ (occurrence) => {
373
+ const observation = governedObservationSchema.safeParse(occurrence);
374
+ if (!observation.success) {
375
+ return [];
376
+ }
377
+ const { disposition, form, line, path, reason, scopeTier, verdict } =
378
+ observation.data;
379
+ return [
380
+ {
381
+ disposition,
382
+ form,
383
+ line,
384
+ path,
385
+ reason,
386
+ ...(scopeTier === undefined || scopeTier === null
387
+ ? {}
388
+ : { scopeTier }),
389
+ verdict,
390
+ },
391
+ ];
392
+ }
393
+ ),
394
+ };
395
+ };
396
+
397
+ type HistoryFileResult =
398
+ | {
399
+ readonly kind: 'evidence';
400
+ readonly value: GovernedVocabularyHistoryEvidence;
401
+ }
402
+ | { readonly kind: 'ignore' }
403
+ | { readonly kind: 'issue'; readonly value: GovernedVocabularyHistoryIssue };
404
+
405
+ const historyIssue = (
406
+ path: string,
407
+ message: string,
408
+ transitionId?: string
409
+ ): HistoryFileResult => ({
410
+ kind: 'issue',
411
+ value: {
412
+ message,
413
+ path,
414
+ ...(transitionId === undefined ? {} : { transitionId }),
415
+ },
416
+ });
417
+
418
+ const governedTransitionForRun = (run: z.infer<typeof historyRunSchema>) => {
419
+ const { plan } = run.plan;
420
+ if (plan.kind !== 'vocabulary') {
421
+ return;
422
+ }
423
+ const transitionIds = [
424
+ plan.id,
425
+ run.provenance?.transitionId,
426
+ run.plan.transitionId,
427
+ ];
428
+ for (const transitionId of transitionIds) {
429
+ if (transitionId === undefined) {
430
+ continue;
431
+ }
432
+ const transition = getGovernedVocabularyTransition(transitionId);
433
+ if (transition !== undefined) {
434
+ return transition;
435
+ }
436
+ }
437
+ return listGovernedVocabularyTransitions().find(
438
+ (transition) =>
439
+ transition.from === plan.from &&
440
+ (transition.target.kind === 'single'
441
+ ? transition.target.to === plan.to
442
+ : transition.target.options.some((option) => option.to === plan.to))
443
+ );
444
+ };
445
+
446
+ const loadHistoryFile = (rootDir: string, name: string): HistoryFileResult => {
447
+ const absolutePath = join(rootDir, '.trails', 'regrade', 'history', name);
448
+ const observedPath = normalizePath(relative(rootDir, absolutePath));
449
+ let json: unknown;
450
+ try {
451
+ json = JSON.parse(readFileSync(absolutePath, 'utf8'));
452
+ } catch {
453
+ return historyIssue(
454
+ observedPath,
455
+ 'Committed Regrade history is not valid JSON.'
456
+ );
457
+ }
458
+ const parsed = historyArtifactSchema.safeParse(json);
459
+ if (!parsed.success) {
460
+ return historyIssue(
461
+ observedPath,
462
+ 'Committed Regrade history has an invalid evidence shape.'
463
+ );
464
+ }
465
+ if (normalizePath(parsed.data.path) !== observedPath) {
466
+ return historyIssue(
467
+ observedPath,
468
+ 'Committed Regrade history path does not match its observed file.'
469
+ );
470
+ }
471
+
472
+ const latestRun = parsed.data.runs.at(-1);
473
+ const transition =
474
+ latestRun === undefined ? undefined : governedTransitionForRun(latestRun);
475
+ if (transition === undefined) {
476
+ return { kind: 'ignore' };
477
+ }
478
+ const allRunsMatchTransition = parsed.data.runs.every((run) => {
479
+ const runPlan = run.plan.plan;
480
+ if (
481
+ runPlan.kind !== 'vocabulary' ||
482
+ governedTransitionForRun(run)?.id !== transition.id ||
483
+ runPlan.from !== transition.from
484
+ ) {
485
+ return false;
486
+ }
487
+ return transition.target.kind === 'single'
488
+ ? transition.target.to === runPlan.to
489
+ : transition.target.options.some((option) => option.to === runPlan.to);
490
+ });
491
+ if (!allRunsMatchTransition) {
492
+ return historyIssue(
493
+ observedPath,
494
+ 'Committed Regrade history does not match its governed registry transition.',
495
+ transition.id
496
+ );
497
+ }
498
+ const governedRuns = parsed.data.runs.map((run) =>
499
+ governedHistoryRunSchema.safeParse(run)
500
+ );
501
+ const rawRuns = (
502
+ json as {
503
+ readonly runs: readonly RawGovernedHistoryRun[];
504
+ }
505
+ ).runs;
506
+ if (
507
+ transition.provenance.mode === 'regrade-history' &&
508
+ (governedRuns.some((run) => !run.success) ||
509
+ governedRuns.some(
510
+ (run, index) =>
511
+ run.success &&
512
+ !validatesDeterministicEvidence(
513
+ run.data,
514
+ rawRuns[index],
515
+ transition.id
516
+ )
517
+ ))
518
+ ) {
519
+ return historyIssue(
520
+ observedPath,
521
+ 'Committed Regrade history has invalid deterministic run evidence.',
522
+ transition.id
523
+ );
524
+ }
525
+ if (
526
+ parsed.data.runs.some(
527
+ (run) =>
528
+ run.provenance !== undefined &&
529
+ run.provenance.transitionId !== transition.id
530
+ )
531
+ ) {
532
+ return historyIssue(
533
+ observedPath,
534
+ 'Committed Regrade history provenance names a different governed transition.',
535
+ transition.id
536
+ );
537
+ }
538
+ if (
539
+ transition.provenance.mode === 'regrade-history' &&
540
+ parsed.data.runs.some((run) => run.provenance === undefined)
541
+ ) {
542
+ return historyIssue(
543
+ observedPath,
544
+ 'Committed Regrade history lacks required governed provenance.',
545
+ transition.id
546
+ );
547
+ }
548
+ const provenance = parsed.data.runs.at(-1)?.provenance;
549
+ const facts = latestRunFacts(parsed.data.runs.at(-1));
550
+ return {
551
+ kind: 'evidence',
552
+ value: {
553
+ ...facts,
554
+ id: parsed.data.id,
555
+ path: parsed.data.path,
556
+ ...(provenance === undefined ? {} : { provenance }),
557
+ runCount: parsed.data.runs.length,
558
+ transitionId: transition.id,
559
+ },
560
+ };
561
+ };
562
+
563
+ export const loadGovernedVocabularyHistory = (
564
+ rootDir: string
565
+ ): GovernedVocabularyHistoryIndex => {
566
+ const directory = join(rootDir, '.trails', 'regrade', 'history');
567
+ if (!existsSync(directory)) {
568
+ return { byTransitionId: new Map(), issues: [] };
569
+ }
570
+
571
+ const byTransitionId = new Map<string, GovernedVocabularyHistoryEvidence>();
572
+ const issues: GovernedVocabularyHistoryIssue[] = [];
573
+ const files = readdirSync(directory)
574
+ .filter((name) => name.endsWith('.json'))
575
+ .toSorted();
576
+
577
+ for (const name of files) {
578
+ const result = loadHistoryFile(rootDir, name);
579
+ if (result.kind === 'ignore') {
580
+ continue;
581
+ }
582
+ if (result.kind === 'issue') {
583
+ issues.push(result.value);
584
+ continue;
585
+ }
586
+ if (byTransitionId.has(result.value.transitionId)) {
587
+ issues.push({
588
+ message:
589
+ 'Multiple committed Regrade histories claim the same governed transition.',
590
+ path: result.value.path,
591
+ transitionId: result.value.transitionId,
592
+ });
593
+ continue;
594
+ }
595
+ byTransitionId.set(result.value.transitionId, result.value);
596
+ }
597
+
598
+ return { byTransitionId, issues };
599
+ };
@@ -1,4 +1,4 @@
1
- import { deriveTrailCliCommandProjection } from '@ontrails/core';
1
+ import { deriveTrailCliCommandRendering } from '@ontrails/core';
2
2
  import type { CliCommandRoute, Topo } from '@ontrails/core';
3
3
  import type { TopoGraph } from '@ontrails/topography';
4
4
 
@@ -21,13 +21,13 @@ const renderPath = (path: readonly string[]): string => path.join(' ');
21
21
  const claimLabel = (claim: RouteClaim): string =>
22
22
  `${claim.kind} route for trail "${claim.trailId}" (${claim.source})`;
23
23
 
24
- const buildProjectionDiagnostic = (
24
+ const buildDerivationDiagnostic = (
25
25
  trailId: string,
26
26
  error: unknown
27
27
  ): WardenDiagnostic => ({
28
28
  filePath: TOPO_FILE,
29
29
  line: 1,
30
- message: `CLI command route projection for trail "${trailId}" is invalid: ${
30
+ message: `CLI command route rendering for trail "${trailId}" is invalid: ${
31
31
  error instanceof Error ? error.message : String(error)
32
32
  }`,
33
33
  rule: RULE_NAME,
@@ -75,8 +75,8 @@ const collectTopoClaims = (
75
75
 
76
76
  for (const trail of topo.list()) {
77
77
  try {
78
- const projection = deriveTrailCliCommandProjection(trail);
79
- for (const route of projection.routes) {
78
+ const rendering = deriveTrailCliCommandRendering(trail);
79
+ for (const route of rendering.routes) {
80
80
  claims.push({
81
81
  kind: route.kind,
82
82
  path: route.path,
@@ -85,7 +85,7 @@ const collectTopoClaims = (
85
85
  });
86
86
  }
87
87
  } catch (error: unknown) {
88
- diagnostics.push(buildProjectionDiagnostic(trail.id, error));
88
+ diagnostics.push(buildDerivationDiagnostic(trail.id, error));
89
89
  }
90
90
  }
91
91
 
@@ -63,7 +63,7 @@ const collectDraftVisibleDebtDiagnostics = (
63
63
  *
64
64
  * Severity is intentionally `warn`, not `error`. The hard rejection layer for
65
65
  * draft state leaking into established outputs is `validateEstablishedTopo` at
66
- * runtime — it blocks topo compile, surface projection, and lockfile writes.
66
+ * runtime — it blocks topo compile, surface rendering, and lockfile writes.
67
67
  * This rule surfaces the debt for human reviewers without duplicating that layer.
68
68
  */
69
69
  export const draftVisibleDebt: WardenRule = {
@@ -54,52 +54,13 @@ const ALLOWED_DUPLICATE_EXPORT_GROUPS: readonly {
54
54
  'ObserveConfig',
55
55
  'ObserveInput',
56
56
  ],
57
- reason: '@ontrails/observe mirrors core observability contracts',
58
- workspaceNames: ['@ontrails/core', '@ontrails/observe'],
59
- },
60
- {
61
- names: [
62
- 'DEFAULT_MEMORY_SINK_MAX_RECORDS',
63
- 'MemorySinkOptions',
64
- 'MemoryTraceSink',
65
- 'createBoundedMemorySink',
66
- 'createMemorySink',
67
- ],
68
- reason:
69
- '@ontrails/tracing compatibility memory sink mirrors @ontrails/observe',
70
- workspaceNames: ['@ontrails/observe', '@ontrails/tracing'],
71
- },
72
- {
73
- names: [
74
- 'ActivationTraceRecordName',
75
- 'NOOP_SINK',
76
- 'SignalTraceRecordName',
77
- 'TRACE_CONTEXT_KEY',
78
- 'TraceFn',
79
- 'clearTraceSink',
80
- 'createActivationTraceRecord',
81
- 'createSignalTraceRecord',
82
- 'createTraceRecord',
83
- 'getTraceContext',
84
- 'getTraceSink',
85
- 'registerTraceSink',
86
- 'traceContextFromRecord',
87
- 'writeActivationTraceRecord',
88
- 'writeSignalTraceRecord',
89
- ],
90
- reason:
91
- '@ontrails/tracing mirrors core tracing contracts for compatibility',
92
- workspaceNames: ['@ontrails/core', '@ontrails/tracing'],
57
+ reason: '@ontrails/observability mirrors core observability contracts',
58
+ workspaceNames: ['@ontrails/core', '@ontrails/observability'],
93
59
  },
94
60
  {
95
61
  names: ['TraceContext', 'TraceRecord', 'TraceSink'],
96
- reason:
97
- '@ontrails/observe and @ontrails/tracing mirror core trace contracts',
98
- workspaceNames: [
99
- '@ontrails/core',
100
- '@ontrails/observe',
101
- '@ontrails/tracing',
102
- ],
62
+ reason: '@ontrails/observability mirrors core trace contracts',
63
+ workspaceNames: ['@ontrails/core', '@ontrails/observability'],
103
64
  },
104
65
  {
105
66
  names: ['AuthError', 'PermitError'],