@ontrails/trails 1.0.0-beta.30 → 1.0.0-beta.39

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.
@@ -2,71 +2,3061 @@
2
2
  * `regrade` trail -- Run downstream migration checks and safe rewrites.
3
3
  */
4
4
 
5
- import { NotFoundError, Result, trail, validateOutput } from '@ontrails/core';
6
- import type { Result as TrailsResult } from '@ontrails/core';
7
5
  import {
6
+ InternalError,
7
+ NotFoundError,
8
+ Result,
9
+ ValidationError,
10
+ matchesAnyPathGlob,
11
+ pathScopeSchema,
12
+ trail,
13
+ validateOutput,
14
+ } from '@ontrails/core';
15
+ import type { PathScope, Result as TrailsResult } from '@ontrails/core';
16
+ import {
17
+ createGovernedAstIdentifierRenameClasses,
18
+ listVocabularyRegradePlansFromRegistry,
19
+ loadWardenRegradeClasses,
20
+ readVocabularyTransitionRecord,
8
21
  regradeReportOutput,
9
22
  runRegrade,
10
- wardenTermRewriteClasses,
23
+ runVocabularyRegrade,
24
+ transitionRecordReportWithSummary,
25
+ vocabularyRegradeTransitionForInput,
26
+ vocabularyDispositionValues,
27
+ vocabularyRegradePlanSchema,
28
+ writeVocabularyTransitionRecord,
29
+ } from '@ontrails/regrade';
30
+ import type {
31
+ RegradeApplySummary,
32
+ RegradeReport,
33
+ RegradeReportEntry,
34
+ RegradeScanDirectoryBucket,
35
+ RegradeScanExtensionBucket,
36
+ VocabularyPreserveRule,
37
+ VocabularyRegradePlan,
38
+ VocabularyPreserveInventoryEntry,
11
39
  } from '@ontrails/regrade';
12
- import type { RegradeReport } from '@ontrails/regrade';
40
+ import { execFileSync } from 'node:child_process';
41
+ import {
42
+ existsSync,
43
+ mkdirSync,
44
+ readdirSync,
45
+ readFileSync,
46
+ rmSync,
47
+ writeFileSync,
48
+ } from 'node:fs';
49
+ import type { Dirent } from 'node:fs';
50
+ import { basename, dirname, isAbsolute, join } from 'node:path';
13
51
  import { z } from 'zod';
14
52
 
53
+ import { loadRegradeConfig } from '../regrade/config.js';
54
+ import {
55
+ REGRADE_HISTORY_SCHEMA_VERSION,
56
+ appendRegradeHistoryRun,
57
+ readRegradeHistoryArtifact,
58
+ regradeHistoryPathForPlan,
59
+ resolveRegradeHistoryPath,
60
+ verifyRegradeHistoryRuns,
61
+ } from '../regrade/history.js';
62
+ import type { RegradeHistorySummary } from '../regrade/history.js';
63
+ import { deriveLiveApiPreserveInventory } from '../regrade/live-api-preserve.js';
64
+ import {
65
+ REGRADE_PLAN_SCHEMA_VERSION,
66
+ regradePlanArtifactSchema,
67
+ regradePlanPathForPlan,
68
+ regradeSourceHash,
69
+ rootRelativePath,
70
+ } from '../regrade/plan-artifact.js';
71
+ import type {
72
+ ClassRegradePlan,
73
+ RegradePlanArtifact,
74
+ RegradePlanBody,
75
+ RegradePlanExpansion,
76
+ VocabularyRegradePlanArtifact,
77
+ } from '../regrade/plan-artifact.js';
15
78
  import { resolveTrailRootDir } from './root-dir.js';
16
79
 
17
- const regradeInputSchema = z.object({
80
+ const regradePathScopeInputSchema = pathScopeSchema.extend({
81
+ exclude: pathScopeSchema.shape.exclude.describe(
82
+ 'Root-relative path globs to exclude during Regrade collection'
83
+ ),
84
+ extensions: pathScopeSchema.shape.extensions.describe(
85
+ 'Source file extensions to scan during Regrade collection'
86
+ ),
87
+ include: pathScopeSchema.shape.include.describe(
88
+ 'Root-relative path patterns to include in vocabulary regrade mode'
89
+ ),
90
+ });
91
+
92
+ const regradePreserveRuleInputSchema = z.object({
93
+ disposition: z
94
+ .enum(vocabularyDispositionValues)
95
+ .optional()
96
+ .describe('Classification to assign to occurrences this rule preserves'),
97
+ forms: z
98
+ .array(z.string().min(1))
99
+ .optional()
100
+ .describe('Matched forms this preserve rule applies to'),
101
+ paths: z
102
+ .array(z.string())
103
+ .optional()
104
+ .describe('Root-relative path globs where this preserve rule applies'),
105
+ pattern: z.string().min(1).describe('Regex or literal pattern to preserve'),
106
+ reason: z.string().optional().describe('Why this form is preserved'),
107
+ });
108
+
109
+ const regradePreserveInputSchema = z.union([
110
+ z.string().min(1),
111
+ regradePreserveRuleInputSchema,
112
+ ]);
113
+
114
+ const regradeInputSchema = regradePathScopeInputSchema.extend({
18
115
  apply: z
19
116
  .boolean()
20
117
  .default(false)
21
118
  .describe('Write safe rewrites to disk; dry-run report only by default'),
119
+ check: z
120
+ .boolean()
121
+ .default(false)
122
+ .describe(
123
+ 'Legacy compatibility: check a saved transition record gate without applying rewrites; prefer `regrade check` for saved plans'
124
+ ),
22
125
  classIds: z
23
126
  .array(z.string())
24
127
  .optional()
25
128
  .describe('Regrade class ids to run (defaults to all built-in classes)'),
129
+ configPath: z
130
+ .string()
131
+ .optional()
132
+ .describe('Path to a Trails config file with regrade defaults'),
133
+ from: z
134
+ .string()
135
+ .min(1)
136
+ .optional()
137
+ .describe('Source vocabulary term for a vocabulary regrade'),
138
+ includeEntries: z
139
+ .enum(['actionable', 'all'])
140
+ .default('actionable')
141
+ .describe(
142
+ 'Report entry detail to include; counts always cover the full run'
143
+ ),
144
+ intent: z
145
+ .string()
146
+ .optional()
147
+ .describe('Human-authored migration intent for a vocabulary regrade'),
148
+ overrides: z
149
+ .record(z.string().min(1), z.string().min(1))
150
+ .optional()
151
+ .describe('Explicit source-form to target-form mappings'),
152
+ planRecord: z
153
+ .string()
154
+ .optional()
155
+ .describe(
156
+ 'Legacy compatibility path to a confirmed transition record; prefer `regrade check`, `regrade preview`, and `regrade apply` with saved plans'
157
+ ),
158
+ preserve: z
159
+ .array(regradePreserveInputSchema)
160
+ .optional()
161
+ .describe(
162
+ 'Regex or literal contexts, or structured preserve rules, for a vocabulary regrade'
163
+ ),
164
+ rootDir: z.string().optional().describe('Workspace root directory'),
165
+ to: z
166
+ .string()
167
+ .min(1)
168
+ .optional()
169
+ .describe('Target vocabulary term for a vocabulary regrade'),
170
+ writeRecord: z
171
+ .boolean()
172
+ .default(false)
173
+ .describe(
174
+ 'Legacy compatibility: persist dry-run or apply evidence as a transition record; prefer `regrade plan` and plan history'
175
+ ),
176
+ });
177
+
178
+ type RegradeInput = z.output<typeof regradeInputSchema>;
179
+
180
+ const regradePlanSummarySchema = z.object({
181
+ classIds: z
182
+ .array(z.string())
183
+ .optional()
184
+ .describe('Class ids for a class-mode plan'),
185
+ expansionPending: z
186
+ .number()
187
+ .optional()
188
+ .describe('Pending staged expansion candidates on this plan'),
189
+ from: z.string().optional().describe('Source term for a vocabulary plan'),
190
+ kind: z.enum(['class', 'vocabulary']).describe('Regrade plan kind'),
191
+ path: z.string(),
192
+ schemaVersion: z.number(),
193
+ status: z.enum(['active', 'stale']),
194
+ to: z.string().optional().describe('Target term for a vocabulary plan'),
195
+ });
196
+
197
+ const regradePlansOutputSchema = z.object({
198
+ plans: z.array(regradePlanSummarySchema),
199
+ });
200
+
201
+ const regradeCheckOutputSchema = regradeReportOutput.extend({
202
+ check: z
203
+ .object({
204
+ plan: z
205
+ .string()
206
+ .describe(
207
+ 'Saved Regrade plan or graduated history path that passed checks'
208
+ ),
209
+ status: z.literal('passed').describe('Check result'),
210
+ })
211
+ .describe('Saved Regrade plan check result'),
212
+ });
213
+
214
+ const regradePlanInputSchema = regradePathScopeInputSchema.extend({
215
+ classIds: z
216
+ .array(z.string().min(1))
217
+ .optional()
218
+ .describe(
219
+ 'Regrade class ids for a class-mode plan; pair with `type: class` on the CLI so the plan subcommand wins over `regrade` positionals'
220
+ ),
221
+ configPath: z
222
+ .string()
223
+ .optional()
224
+ .describe('Path to a Trails config file with regrade defaults'),
225
+ expand: z
226
+ .boolean()
227
+ .default(false)
228
+ .describe('Stage wide-net review candidates in the saved plan'),
229
+ fresh: z
230
+ .boolean()
231
+ .default(false)
232
+ .describe(
233
+ 'Replace an existing active plan instead of preserving authored fields'
234
+ ),
235
+ from: z
236
+ .string()
237
+ .min(1)
238
+ .optional()
239
+ .describe('Source vocabulary term or phrase'),
240
+ include: pathScopeSchema.shape.include.describe(
241
+ 'Root-relative path globs to collect during the plan run'
242
+ ),
243
+ includeEntries: z
244
+ .enum(['actionable', 'all'])
245
+ .default('actionable')
246
+ .describe(
247
+ 'Report entry detail to inspect while deriving plan freshness and expansion'
248
+ ),
249
+ intent: z
250
+ .string()
251
+ .optional()
252
+ .describe('Human-authored migration intent for the plan'),
253
+ name: z
254
+ .string()
255
+ .min(1)
256
+ .optional()
257
+ .describe(
258
+ 'Transition name for a class-mode plan; names the plan and history files'
259
+ ),
260
+ overrides: z
261
+ .record(z.string().min(1), z.string().min(1))
262
+ .optional()
263
+ .describe('Explicit source-form to target-form mappings'),
264
+ preserve: z
265
+ .array(regradePreserveInputSchema)
266
+ .optional()
267
+ .describe(
268
+ 'Regex or literal contexts, or structured preserve rules, for a vocabulary regrade'
269
+ ),
26
270
  rootDir: z.string().optional().describe('Workspace root directory'),
271
+ to: z.string().min(1).optional().describe('Target vocabulary term or phrase'),
272
+ type: z
273
+ .enum(['class', 'vocabulary'])
274
+ .optional()
275
+ .describe(
276
+ 'Optional plan type qualifier when a source/target pair is ambiguous'
277
+ ),
278
+ });
279
+
280
+ const regradePlanReferenceInputSchema = z.object({
281
+ includeEntries: z
282
+ .enum(['actionable', 'all'])
283
+ .default('actionable')
284
+ .describe('Report entry detail to include while evaluating a saved plan'),
285
+ plan: z
286
+ .string()
287
+ .optional()
288
+ .describe('Plan name or path; omitted when exactly one active plan exists'),
289
+ rootDir: z.string().optional().describe('Workspace root directory'),
290
+ });
291
+
292
+ const regradeApplyPlanInputSchema = regradePlanReferenceInputSchema;
293
+
294
+ const regradeAdjustInputSchema = z.object({
295
+ rootDir: z.string().optional().describe('Workspace root directory'),
296
+ transition: z
297
+ .string()
298
+ .min(1)
299
+ .describe('Graduated transition name, e.g. facet-to-trailhead'),
300
+ });
301
+
302
+ type RegradePlanInput = z.output<typeof regradePlanInputSchema>;
303
+ type RegradePlanReferenceInput = z.output<
304
+ typeof regradePlanReferenceInputSchema
305
+ >;
306
+ type RegradeApplyPlanInput = z.output<typeof regradeApplyPlanInputSchema>;
307
+ type RegradeAdjustInput = z.output<typeof regradeAdjustInputSchema>;
308
+
309
+ const hasVocabularyInput = (input: RegradeInput) =>
310
+ input.from !== undefined ||
311
+ input.check ||
312
+ input.include !== undefined ||
313
+ input.intent !== undefined ||
314
+ input.overrides !== undefined ||
315
+ input.planRecord !== undefined ||
316
+ input.preserve !== undefined ||
317
+ input.to !== undefined;
318
+
319
+ const classModeCollection = (
320
+ input: RegradeInput,
321
+ configScope?: RegradeConfigScope | undefined
322
+ ):
323
+ | {
324
+ readonly exclude?: readonly string[];
325
+ readonly extensions?: readonly string[];
326
+ }
327
+ | undefined => {
328
+ if (
329
+ configScope?.exclude === undefined &&
330
+ configScope?.extensions === undefined &&
331
+ input.exclude === undefined &&
332
+ input.extensions === undefined
333
+ ) {
334
+ return undefined;
335
+ }
336
+
337
+ return {
338
+ ...(configScope?.exclude === undefined
339
+ ? {}
340
+ : { exclude: configScope.exclude }),
341
+ ...(configScope?.extensions === undefined
342
+ ? {}
343
+ : { extensions: configScope.extensions }),
344
+ ...(input.exclude === undefined ? {} : { exclude: input.exclude }),
345
+ ...(input.extensions === undefined ? {} : { extensions: input.extensions }),
346
+ };
347
+ };
348
+
349
+ interface RegradeConfigScope {
350
+ readonly exclude?: PathScope['exclude'] | undefined;
351
+ readonly extensions?: PathScope['extensions'] | undefined;
352
+ readonly include?: PathScope['include'] | undefined;
353
+ }
354
+
355
+ interface RegradeCollectionScope {
356
+ readonly exclude?: readonly string[];
357
+ readonly extensions?: readonly string[];
358
+ readonly include?: readonly string[];
359
+ }
360
+
361
+ const symbolSourceExtensions: readonly string[] = [
362
+ '.cjs',
363
+ '.cts',
364
+ '.js',
365
+ '.jsx',
366
+ '.mjs',
367
+ '.mts',
368
+ '.ts',
369
+ '.tsx',
370
+ ] as const;
371
+
372
+ const vocabularyProseExtensions: readonly string[] = [
373
+ '.md',
374
+ '.mdx',
375
+ '.txt',
376
+ ] as const;
377
+
378
+ const normalizeExtension = (extension: string): string =>
379
+ extension === '' || extension.startsWith('.') ? extension : `.${extension}`;
380
+
381
+ const compileVocabularyPreservePattern = (pattern: string): RegExp => {
382
+ try {
383
+ return new RegExp(pattern);
384
+ } catch {
385
+ return new RegExp(pattern.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&'));
386
+ }
387
+ };
388
+
389
+ const globalVocabularyPreservePattern = (pattern: RegExp): RegExp => {
390
+ const flags = pattern.flags.includes('g')
391
+ ? pattern.flags
392
+ : `${pattern.flags}g`;
393
+ return new RegExp(pattern.source, flags);
394
+ };
395
+
396
+ const preservePatternOverlapsSpan = (
397
+ pattern: RegExp,
398
+ source: string,
399
+ start: number,
400
+ end: number
401
+ ): boolean => {
402
+ for (const match of source.matchAll(
403
+ globalVocabularyPreservePattern(pattern)
404
+ )) {
405
+ const matchStart = match.index ?? 0;
406
+ const matchEnd = matchStart + match[0].length;
407
+ if (matchStart !== matchEnd && start < matchEnd && matchStart < end) {
408
+ return true;
409
+ }
410
+ }
411
+ return false;
412
+ };
413
+
414
+ const preserveRuleMatchesSymbolOccurrence = (
415
+ rule: VocabularyPreserveRule,
416
+ occurrence: {
417
+ readonly form: string;
418
+ readonly path: string;
419
+ readonly source: string;
420
+ readonly start: number;
421
+ readonly end: number;
422
+ }
423
+ ): boolean => {
424
+ if (rule.forms !== undefined && !rule.forms.includes(occurrence.form)) {
425
+ return false;
426
+ }
427
+ if (
428
+ rule.paths !== undefined &&
429
+ !matchesAnyPathGlob(occurrence.path, rule.paths)
430
+ ) {
431
+ return false;
432
+ }
433
+ const pattern = compileVocabularyPreservePattern(rule.pattern);
434
+ return (
435
+ pattern.test(occurrence.form) ||
436
+ preservePatternOverlapsSpan(
437
+ pattern,
438
+ occurrence.source,
439
+ occurrence.start,
440
+ occurrence.end
441
+ )
442
+ );
443
+ };
444
+
445
+ const symbolOccurrenceIsPreserved = (
446
+ rules: readonly VocabularyPreserveRule[] | undefined,
447
+ occurrence: {
448
+ readonly form: string;
449
+ readonly path: string;
450
+ readonly source: string;
451
+ readonly start: number;
452
+ readonly end: number;
453
+ }
454
+ ): boolean =>
455
+ rules?.some((rule) =>
456
+ preserveRuleMatchesSymbolOccurrence(rule, occurrence)
457
+ ) ?? false;
458
+
459
+ const vocabularyScopeFromConfig = (
460
+ scope: RegradeConfigScope | undefined
461
+ ): VocabularyRegradePlan['scope'] | undefined =>
462
+ scope === undefined
463
+ ? undefined
464
+ : {
465
+ ...(scope.exclude === undefined ? {} : { exclude: scope.exclude }),
466
+ ...(scope.extensions === undefined
467
+ ? {}
468
+ : { extensions: scope.extensions }),
469
+ ...(scope.include === undefined ? {} : { include: scope.include }),
470
+ };
471
+
472
+ const vocabularyPreserveFromInput = (
473
+ preserve: RegradeInput['preserve']
474
+ ): readonly VocabularyPreserveRule[] | undefined =>
475
+ preserve?.map((rule) => {
476
+ if (typeof rule === 'string') {
477
+ return { pattern: rule, reason: 'preserved-by-operator-input' };
478
+ }
479
+
480
+ return {
481
+ ...(rule.disposition === undefined
482
+ ? {}
483
+ : { disposition: rule.disposition }),
484
+ ...(rule.forms === undefined ? {} : { forms: rule.forms }),
485
+ ...(rule.paths === undefined ? {} : { paths: rule.paths }),
486
+ pattern: rule.pattern,
487
+ ...(rule.reason === undefined ? {} : { reason: rule.reason }),
488
+ };
489
+ });
490
+
491
+ const vocabularyRegistryPlanForInput = (
492
+ input: RegradeInput
493
+ ): VocabularyRegradePlan | undefined =>
494
+ listVocabularyRegradePlansFromRegistry().find(
495
+ (plan) => plan.from === input.from && plan.to === input.to
496
+ );
497
+
498
+ const uniqueSorted = (values: readonly string[]): readonly string[] =>
499
+ [...new Set(values)].toSorted((left, right) => left.localeCompare(right));
500
+
501
+ const uniqueInOrder = (values: readonly string[]): readonly string[] => [
502
+ ...new Set(values),
503
+ ];
504
+
505
+ const mergeScopeList = (
506
+ left: readonly string[] | undefined,
507
+ right: readonly string[] | undefined
508
+ ): readonly string[] | undefined => {
509
+ const merged = uniqueInOrder([...(left ?? []), ...(right ?? [])]);
510
+ return merged.length === 0 ? undefined : merged;
511
+ };
512
+
513
+ const mergeVocabularyScope = (
514
+ registryScope: VocabularyRegradePlan['scope'] | undefined,
515
+ configScope: VocabularyRegradePlan['scope'] | undefined,
516
+ input: Pick<RegradeInput, 'exclude' | 'extensions' | 'include'>
517
+ ): VocabularyRegradePlan['scope'] | undefined => {
518
+ const callerExclude = input.exclude ?? configScope?.exclude;
519
+ const callerInclude = input.include ?? configScope?.include;
520
+ const extensions =
521
+ input.extensions ?? configScope?.extensions ?? registryScope?.extensions;
522
+ const exclude = mergeScopeList(registryScope?.exclude, callerExclude);
523
+ const include = mergeScopeList(registryScope?.include, callerInclude);
524
+
525
+ if (
526
+ exclude === undefined &&
527
+ extensions === undefined &&
528
+ include === undefined
529
+ ) {
530
+ return undefined;
531
+ }
532
+
533
+ return {
534
+ ...(exclude === undefined ? {} : { exclude }),
535
+ ...(extensions === undefined ? {} : { extensions }),
536
+ ...(include === undefined ? {} : { include }),
537
+ };
538
+ };
539
+
540
+ const mergeNumericRecords = (
541
+ left: Readonly<Record<string, number>>,
542
+ right: Readonly<Record<string, number>>
543
+ ): Readonly<Record<string, number>> => {
544
+ const keys = uniqueSorted([...Object.keys(left), ...Object.keys(right)]);
545
+ return Object.fromEntries(
546
+ keys.map((key) => [key, Math.max(left[key] ?? 0, right[key] ?? 0)])
547
+ );
548
+ };
549
+
550
+ const sumNumericRecords = (
551
+ left: Readonly<Record<string, number>>,
552
+ right: Readonly<Record<string, number>>
553
+ ): Readonly<Record<string, number>> => {
554
+ const keys = uniqueSorted([...Object.keys(left), ...Object.keys(right)]);
555
+ return Object.fromEntries(
556
+ keys.map((key) => [key, (left[key] ?? 0) + (right[key] ?? 0)])
557
+ );
558
+ };
559
+
560
+ const extensionForPath = (path: string): string => {
561
+ const name = path.split('/').at(-1) ?? path;
562
+ const dot = name.lastIndexOf('.');
563
+ return dot <= 0 || dot === name.length - 1 ? '<none>' : name.slice(dot);
564
+ };
565
+
566
+ const topLevelForPath = (path: string): string => {
567
+ const [segment] = path.split('/');
568
+ return segment === undefined || segment.length === 0 ? '.' : segment;
569
+ };
570
+
571
+ const countFilesBy = (
572
+ paths: readonly string[],
573
+ keyForPath: (path: string) => string
574
+ ): Map<string, number> => {
575
+ const counts = new Map<string, number>();
576
+ for (const path of new Set(paths)) {
577
+ const key = keyForPath(path);
578
+ counts.set(key, (counts.get(key) ?? 0) + 1);
579
+ }
580
+ return counts;
581
+ };
582
+
583
+ const countOccurrencesBy = (
584
+ paths: readonly string[],
585
+ keyForPath: (path: string) => string
586
+ ): Map<string, number> => {
587
+ const counts = new Map<string, number>();
588
+ for (const path of paths) {
589
+ const key = keyForPath(path);
590
+ counts.set(key, (counts.get(key) ?? 0) + 1);
591
+ }
592
+ return counts;
593
+ };
594
+
595
+ const sortBuckets = <T extends { readonly files: number }>(
596
+ left: T & { readonly key: string },
597
+ right: T & { readonly key: string }
598
+ ): number => right.files - left.files || left.key.localeCompare(right.key);
599
+
600
+ const mergedDirectoryBuckets = (
601
+ matchedPaths: readonly string[],
602
+ occurrencePaths: readonly string[]
603
+ ): readonly RegradeScanDirectoryBucket[] => {
604
+ const fileCounts = countFilesBy(matchedPaths, topLevelForPath);
605
+ const occurrenceCounts = countOccurrencesBy(occurrencePaths, topLevelForPath);
606
+ const buckets: (RegradeScanDirectoryBucket & { readonly key: string })[] = [];
607
+ for (const [path, files] of fileCounts.entries()) {
608
+ buckets.push(
609
+ occurrencePaths.length === 0
610
+ ? { files, key: path, path }
611
+ : {
612
+ files,
613
+ key: path,
614
+ occurrences: occurrenceCounts.get(path) ?? 0,
615
+ path,
616
+ }
617
+ );
618
+ }
619
+ return buckets
620
+ .toSorted(sortBuckets)
621
+ .map(({ key: _key, ...bucket }) => bucket);
622
+ };
623
+
624
+ const mergedExtensionBuckets = (
625
+ matchedPaths: readonly string[],
626
+ occurrencePaths: readonly string[]
627
+ ): readonly RegradeScanExtensionBucket[] => {
628
+ const fileCounts = countFilesBy(matchedPaths, extensionForPath);
629
+ const occurrenceCounts = countOccurrencesBy(
630
+ occurrencePaths,
631
+ extensionForPath
632
+ );
633
+ const buckets: (RegradeScanExtensionBucket & { readonly key: string })[] = [];
634
+ for (const [extension, files] of fileCounts.entries()) {
635
+ buckets.push(
636
+ occurrencePaths.length === 0
637
+ ? { extension, files, key: extension }
638
+ : {
639
+ extension,
640
+ files,
641
+ key: extension,
642
+ occurrences: occurrenceCounts.get(extension) ?? 0,
643
+ }
644
+ );
645
+ }
646
+ return buckets
647
+ .toSorted(sortBuckets)
648
+ .map(({ key: _key, ...bucket }) => bucket);
649
+ };
650
+
651
+ const actionableEntryPaths = (
652
+ entries: readonly RegradeReportEntry[]
653
+ ): readonly string[] =>
654
+ entries.flatMap((entry) =>
655
+ entry.outcome === 'rewrite' || entry.outcome === 'needs-review'
656
+ ? [entry.path]
657
+ : []
658
+ );
659
+
660
+ const mergeApplySummary = (
661
+ left: RegradeApplySummary | undefined,
662
+ right: RegradeApplySummary | undefined
663
+ ): RegradeApplySummary | undefined => {
664
+ if (left === undefined && right === undefined) {
665
+ return undefined;
666
+ }
667
+
668
+ const leftValue = left ?? {
669
+ applied: 0,
670
+ filesChanged: 0,
671
+ review: 0,
672
+ skipped: 0,
673
+ unknown: 0,
674
+ };
675
+ const rightValue = right ?? {
676
+ applied: 0,
677
+ filesChanged: 0,
678
+ review: 0,
679
+ skipped: 0,
680
+ unknown: 0,
681
+ };
682
+
683
+ return {
684
+ applied: leftValue.applied + rightValue.applied,
685
+ filesChanged: leftValue.filesChanged + rightValue.filesChanged,
686
+ review: leftValue.review + rightValue.review,
687
+ skipped: Math.max(leftValue.skipped, rightValue.skipped),
688
+ unknown: leftValue.unknown + rightValue.unknown,
689
+ };
690
+ };
691
+
692
+ type VocabularyTransitionRunReport = NonNullable<
693
+ RegradeReport['run']
694
+ >['report'];
695
+
696
+ const transitionRunReportForRegradeReport = (
697
+ report: RegradeReport
698
+ ): VocabularyTransitionRunReport => {
699
+ const applied = report.apply?.applied ?? 0;
700
+ const filesChanged = report.apply?.filesChanged ?? 0;
701
+ const modified = report.apply === undefined ? report.rewritten : 0;
702
+ const deferred = report.review;
703
+ const open =
704
+ report.apply === undefined
705
+ ? report.rewritten + report.review
706
+ : report.review;
707
+ const remainingByDisposition =
708
+ open === 0 ? {} : { 'code-context-out-of-engine': open };
709
+ const reasons = [
710
+ ...(report.apply === undefined && report.rewritten > 0
711
+ ? ['safe-modifications-not-yet-applied']
712
+ : []),
713
+ ...(report.review > 0 ? ['deferred-forms-or-occurrences'] : []),
714
+ ];
715
+
716
+ return {
717
+ applied,
718
+ deferred,
719
+ dispositions: remainingByDisposition,
720
+ filesChanged,
721
+ gate: {
722
+ reasons,
723
+ remaining: open,
724
+ remainingByDisposition,
725
+ status: open === 0 ? 'green' : 'open',
726
+ },
727
+ modified,
728
+ open,
729
+ skipped: report.skipped,
730
+ };
731
+ };
732
+
733
+ const mergeTransitionRunReportWithSymbol = (
734
+ vocabularyReport: VocabularyTransitionRunReport,
735
+ symbolReport: RegradeReport
736
+ ): VocabularyTransitionRunReport => {
737
+ const symbolRunReport = transitionRunReportForRegradeReport(symbolReport);
738
+ const modified = vocabularyReport.modified + symbolRunReport.modified;
739
+ const open = vocabularyReport.open + symbolRunReport.open;
740
+ const dispositions = sumNumericRecords(
741
+ vocabularyReport.dispositions,
742
+ symbolRunReport.dispositions
743
+ );
744
+ const remainingByDisposition = sumNumericRecords(
745
+ vocabularyReport.gate.remainingByDisposition,
746
+ symbolRunReport.gate.remainingByDisposition
747
+ );
748
+ const reasons = uniqueSorted([
749
+ ...vocabularyReport.gate.reasons,
750
+ ...symbolRunReport.gate.reasons,
751
+ ]);
752
+
753
+ return {
754
+ applied: vocabularyReport.applied + symbolRunReport.applied,
755
+ deferred: vocabularyReport.deferred + symbolRunReport.deferred,
756
+ dispositions,
757
+ filesChanged: vocabularyReport.filesChanged + symbolRunReport.filesChanged,
758
+ gate: {
759
+ reasons,
760
+ remaining: open,
761
+ remainingByDisposition,
762
+ status: open === 0 ? 'green' : 'open',
763
+ },
764
+ modified,
765
+ open,
766
+ skipped: vocabularyReport.skipped + symbolRunReport.skipped,
767
+ };
768
+ };
769
+
770
+ const mergeRegradeReports = (
771
+ vocabularyReport: RegradeReport,
772
+ symbolReport: RegradeReport
773
+ ): RegradeReport => {
774
+ const entries = [
775
+ ...vocabularyReport.entries,
776
+ ...symbolReport.entries,
777
+ ].toSorted(
778
+ (left, right) =>
779
+ left.path.localeCompare(right.path) ||
780
+ (left.classId ?? '').localeCompare(right.classId ?? '')
781
+ );
782
+ const matchedPaths = actionableEntryPaths(entries);
783
+ const rewritten = new Set(
784
+ entries
785
+ .filter((entry) => entry.outcome === 'rewrite')
786
+ .map((entry) => entry.path)
787
+ ).size;
788
+ const review = new Set(
789
+ entries
790
+ .filter((entry) => entry.outcome === 'needs-review')
791
+ .map((entry) => entry.path)
792
+ ).size;
793
+ const matched = new Set(matchedPaths).size;
794
+ const occurrencePaths =
795
+ vocabularyReport.run?.ledger.occurrences.map(
796
+ (occurrence) => occurrence.path
797
+ ) ?? [];
798
+ const skippedByReason = mergeNumericRecords(
799
+ vocabularyReport.skipsByReason,
800
+ symbolReport.skipsByReason
801
+ );
802
+ const apply = mergeApplySummary(vocabularyReport.apply, symbolReport.apply);
803
+ const scanned = vocabularyReport.scanned + symbolReport.scanned;
804
+ const run =
805
+ vocabularyReport.run === undefined
806
+ ? undefined
807
+ : {
808
+ ...vocabularyReport.run,
809
+ report: mergeTransitionRunReportWithSymbol(
810
+ vocabularyReport.run.report,
811
+ symbolReport
812
+ ),
813
+ };
814
+
815
+ return {
816
+ ...vocabularyReport,
817
+ ...(apply === undefined ? {} : { apply }),
818
+ entries,
819
+ matched,
820
+ review,
821
+ rewritten,
822
+ scan: {
823
+ byDirectory: mergedDirectoryBuckets(matchedPaths, occurrencePaths),
824
+ byExtension: mergedExtensionBuckets(matchedPaths, occurrencePaths),
825
+ files: {
826
+ matched: new Set(matchedPaths).size,
827
+ scanned,
828
+ skipped: Math.max(vocabularyReport.skipped, symbolReport.skipped),
829
+ },
830
+ skippedByReason,
831
+ },
832
+ ...(run === undefined ? {} : { run }),
833
+ scanned,
834
+ selectedClassIds: uniqueSorted([
835
+ ...vocabularyReport.selectedClassIds,
836
+ ...symbolReport.selectedClassIds,
837
+ ]),
838
+ skipped: Math.max(vocabularyReport.skipped, symbolReport.skipped),
839
+ skipsByReason: skippedByReason,
840
+ unknownClassIds: uniqueSorted([
841
+ ...vocabularyReport.unknownClassIds,
842
+ ...symbolReport.unknownClassIds,
843
+ ]),
844
+ };
845
+ };
846
+
847
+ const vocabularySymbolCollection = (
848
+ scope: VocabularyRegradePlan['scope'] | undefined
849
+ ): RegradeCollectionScope | null | undefined => {
850
+ const exclude = scope?.exclude;
851
+ const extensions = scope?.extensions;
852
+ const include = scope?.include;
853
+ const explicitExtensions = extensions !== undefined;
854
+ const codeExtensions =
855
+ extensions === undefined
856
+ ? symbolSourceExtensions
857
+ : uniqueSorted(
858
+ extensions
859
+ .map(normalizeExtension)
860
+ .filter((extension) => symbolSourceExtensions.includes(extension))
861
+ );
862
+ if (explicitExtensions && codeExtensions.length === 0) {
863
+ return null;
864
+ }
865
+ if (
866
+ exclude === undefined &&
867
+ codeExtensions.length === 0 &&
868
+ include === undefined
869
+ ) {
870
+ return undefined;
871
+ }
872
+ return {
873
+ ...(exclude === undefined ? {} : { exclude }),
874
+ ...(codeExtensions.length === 0 ? {} : { extensions: codeExtensions }),
875
+ ...(include === undefined ? {} : { include }),
876
+ };
877
+ };
878
+
879
+ const vocabularyProseScope = (
880
+ scope: VocabularyRegradePlan['scope'] | undefined
881
+ ): NonNullable<VocabularyRegradePlan['scope']> | null => {
882
+ const explicitExtensions = scope?.extensions !== undefined;
883
+ const extensions =
884
+ scope?.extensions === undefined
885
+ ? vocabularyProseExtensions
886
+ : uniqueSorted(
887
+ scope.extensions
888
+ .map(normalizeExtension)
889
+ .filter((extension) =>
890
+ vocabularyProseExtensions.includes(extension)
891
+ )
892
+ );
893
+
894
+ if (explicitExtensions && extensions.length === 0) {
895
+ return null;
896
+ }
897
+
898
+ return {
899
+ ...(scope?.exclude === undefined ? {} : { exclude: scope.exclude }),
900
+ extensions,
901
+ ...(scope?.ignoredDirectories === undefined
902
+ ? {}
903
+ : { ignoredDirectories: scope.ignoredDirectories }),
904
+ ...(scope?.include === undefined ? {} : { include: scope.include }),
905
+ };
906
+ };
907
+
908
+ const vocabularyProsePlan = (
909
+ plan: VocabularyRegradePlan
910
+ ): VocabularyRegradePlan | null => {
911
+ const scope = vocabularyProseScope(plan.scope);
912
+ if (scope === null) {
913
+ return null;
914
+ }
915
+
916
+ return { ...plan, scope };
917
+ };
918
+
919
+ const mergeVocabularyOverrides = (
920
+ registryPlan: VocabularyRegradePlan | undefined,
921
+ input: z.output<typeof regradeInputSchema>
922
+ ): VocabularyRegradePlan['overrides'] | undefined => {
923
+ const overrides = {
924
+ ...registryPlan?.overrides,
925
+ ...input.overrides,
926
+ };
927
+ return Object.keys(overrides).length === 0 ? undefined : overrides;
928
+ };
929
+
930
+ const mergeVocabularyPreserveRules = (
931
+ registryPlan: VocabularyRegradePlan | undefined,
932
+ preserve: readonly VocabularyPreserveRule[] | undefined
933
+ ): readonly VocabularyPreserveRule[] | undefined => {
934
+ const rules = [...(registryPlan?.preserve ?? []), ...(preserve ?? [])];
935
+ return rules.length === 0 ? undefined : rules;
936
+ };
937
+
938
+ const vocabularyIntentForInput = (
939
+ input: z.output<typeof regradeInputSchema>,
940
+ registryPlan: VocabularyRegradePlan | undefined
941
+ ): string | undefined => {
942
+ if (input.intent !== undefined) {
943
+ return input.intent;
944
+ }
945
+ return registryPlan?.intent;
946
+ };
947
+
948
+ const buildVocabularyPlan = (
949
+ input: RegradeInput,
950
+ configScope?: VocabularyRegradePlan['scope']
951
+ ): TrailsResult<VocabularyRegradePlan, ValidationError> => {
952
+ if (input.from === undefined || input.to === undefined) {
953
+ return Result.err(
954
+ new ValidationError('A vocabulary regrade requires both `from` and `to`.')
955
+ );
956
+ }
957
+ if (input.classIds !== undefined) {
958
+ return Result.err(
959
+ new ValidationError(
960
+ '`classIds` selects class-mode Regrade and cannot be combined with vocabulary-regrade `from`/`to`.'
961
+ )
962
+ );
963
+ }
964
+
965
+ const preserve = vocabularyPreserveFromInput(input.preserve);
966
+ const registryPlan = vocabularyRegistryPlanForInput(input);
967
+ const intent = vocabularyIntentForInput(input, registryPlan);
968
+ const overrides = mergeVocabularyOverrides(registryPlan, input);
969
+ const preserveRules = mergeVocabularyPreserveRules(registryPlan, preserve);
970
+ const scope = mergeVocabularyScope(registryPlan?.scope, configScope, input);
971
+
972
+ return Result.ok({
973
+ ...(registryPlan?.caseSensitive === undefined
974
+ ? {}
975
+ : { caseSensitive: registryPlan.caseSensitive }),
976
+ ...(registryPlan?.deferForms === undefined
977
+ ? {}
978
+ : { deferForms: registryPlan.deferForms }),
979
+ from: input.from,
980
+ id: registryPlan?.id ?? `vocabulary:${input.from}->${input.to}`,
981
+ kind: 'vocabulary',
982
+ ...(intent === undefined ? {} : { intent }),
983
+ ...(overrides === undefined ? {} : { overrides }),
984
+ ...(preserveRules === undefined ? {} : { preserve: preserveRules }),
985
+ ...(scope === undefined ? {} : { scope }),
986
+ to: input.to,
987
+ });
988
+ };
989
+
990
+ const regradeRootNotFound = (rootDir: string) =>
991
+ Result.err(
992
+ new NotFoundError(
993
+ `Regrade root "${rootDir}" could not be read as a directory.`
994
+ )
995
+ );
996
+
997
+ const regradeNoEngineForScope = () =>
998
+ Result.err(
999
+ new ValidationError(
1000
+ 'Vocabulary regrade has no prose or governed symbol engine for the selected extension scope.'
1001
+ )
1002
+ );
1003
+
1004
+ const regradeRootIsReadable = (rootDir: string): boolean => {
1005
+ try {
1006
+ readdirSync(rootDir, { withFileTypes: true });
1007
+ return true;
1008
+ } catch {
1009
+ return false;
1010
+ }
1011
+ };
1012
+
1013
+ const validateRegradeReport = (
1014
+ report: RegradeReport
1015
+ ): TrailsResult<RegradeReport, Error> => {
1016
+ const validated = validateOutput(regradeReportOutput, report);
1017
+ if (validated.isErr()) {
1018
+ return validated;
1019
+ }
1020
+ return Result.ok(report);
1021
+ };
1022
+
1023
+ const reportWithVocabularyTransitionRun = (params: {
1024
+ readonly plan: VocabularyRegradePlan;
1025
+ readonly preserveInventory: readonly VocabularyPreserveInventoryEntry[];
1026
+ readonly report: RegradeReport;
1027
+ }): RegradeReport => {
1028
+ const preserveInventory =
1029
+ params.preserveInventory.length === 0
1030
+ ? params.report.run?.preserveInventory
1031
+ : params.preserveInventory;
1032
+ const run = params.report.run ?? {
1033
+ ledger: { cycle: 1, forms: {}, occurrences: [] },
1034
+ plan: params.plan,
1035
+ report: transitionRunReportForRegradeReport(params.report),
1036
+ };
1037
+
1038
+ return {
1039
+ ...params.report,
1040
+ run: {
1041
+ ...run,
1042
+ plan: params.plan,
1043
+ ...(preserveInventory === undefined ? {} : { preserveInventory }),
1044
+ report:
1045
+ params.report.run === undefined
1046
+ ? transitionRunReportForRegradeReport(params.report)
1047
+ : params.report.run.report,
1048
+ },
1049
+ };
1050
+ };
1051
+
1052
+ const runGovernedSymbolRegrade = (params: {
1053
+ readonly apply: boolean;
1054
+ readonly includeEntries: RegradeInput['includeEntries'];
1055
+ readonly plan: VocabularyRegradePlan;
1056
+ readonly preserveInventory: readonly VocabularyPreserveInventoryEntry[];
1057
+ readonly rootDir: string;
1058
+ }): TrailsResult<RegradeReport | null, Error> => {
1059
+ const transition = vocabularyRegradeTransitionForInput(
1060
+ params.plan.from,
1061
+ params.plan.to
1062
+ );
1063
+ if (transition === undefined) {
1064
+ return Result.ok(null);
1065
+ }
1066
+
1067
+ const symbolCollection = vocabularySymbolCollection(params.plan.scope);
1068
+ if (symbolCollection === null) {
1069
+ return Result.ok(null);
1070
+ }
1071
+ return runRegrade({
1072
+ apply: params.apply,
1073
+ classes: createGovernedAstIdentifierRenameClasses(
1074
+ {
1075
+ ...transition,
1076
+ symbolRenames: transition.symbolRenames,
1077
+ },
1078
+ {
1079
+ shouldPreserve: (occurrence) =>
1080
+ symbolOccurrenceIsPreserved(params.plan.preserve, {
1081
+ end: occurrence.end,
1082
+ form: occurrence.from,
1083
+ path: occurrence.path,
1084
+ source: occurrence.source,
1085
+ start: occurrence.start,
1086
+ }) ||
1087
+ symbolOccurrenceIsPreserved(params.preserveInventory, {
1088
+ end: occurrence.end,
1089
+ form: occurrence.from,
1090
+ path: occurrence.path,
1091
+ source: occurrence.source,
1092
+ start: occurrence.start,
1093
+ }),
1094
+ }
1095
+ ),
1096
+ ...(symbolCollection === undefined ? {} : { collection: symbolCollection }),
1097
+ includeEntries: params.includeEntries,
1098
+ root: params.rootDir,
1099
+ });
1100
+ };
1101
+
1102
+ const vocabularyRecordPathForInput = (
1103
+ rootDir: string,
1104
+ recordPath: string
1105
+ ): string => (isAbsolute(recordPath) ? recordPath : join(rootDir, recordPath));
1106
+
1107
+ const currentCommitSha = (rootDir: string): string | undefined => {
1108
+ try {
1109
+ return execFileSync(
1110
+ 'git',
1111
+ ['-C', rootDir, 'rev-parse', '--short=7', 'HEAD'],
1112
+ {
1113
+ encoding: 'utf8',
1114
+ stdio: ['ignore', 'pipe', 'ignore'],
1115
+ }
1116
+ ).trim();
1117
+ } catch {
1118
+ return undefined;
1119
+ }
1120
+ };
1121
+
1122
+ const vocabularyRecordEnvironment = (
1123
+ rootDir: string
1124
+ ): { readonly commitSha?: string; readonly root: string } => {
1125
+ const commitSha = currentCommitSha(rootDir);
1126
+ return {
1127
+ ...(commitSha === undefined ? {} : { commitSha }),
1128
+ root: rootDir,
1129
+ };
1130
+ };
1131
+
1132
+ const pendingExpansionCandidateCount = (plan: RegradePlanArtifact): number =>
1133
+ plan.expansion?.candidates.filter(
1134
+ (candidate) => candidate.status === 'pending'
1135
+ ).length ?? 0;
1136
+
1137
+ const reportWithPlanSummary = (
1138
+ report: RegradeReport,
1139
+ plan: RegradePlanArtifact,
1140
+ status: 'active' | 'stale'
1141
+ ): RegradeReport => ({
1142
+ ...report,
1143
+ plan: {
1144
+ ...(pendingExpansionCandidateCount(plan) === 0
1145
+ ? {}
1146
+ : { expansionPending: pendingExpansionCandidateCount(plan) }),
1147
+ path: plan.path,
1148
+ schemaVersion: plan.schemaVersion,
1149
+ status,
1150
+ },
1151
+ });
1152
+
1153
+ const reportWithHistorySummary = (
1154
+ report: RegradeReport,
1155
+ params: RegradeHistorySummary
1156
+ ): RegradeReport => ({
1157
+ ...report,
1158
+ history: {
1159
+ path: params.path,
1160
+ schemaVersion: params.schemaVersion,
1161
+ status: params.status,
1162
+ },
1163
+ });
1164
+
1165
+ const authoredPlanFieldKeys = [
1166
+ 'caseSensitive',
1167
+ 'deferForms',
1168
+ 'id',
1169
+ 'intent',
1170
+ 'overrides',
1171
+ 'preserve',
1172
+ 'scope',
1173
+ ] as const;
1174
+
1175
+ const isAuthoredPlanField = (
1176
+ input: RegradePlanInput,
1177
+ key: (typeof authoredPlanFieldKeys)[number]
1178
+ ): boolean => {
1179
+ switch (key) {
1180
+ case 'intent':
1181
+ case 'overrides':
1182
+ case 'preserve': {
1183
+ return input[key] !== undefined;
1184
+ }
1185
+ case 'scope': {
1186
+ return (
1187
+ input.exclude !== undefined ||
1188
+ input.extensions !== undefined ||
1189
+ input.include !== undefined
1190
+ );
1191
+ }
1192
+ default: {
1193
+ return false;
1194
+ }
1195
+ }
1196
+ };
1197
+
1198
+ const regradePlanProvenanceForInput = (
1199
+ input: RegradePlanInput,
1200
+ plan: VocabularyRegradePlan
1201
+ ): RegradePlanArtifact['provenance'] => {
1202
+ const fields: Record<string, 'authored' | 'derived'> = {
1203
+ from: 'authored',
1204
+ kind: 'derived',
1205
+ to: 'authored',
1206
+ };
1207
+
1208
+ for (const key of [
1209
+ 'caseSensitive',
1210
+ 'deferForms',
1211
+ 'id',
1212
+ 'intent',
1213
+ 'overrides',
1214
+ 'preserve',
1215
+ 'scope',
1216
+ ] as const) {
1217
+ if (plan[key] !== undefined) {
1218
+ fields[key] = isAuthoredPlanField(input, key) ? 'authored' : 'derived';
1219
+ }
1220
+ }
1221
+
1222
+ return { fields };
1223
+ };
1224
+
1225
+ const mergeAuthoredPlanFields = (
1226
+ current: VocabularyRegradePlanArtifact,
1227
+ plan: VocabularyRegradePlan
1228
+ ): VocabularyRegradePlan => {
1229
+ const merged: Record<string, unknown> = { ...plan };
1230
+ for (const key of authoredPlanFieldKeys) {
1231
+ if (
1232
+ current.provenance.fields[key] === 'authored' &&
1233
+ current.plan[key] !== undefined
1234
+ ) {
1235
+ Object.assign(merged, { [key]: current.plan[key] });
1236
+ }
1237
+ }
1238
+ return vocabularyRegradePlanSchema.parse(merged) as VocabularyRegradePlan;
1239
+ };
1240
+
1241
+ const preserveAuthoredPlanProvenance = (
1242
+ current: VocabularyRegradePlanArtifact,
1243
+ provenance: RegradePlanArtifact['provenance']
1244
+ ): RegradePlanArtifact['provenance'] => {
1245
+ const fields = { ...provenance.fields };
1246
+ for (const key of authoredPlanFieldKeys) {
1247
+ if (
1248
+ current.provenance.fields[key] === 'authored' &&
1249
+ current.plan[key] !== undefined
1250
+ ) {
1251
+ fields[key] = 'authored';
1252
+ }
1253
+ }
1254
+ return { fields };
1255
+ };
1256
+
1257
+ const buildRegradePlanArtifact = (params: {
1258
+ readonly expansion?: RegradePlanArtifact['expansion'];
1259
+ readonly input: RegradePlanInput;
1260
+ readonly plan: VocabularyRegradePlan;
1261
+ readonly report: RegradeReport;
1262
+ readonly rootDir: string;
1263
+ readonly transitionId?: string | undefined;
1264
+ }): RegradePlanArtifact => {
1265
+ const absolutePath = regradePlanPathForPlan(params.rootDir, params.plan);
1266
+ return {
1267
+ ...(params.expansion === undefined ? {} : { expansion: params.expansion }),
1268
+ kind: 'regrade-plan',
1269
+ path: rootRelativePath(params.rootDir, absolutePath),
1270
+ plan: params.plan,
1271
+ provenance: regradePlanProvenanceForInput(params.input, params.plan),
1272
+ schemaVersion: REGRADE_PLAN_SCHEMA_VERSION,
1273
+ sourceHash: regradeSourceHash(params.report),
1274
+ ...(params.transitionId === undefined
1275
+ ? {}
1276
+ : { transitionId: params.transitionId }),
1277
+ };
1278
+ };
1279
+
1280
+ const writeRegradePlanArtifact = (
1281
+ rootDir: string,
1282
+ artifact: RegradePlanArtifact
1283
+ ): TrailsResult<RegradePlanArtifact, InternalError | ValidationError> => {
1284
+ const parsed = regradePlanArtifactSchema.safeParse(artifact);
1285
+ if (!parsed.success) {
1286
+ return Result.err(
1287
+ new ValidationError('Invalid Regrade plan artifact.', {
1288
+ context: { issues: parsed.error.issues },
1289
+ })
1290
+ );
1291
+ }
1292
+ const absolutePath = join(rootDir, artifact.path);
1293
+ try {
1294
+ mkdirSync(dirname(absolutePath), { recursive: true });
1295
+ writeFileSync(absolutePath, `${JSON.stringify(parsed.data, null, 2)}\n`);
1296
+ } catch (error) {
1297
+ return Result.err(
1298
+ new InternalError('Failed to write Regrade plan artifact.', {
1299
+ ...(error instanceof Error ? { cause: error } : {}),
1300
+ context: { path: artifact.path },
1301
+ })
1302
+ );
1303
+ }
1304
+ return Result.ok(parsed.data as unknown as RegradePlanArtifact);
1305
+ };
1306
+
1307
+ const validateRegradePlanArtifact = (
1308
+ artifact: RegradePlanArtifact
1309
+ ): TrailsResult<RegradePlanArtifact, ValidationError> => {
1310
+ const parsed = regradePlanArtifactSchema.safeParse(artifact);
1311
+ if (!parsed.success) {
1312
+ return Result.err(
1313
+ new ValidationError('Invalid Regrade plan artifact.', {
1314
+ context: { issues: parsed.error.issues },
1315
+ })
1316
+ );
1317
+ }
1318
+ return Result.ok(parsed.data as unknown as RegradePlanArtifact);
1319
+ };
1320
+
1321
+ const readRegradePlanArtifact = (
1322
+ path: string
1323
+ ): TrailsResult<RegradePlanArtifact, InternalError | ValidationError> => {
1324
+ if (!existsSync(path)) {
1325
+ return Result.err(new ValidationError(`Regrade plan "${path}" not found.`));
1326
+ }
1327
+ let parsedJson: unknown;
1328
+ try {
1329
+ parsedJson = JSON.parse(readFileSync(path, 'utf8'));
1330
+ } catch (error) {
1331
+ return Result.err(
1332
+ new InternalError('Failed to read Regrade plan artifact.', {
1333
+ ...(error instanceof Error ? { cause: error } : {}),
1334
+ context: { path },
1335
+ })
1336
+ );
1337
+ }
1338
+ const parsed = regradePlanArtifactSchema.safeParse(parsedJson);
1339
+ if (!parsed.success) {
1340
+ return Result.err(
1341
+ new ValidationError('Invalid Regrade plan artifact.', {
1342
+ context: { issues: parsed.error.issues, path },
1343
+ })
1344
+ );
1345
+ }
1346
+ return Result.ok(parsed.data as unknown as RegradePlanArtifact);
1347
+ };
1348
+
1349
+ /**
1350
+ * Transition identity is not an authored plan field — it follows the
1351
+ * transition. Plan re-derivation (including `--fresh`) carries it forward
1352
+ * from the existing active plan of the same kind so a subsequent apply
1353
+ * appends to the same consolidated history spine instead of forking it.
1354
+ */
1355
+ const priorTransitionId = (
1356
+ currentPath: string,
1357
+ kind: RegradePlanBody['kind']
1358
+ ): string | undefined => {
1359
+ if (!existsSync(currentPath)) {
1360
+ return undefined;
1361
+ }
1362
+ const existing = readRegradePlanArtifact(currentPath);
1363
+ if (existing.isErr() || existing.value.plan.kind !== kind) {
1364
+ return undefined;
1365
+ }
1366
+ return existing.value.transitionId;
1367
+ };
1368
+
1369
+ const hasPathSeparator = (value: string): boolean =>
1370
+ value.includes('/') || value.includes('\\');
1371
+
1372
+ const isPlanPathReference = (value: string): boolean =>
1373
+ hasPathSeparator(value) ||
1374
+ value.startsWith('.') ||
1375
+ value.startsWith('~') ||
1376
+ isAbsolute(value);
1377
+
1378
+ const collectActiveRegradePlanPaths = (rootDir: string): string[] => {
1379
+ const results: string[] = [];
1380
+ const skipDirectories = new Set([
1381
+ '.git',
1382
+ '.next',
1383
+ '.turbo',
1384
+ 'dist',
1385
+ 'node_modules',
1386
+ ]);
1387
+
1388
+ const visit = (dir: string): void => {
1389
+ let entries: Dirent[] | undefined;
1390
+ try {
1391
+ entries = readdirSync(dir, { withFileTypes: true });
1392
+ } catch {
1393
+ return;
1394
+ }
1395
+ if (entries === undefined) {
1396
+ return;
1397
+ }
1398
+
1399
+ if (
1400
+ entries.some((entry) => entry.isDirectory() && entry.name === '.trails')
1401
+ ) {
1402
+ const regradeDir = join(dir, '.trails', 'regrade');
1403
+ try {
1404
+ for (const entry of readdirSync(regradeDir, { withFileTypes: true })) {
1405
+ if (entry.isFile() && entry.name.endsWith('.json')) {
1406
+ results.push(join(regradeDir, entry.name));
1407
+ }
1408
+ }
1409
+ } catch {
1410
+ // Not every `.trails` directory has Regrade plans.
1411
+ }
1412
+ }
1413
+
1414
+ for (const entry of entries) {
1415
+ if (!entry.isDirectory() || skipDirectories.has(entry.name)) {
1416
+ continue;
1417
+ }
1418
+ visit(join(dir, entry.name));
1419
+ }
1420
+ };
1421
+
1422
+ visit(rootDir);
1423
+ return results.toSorted((left, right) => left.localeCompare(right));
1424
+ };
1425
+
1426
+ const resolveRegradePlanPath = (
1427
+ rootDir: string,
1428
+ planRef?: string | undefined
1429
+ ): TrailsResult<string, ValidationError> => {
1430
+ if (planRef !== undefined) {
1431
+ if (isPlanPathReference(planRef)) {
1432
+ const normalized = planRef.startsWith('~/')
1433
+ ? join(process.env['HOME'] ?? '', planRef.slice(2))
1434
+ : planRef;
1435
+ return Result.ok(
1436
+ isAbsolute(normalized) ? normalized : join(rootDir, normalized)
1437
+ );
1438
+ }
1439
+ const normalizedRef = planRef.endsWith('.json')
1440
+ ? planRef.slice(0, -'.json'.length)
1441
+ : planRef;
1442
+ const matches = collectActiveRegradePlanPaths(rootDir).filter(
1443
+ (candidate) => basename(candidate, '.json') === normalizedRef
1444
+ );
1445
+ if (matches.length === 1) {
1446
+ return Result.ok(matches[0] as string);
1447
+ }
1448
+ if (matches.length === 0) {
1449
+ return Result.err(
1450
+ new ValidationError(`No active Regrade plan named "${planRef}" found.`)
1451
+ );
1452
+ }
1453
+ return Result.err(
1454
+ new ValidationError(
1455
+ `Multiple active Regrade plans named "${planRef}" found.`,
1456
+ {
1457
+ context: {
1458
+ matches: matches.map((match) => rootRelativePath(rootDir, match)),
1459
+ },
1460
+ }
1461
+ )
1462
+ );
1463
+ }
1464
+
1465
+ const plans = collectActiveRegradePlanPaths(rootDir);
1466
+ if (plans.length === 1) {
1467
+ return Result.ok(plans[0] as string);
1468
+ }
1469
+ if (plans.length === 0) {
1470
+ return Result.err(new ValidationError('No active Regrade plans found.'));
1471
+ }
1472
+ return Result.err(
1473
+ new ValidationError('Multiple active Regrade plans found; pass `--plan`.', {
1474
+ context: { plans: plans.map((plan) => rootRelativePath(rootDir, plan)) },
1475
+ })
1476
+ );
1477
+ };
1478
+
1479
+ const planStatusForReport = (
1480
+ artifact: RegradePlanArtifact,
1481
+ report: RegradeReport
1482
+ ): 'active' | 'stale' =>
1483
+ artifact.sourceHash === regradeSourceHash(report) ? 'active' : 'stale';
1484
+
1485
+ const regradePlanGateContext = (
1486
+ report: RegradeReport
1487
+ ):
1488
+ | {
1489
+ readonly gate?: unknown;
1490
+ readonly modified?: number;
1491
+ readonly review?: number;
1492
+ }
1493
+ | undefined => {
1494
+ const { apply, review, rewritten } = report;
1495
+ const modified = apply === undefined ? rewritten : 0;
1496
+ const counts = {
1497
+ ...(modified === 0 ? {} : { modified }),
1498
+ ...(review === 0 ? {} : { review }),
1499
+ };
1500
+ // Class-mode reports carry no vocabulary run: the gate is derived from the
1501
+ // outstanding rewrite and review counts alone.
1502
+ const gateStatus = report.run?.report.gate.status;
1503
+ if (gateStatus !== undefined && gateStatus !== 'green') {
1504
+ return { gate: report.run?.report.gate, ...counts };
1505
+ }
1506
+ if (modified > 0 || review > 0) {
1507
+ return { gate: report.run?.report.gate, ...counts };
1508
+ }
1509
+ return undefined;
1510
+ };
1511
+
1512
+ const persistVocabularyRecord = (params: {
1513
+ readonly report: RegradeReport;
1514
+ readonly rootDir: string;
1515
+ readonly status: 'applied' | 'candidate' | 'checked';
1516
+ }): TrailsResult<RegradeReport, Error> => {
1517
+ const recordResult = writeVocabularyTransitionRecord({
1518
+ environment: vocabularyRecordEnvironment(params.rootDir),
1519
+ report: params.report,
1520
+ root: params.rootDir,
1521
+ status: params.status,
1522
+ });
1523
+ if (recordResult.isErr()) {
1524
+ return recordResult;
1525
+ }
1526
+ return validateRegradeReport(
1527
+ transitionRecordReportWithSummary(params.report, recordResult.value.summary)
1528
+ );
1529
+ };
1530
+
1531
+ const runResolvedVocabularyPlan = (params: {
1532
+ readonly apply: boolean;
1533
+ readonly includeEntries: RegradeInput['includeEntries'];
1534
+ readonly plan: VocabularyRegradePlan;
1535
+ readonly preserveInventory: readonly VocabularyPreserveInventoryEntry[];
1536
+ readonly rootDir: string;
1537
+ }): TrailsResult<RegradeReport, Error> => {
1538
+ const prosePlan = vocabularyProsePlan(params.plan);
1539
+ const reportResult: TrailsResult<RegradeReport | null, Error> =
1540
+ prosePlan === null
1541
+ ? Result.ok(null)
1542
+ : runVocabularyRegrade({
1543
+ apply: params.apply,
1544
+ includeEntries: params.includeEntries,
1545
+ plan: prosePlan,
1546
+ ...(params.preserveInventory.length === 0
1547
+ ? {}
1548
+ : { preserveInventory: params.preserveInventory }),
1549
+ root: params.rootDir,
1550
+ });
1551
+ if (reportResult.isErr()) {
1552
+ return reportResult;
1553
+ }
1554
+
1555
+ const symbolReportResult = runGovernedSymbolRegrade({
1556
+ apply: params.apply,
1557
+ includeEntries: params.includeEntries,
1558
+ plan: params.plan,
1559
+ preserveInventory: params.preserveInventory,
1560
+ rootDir: params.rootDir,
1561
+ });
1562
+ if (symbolReportResult.isErr()) {
1563
+ return symbolReportResult;
1564
+ }
1565
+
1566
+ const report = reportResult.value;
1567
+ const symbolReport = symbolReportResult.value;
1568
+ if (report === null) {
1569
+ if (symbolReport === null) {
1570
+ return regradeNoEngineForScope();
1571
+ }
1572
+ return validateRegradeReport(
1573
+ reportWithVocabularyTransitionRun({
1574
+ plan: params.plan,
1575
+ preserveInventory: params.preserveInventory,
1576
+ report: symbolReport,
1577
+ })
1578
+ );
1579
+ }
1580
+
1581
+ const validated = validateRegradeReport(
1582
+ reportWithVocabularyTransitionRun({
1583
+ plan: params.plan,
1584
+ preserveInventory: params.preserveInventory,
1585
+ report,
1586
+ })
1587
+ );
1588
+ if (validated.isErr()) {
1589
+ return validated;
1590
+ }
1591
+ if (symbolReport === null) {
1592
+ return Result.ok(validated.value);
1593
+ }
1594
+
1595
+ return validateRegradeReport(
1596
+ reportWithVocabularyTransitionRun({
1597
+ plan: params.plan,
1598
+ preserveInventory: params.preserveInventory,
1599
+ report: mergeRegradeReports(report, symbolReport),
1600
+ })
1601
+ );
1602
+ };
1603
+
1604
+ interface ClassRegradeCoreParams {
1605
+ readonly apply: boolean;
1606
+ readonly classIds?: readonly string[] | undefined;
1607
+ readonly collection?:
1608
+ | {
1609
+ readonly exclude?: readonly string[] | undefined;
1610
+ readonly extensions?: readonly string[] | undefined;
1611
+ readonly include?: readonly string[] | undefined;
1612
+ }
1613
+ | undefined;
1614
+ readonly includeEntries: RegradeInput['includeEntries'];
1615
+ readonly rootDir: string;
1616
+ }
1617
+
1618
+ const runClassRegradeCore = async (
1619
+ params: ClassRegradeCoreParams
1620
+ ): Promise<TrailsResult<RegradeReport, Error>> => {
1621
+ const classSet = await loadWardenRegradeClasses(params.rootDir);
1622
+ if (classSet.diagnostics.length > 0) {
1623
+ return Result.err(
1624
+ new InternalError('Failed to load Regrade project Warden rules.', {
1625
+ context: {
1626
+ diagnostics: classSet.diagnostics,
1627
+ rootDir: params.rootDir,
1628
+ },
1629
+ })
1630
+ );
1631
+ }
1632
+
1633
+ const collection =
1634
+ params.collection === undefined
1635
+ ? undefined
1636
+ : {
1637
+ ...(params.collection.exclude === undefined
1638
+ ? {}
1639
+ : { exclude: params.collection.exclude }),
1640
+ ...(params.collection.extensions === undefined
1641
+ ? {}
1642
+ : { extensions: params.collection.extensions }),
1643
+ ...(params.collection.include === undefined
1644
+ ? {}
1645
+ : { include: params.collection.include }),
1646
+ };
1647
+ const reportResult: TrailsResult<RegradeReport | null, Error> = runRegrade({
1648
+ apply: params.apply,
1649
+ classes: classSet.classes,
1650
+ ...(collection === undefined ? {} : { collection }),
1651
+ includeEntries: params.includeEntries,
1652
+ root: params.rootDir,
1653
+ ...(params.classIds === undefined
1654
+ ? {}
1655
+ : { selection: { classIds: params.classIds } }),
1656
+ });
1657
+ if (reportResult.isErr()) {
1658
+ return reportResult;
1659
+ }
1660
+
1661
+ const report = reportResult.value;
1662
+ if (report === null) {
1663
+ return regradeRootNotFound(params.rootDir);
1664
+ }
1665
+
1666
+ return validateRegradeReport(report);
1667
+ };
1668
+
1669
+ const runClassPlanRegradeRun = (params: {
1670
+ readonly apply: boolean;
1671
+ readonly includeEntries: RegradePlanReferenceInput['includeEntries'];
1672
+ readonly plan: ClassRegradePlan;
1673
+ readonly rootDir: string;
1674
+ }): Promise<TrailsResult<RegradeReport, Error>> =>
1675
+ runClassRegradeCore({
1676
+ apply: params.apply,
1677
+ classIds: params.plan.classIds,
1678
+ ...(params.plan.scope === undefined
1679
+ ? {}
1680
+ : { collection: params.plan.scope }),
1681
+ includeEntries: params.includeEntries,
1682
+ rootDir: params.rootDir,
1683
+ });
1684
+
1685
+ const runPlanArtifactDryRun = async (params: {
1686
+ readonly artifact: RegradePlanArtifact;
1687
+ readonly includeEntries: RegradePlanReferenceInput['includeEntries'];
1688
+ readonly rootDir: string;
1689
+ }): Promise<TrailsResult<RegradeReport, Error>> => {
1690
+ const planBody = params.artifact.plan;
1691
+ if (planBody.kind === 'class') {
1692
+ return runClassPlanRegradeRun({
1693
+ apply: false,
1694
+ includeEntries: params.includeEntries,
1695
+ plan: planBody,
1696
+ rootDir: params.rootDir,
1697
+ });
1698
+ }
1699
+ const preserveInventory = await deriveLiveApiPreserveInventory(planBody);
1700
+ return runResolvedVocabularyPlan({
1701
+ apply: false,
1702
+ includeEntries: params.includeEntries,
1703
+ plan: planBody,
1704
+ preserveInventory,
1705
+ rootDir: params.rootDir,
1706
+ });
1707
+ };
1708
+
1709
+ const runLegacyVocabularyRecordRegrade = (
1710
+ input: RegradeInput,
1711
+ rootDir: string,
1712
+ absoluteRecordPath: string
1713
+ ): TrailsResult<RegradeReport, Error> => {
1714
+ const recordResult = readVocabularyTransitionRecord(absoluteRecordPath);
1715
+ if (recordResult.isErr()) {
1716
+ return recordResult;
1717
+ }
1718
+ const record = recordResult.value;
1719
+ if (record.report.run === undefined) {
1720
+ return Result.err(
1721
+ new ValidationError(
1722
+ 'Transition record does not contain a vocabulary run.'
1723
+ )
1724
+ );
1725
+ }
1726
+ const dryRun = runResolvedVocabularyPlan({
1727
+ apply: false,
1728
+ includeEntries: input.includeEntries,
1729
+ plan: record.report.run.plan,
1730
+ preserveInventory: record.report.run.preserveInventory ?? [],
1731
+ rootDir,
1732
+ });
1733
+ if (dryRun.isErr()) {
1734
+ return dryRun;
1735
+ }
1736
+ if (regradeSourceHash(dryRun.value) !== regradeSourceHash(record.report)) {
1737
+ return Result.err(
1738
+ new ValidationError(
1739
+ 'Vocabulary transition record is stale for the current source tree. Re-run discovery and review the new record before applying.',
1740
+ { context: { recordPath: record.recordPath } }
1741
+ )
1742
+ );
1743
+ }
1744
+ if (input.check) {
1745
+ const checked = transitionRecordReportWithSummary(record.report, {
1746
+ path: record.recordPath,
1747
+ schemaVersion: record.schemaVersion,
1748
+ status: 'checked',
1749
+ });
1750
+ if (record.report.run.report.gate.status !== 'green') {
1751
+ return Result.err(
1752
+ new ValidationError('Vocabulary transition record gate is open.', {
1753
+ context: {
1754
+ gate: record.report.run.report.gate,
1755
+ recordPath: record.recordPath,
1756
+ },
1757
+ })
1758
+ );
1759
+ }
1760
+ return validateRegradeReport(checked);
1761
+ }
1762
+
1763
+ if (!input.apply) {
1764
+ return Result.err(
1765
+ new ValidationError(
1766
+ 'Applying a legacy vocabulary transition record requires `apply: true` or `--apply`. Use `--check` to verify the record without mutating source.',
1767
+ { context: { recordPath: record.recordPath } }
1768
+ )
1769
+ );
1770
+ }
1771
+
1772
+ const applied = runResolvedVocabularyPlan({
1773
+ apply: true,
1774
+ includeEntries: input.includeEntries,
1775
+ plan: record.report.run.plan,
1776
+ preserveInventory: record.report.run.preserveInventory ?? [],
1777
+ rootDir,
1778
+ });
1779
+ if (applied.isErr()) {
1780
+ return applied;
1781
+ }
1782
+ return persistVocabularyRecord({
1783
+ report: applied.value,
1784
+ rootDir,
1785
+ status: 'applied',
1786
+ });
1787
+ };
1788
+
1789
+ const runVocabularyCommandRegrade = async (
1790
+ input: RegradeInput,
1791
+ rootDir: string,
1792
+ configScope?: RegradeConfigScope | undefined
1793
+ ): Promise<TrailsResult<RegradeReport, Error>> => {
1794
+ if (input.apply && input.planRecord === undefined) {
1795
+ return Result.err(
1796
+ new ValidationError(
1797
+ 'Vocabulary regrade apply requires `planRecord`. Run discovery with `writeRecord` first, review the record, then apply the confirmed record.'
1798
+ )
1799
+ );
1800
+ }
1801
+ if (input.check && input.planRecord === undefined) {
1802
+ return Result.err(
1803
+ new ValidationError(
1804
+ 'Vocabulary regrade check requires `planRecord` so the gate is computed from persisted evidence.'
1805
+ )
1806
+ );
1807
+ }
1808
+ if (input.planRecord !== undefined) {
1809
+ const absoluteRecordPath = vocabularyRecordPathForInput(
1810
+ rootDir,
1811
+ input.planRecord
1812
+ );
1813
+ return runLegacyVocabularyRecordRegrade(input, rootDir, absoluteRecordPath);
1814
+ }
1815
+
1816
+ const planResult = buildVocabularyPlan(
1817
+ input,
1818
+ vocabularyScopeFromConfig(configScope)
1819
+ );
1820
+ if (planResult.isErr()) {
1821
+ return planResult;
1822
+ }
1823
+ if (!regradeRootIsReadable(rootDir)) {
1824
+ return regradeRootNotFound(rootDir);
1825
+ }
1826
+
1827
+ const preserveInventory = await deriveLiveApiPreserveInventory(
1828
+ planResult.value
1829
+ );
1830
+ const report = runResolvedVocabularyPlan({
1831
+ apply: input.apply,
1832
+ includeEntries: input.includeEntries,
1833
+ plan: planResult.value,
1834
+ preserveInventory,
1835
+ rootDir,
1836
+ });
1837
+ if (report.isErr() || !input.writeRecord) {
1838
+ return report;
1839
+ }
1840
+ return persistVocabularyRecord({
1841
+ report: report.value,
1842
+ rootDir,
1843
+ status: input.apply ? 'applied' : 'candidate',
1844
+ });
1845
+ };
1846
+
1847
+ const expansionCandidateKey = (
1848
+ candidate: RegradePlanExpansion['candidates'][number]
1849
+ ): string =>
1850
+ [candidate.kind, candidate.value, candidate.suggestedClassification].join(
1851
+ '\0'
1852
+ );
1853
+
1854
+ const expansionEvidenceKey = (
1855
+ evidence: RegradePlanExpansion['candidates'][number]['evidence'][number]
1856
+ ): string =>
1857
+ [
1858
+ evidence.path,
1859
+ evidence.line ?? '',
1860
+ evidence.column ?? '',
1861
+ evidence.detail ?? '',
1862
+ ].join('\0');
1863
+
1864
+ const mergeExpansionEvidence = (
1865
+ left: RegradePlanExpansion['candidates'][number]['evidence'],
1866
+ right: RegradePlanExpansion['candidates'][number]['evidence']
1867
+ ): RegradePlanExpansion['candidates'][number]['evidence'] => {
1868
+ const merged = new Map<
1869
+ string,
1870
+ RegradePlanExpansion['candidates'][number]['evidence'][number]
1871
+ >();
1872
+ for (const evidence of [...left, ...right]) {
1873
+ merged.set(expansionEvidenceKey(evidence), evidence);
1874
+ }
1875
+ return [...merged.values()].toSorted((a, b) =>
1876
+ a.path === b.path
1877
+ ? (a.line ?? 0) - (b.line ?? 0) ||
1878
+ (a.column ?? 0) - (b.column ?? 0) ||
1879
+ (a.detail ?? '').localeCompare(b.detail ?? '')
1880
+ : a.path.localeCompare(b.path)
1881
+ );
1882
+ };
1883
+
1884
+ const compareCandidates = (
1885
+ left: RegradePlanExpansion['candidates'][number],
1886
+ right: RegradePlanExpansion['candidates'][number]
1887
+ ): number => {
1888
+ if (left.kind !== right.kind) {
1889
+ return left.kind.localeCompare(right.kind);
1890
+ }
1891
+ if (left.value !== right.value) {
1892
+ return left.value.localeCompare(right.value);
1893
+ }
1894
+ return left.suggestedClassification.localeCompare(
1895
+ right.suggestedClassification
1896
+ );
1897
+ };
1898
+
1899
+ const expansionForReport = (
1900
+ report: RegradeReport
1901
+ ): RegradePlanArtifact['expansion'] => {
1902
+ const candidates = new Map<
1903
+ string,
1904
+ RegradePlanExpansion['candidates'][number]
1905
+ >();
1906
+ const candidateValues = new Set<string>();
1907
+ const addCandidate = (
1908
+ candidate: RegradePlanExpansion['candidates'][number]
1909
+ ): void => {
1910
+ const key = expansionCandidateKey(candidate);
1911
+ candidateValues.add(`${candidate.kind}\0${candidate.value}`);
1912
+ const current = candidates.get(key);
1913
+ if (current === undefined) {
1914
+ candidates.set(key, candidate);
1915
+ return;
1916
+ }
1917
+ candidates.set(key, {
1918
+ ...current,
1919
+ evidence: mergeExpansionEvidence(current.evidence, candidate.evidence),
1920
+ });
1921
+ };
1922
+
1923
+ for (const occurrence of report.run?.ledger.occurrences ?? []) {
1924
+ if (occurrence.verdict !== 'deferred') {
1925
+ continue;
1926
+ }
1927
+ addCandidate({
1928
+ evidence: [
1929
+ {
1930
+ column: occurrence.column,
1931
+ detail: occurrence.reason,
1932
+ line: occurrence.line,
1933
+ path: occurrence.path,
1934
+ },
1935
+ ],
1936
+ kind: 'form',
1937
+ status: 'pending',
1938
+ suggestedClassification: occurrence.disposition,
1939
+ value: occurrence.form,
1940
+ });
1941
+ }
1942
+
1943
+ for (const entry of report.entries) {
1944
+ if (entry.outcome !== 'needs-review' || entry.reviewDetails === undefined) {
1945
+ continue;
1946
+ }
1947
+ for (const detail of entry.reviewDetails) {
1948
+ if (detail.symbol === undefined) {
1949
+ continue;
1950
+ }
1951
+ if (candidateValues.has(`form\0${detail.symbol}`)) {
1952
+ continue;
1953
+ }
1954
+ addCandidate({
1955
+ evidence: [
1956
+ {
1957
+ ...(detail.span === undefined
1958
+ ? {}
1959
+ : {
1960
+ column: detail.span.column,
1961
+ line: detail.span.line,
1962
+ }),
1963
+ detail: detail.reason,
1964
+ path: entry.path,
1965
+ },
1966
+ ],
1967
+ kind: 'form',
1968
+ status: 'pending',
1969
+ suggestedClassification: entry.reason ?? detail.reason,
1970
+ value: detail.symbol,
1971
+ });
1972
+ }
1973
+ }
1974
+
1975
+ return { candidates: [...candidates.values()].toSorted(compareCandidates) };
1976
+ };
1977
+
1978
+ const preserveRuleCoversForm = (
1979
+ rule: VocabularyPreserveRule,
1980
+ form: string
1981
+ ): boolean =>
1982
+ (rule.forms === undefined || rule.forms.includes(form)) &&
1983
+ compileVocabularyPreservePattern(rule.pattern).test(form);
1984
+
1985
+ const preserveRuleCoversCandidateEvidence = (
1986
+ rule: VocabularyPreserveRule,
1987
+ form: string,
1988
+ evidence: RegradePlanExpansion['candidates'][number]['evidence'][number]
1989
+ ): boolean =>
1990
+ preserveRuleCoversForm(rule, form) &&
1991
+ (rule.paths === undefined || matchesAnyPathGlob(evidence.path, rule.paths));
1992
+
1993
+ const preserveRulesCoverFormCandidate = (
1994
+ preserve: readonly VocabularyPreserveRule[] | undefined,
1995
+ candidate: RegradePlanExpansion['candidates'][number]
1996
+ ): boolean => {
1997
+ if (preserve === undefined) {
1998
+ return false;
1999
+ }
2000
+ if (candidate.kind !== 'form') {
2001
+ return false;
2002
+ }
2003
+
2004
+ if (candidate.evidence.length === 0) {
2005
+ return preserve.some(
2006
+ (rule) =>
2007
+ rule.paths === undefined &&
2008
+ preserveRuleCoversForm(rule, candidate.value)
2009
+ );
2010
+ }
2011
+
2012
+ return candidate.evidence.every((evidence) =>
2013
+ preserve.some((rule) =>
2014
+ preserveRuleCoversCandidateEvidence(rule, candidate.value, evidence)
2015
+ )
2016
+ );
2017
+ };
2018
+
2019
+ const primaryPlanCoversExpansionCandidate = (
2020
+ plan: VocabularyRegradePlan,
2021
+ candidate: RegradePlanExpansion['candidates'][number]
2022
+ ): boolean => {
2023
+ if (candidate.kind !== 'form') {
2024
+ return false;
2025
+ }
2026
+ return (
2027
+ plan.deferForms?.includes(candidate.value) === true ||
2028
+ plan.overrides?.[candidate.value] !== undefined ||
2029
+ preserveRulesCoverFormCandidate(plan.preserve, candidate)
2030
+ );
2031
+ };
2032
+
2033
+ const mergeRegradePlanExpansion = (
2034
+ current: RegradePlanExpansion | undefined,
2035
+ next: RegradePlanExpansion | undefined,
2036
+ plan: VocabularyRegradePlan
2037
+ ): RegradePlanExpansion | undefined => {
2038
+ const candidates = new Map<
2039
+ string,
2040
+ RegradePlanExpansion['candidates'][number]
2041
+ >();
2042
+
2043
+ for (const candidate of current?.candidates ?? []) {
2044
+ if (primaryPlanCoversExpansionCandidate(plan, candidate)) {
2045
+ continue;
2046
+ }
2047
+ candidates.set(expansionCandidateKey(candidate), candidate);
2048
+ }
2049
+
2050
+ for (const candidate of next?.candidates ?? []) {
2051
+ if (primaryPlanCoversExpansionCandidate(plan, candidate)) {
2052
+ continue;
2053
+ }
2054
+ const key = expansionCandidateKey(candidate);
2055
+ const existing = candidates.get(key);
2056
+ if (existing?.status === 'rejected') {
2057
+ candidates.set(key, existing);
2058
+ continue;
2059
+ }
2060
+ candidates.set(key, {
2061
+ ...candidate,
2062
+ ...(existing === undefined
2063
+ ? {}
2064
+ : {
2065
+ evidence: mergeExpansionEvidence(
2066
+ existing.evidence,
2067
+ candidate.evidence
2068
+ ),
2069
+ status: existing.status,
2070
+ }),
2071
+ });
2072
+ }
2073
+
2074
+ const merged = [...candidates.values()]
2075
+ .filter(
2076
+ (candidate) => !primaryPlanCoversExpansionCandidate(plan, candidate)
2077
+ )
2078
+ .toSorted(compareCandidates);
2079
+ return merged.length === 0 ? undefined : { candidates: merged };
2080
+ };
2081
+
2082
+ const classPlanScopeForInput = (
2083
+ input: RegradePlanInput,
2084
+ configScope: RegradeConfigScope | undefined
2085
+ ): ClassRegradePlan['scope'] => {
2086
+ const exclude = input.exclude ?? configScope?.exclude;
2087
+ const extensions = input.extensions ?? configScope?.extensions;
2088
+ const include = input.include ?? configScope?.include;
2089
+ if (
2090
+ exclude === undefined &&
2091
+ extensions === undefined &&
2092
+ include === undefined
2093
+ ) {
2094
+ return undefined;
2095
+ }
2096
+ return {
2097
+ ...(exclude === undefined ? {} : { exclude: [...exclude] }),
2098
+ ...(extensions === undefined ? {} : { extensions: [...extensions] }),
2099
+ ...(include === undefined ? {} : { include: [...include] }),
2100
+ };
2101
+ };
2102
+
2103
+ const validateClassPlanInput = (
2104
+ input: RegradePlanInput
2105
+ ): ValidationError | null => {
2106
+ if (input.classIds === undefined || input.classIds.length === 0) {
2107
+ return new ValidationError(
2108
+ 'A class-mode Regrade plan requires at least one class id.'
2109
+ );
2110
+ }
2111
+ if (input.from !== undefined || input.to !== undefined) {
2112
+ return new ValidationError(
2113
+ '`classIds` selects a class-mode plan and cannot be combined with vocabulary `from`/`to`.'
2114
+ );
2115
+ }
2116
+ if (input.type === 'vocabulary') {
2117
+ return new ValidationError(
2118
+ '`type: vocabulary` cannot be combined with `classIds`.'
2119
+ );
2120
+ }
2121
+ if (input.expand) {
2122
+ return new ValidationError(
2123
+ '`expand` stages vocabulary review candidates and is not supported for class-mode plans.'
2124
+ );
2125
+ }
2126
+ return null;
2127
+ };
2128
+
2129
+ type ClassPlanArtifact = RegradePlanArtifact & {
2130
+ readonly plan: ClassRegradePlan;
2131
+ };
2132
+
2133
+ const readCurrentClassPlanArtifact = (
2134
+ input: RegradePlanInput,
2135
+ currentPath: string
2136
+ ): TrailsResult<ClassPlanArtifact | null, Error> => {
2137
+ if (input.fresh || !existsSync(currentPath)) {
2138
+ return Result.ok(null);
2139
+ }
2140
+ const currentResult = readRegradePlanArtifact(currentPath);
2141
+ if (currentResult.isErr()) {
2142
+ return currentResult;
2143
+ }
2144
+ const candidate = currentResult.value;
2145
+ if (candidate.plan.kind !== 'class') {
2146
+ return Result.ok(null);
2147
+ }
2148
+ return Result.ok({ ...candidate, plan: candidate.plan });
2149
+ };
2150
+
2151
+ /** Carry authored intent and scope forward from the existing plan artifact. */
2152
+ const mergeAuthoredClassPlanFields = (
2153
+ plan: ClassRegradePlan,
2154
+ input: RegradePlanInput,
2155
+ authoredScope: boolean,
2156
+ current: ClassPlanArtifact | null
2157
+ ): ClassRegradePlan => {
2158
+ if (current === null) {
2159
+ return plan;
2160
+ }
2161
+ let merged = plan;
2162
+ if (
2163
+ input.intent === undefined &&
2164
+ current.provenance.fields['intent'] === 'authored' &&
2165
+ current.plan.intent !== undefined
2166
+ ) {
2167
+ merged = { ...merged, intent: current.plan.intent };
2168
+ }
2169
+ if (
2170
+ input.name === undefined &&
2171
+ current.provenance.fields['name'] === 'authored' &&
2172
+ current.plan.name !== undefined
2173
+ ) {
2174
+ merged = { ...merged, name: current.plan.name };
2175
+ }
2176
+ if (
2177
+ !authoredScope &&
2178
+ current.provenance.fields['scope'] === 'authored' &&
2179
+ current.plan.scope !== undefined
2180
+ ) {
2181
+ merged = { ...merged, scope: current.plan.scope };
2182
+ }
2183
+ return merged;
2184
+ };
2185
+
2186
+ const classPlanProvenance = (
2187
+ plan: ClassRegradePlan,
2188
+ authoredScope: boolean,
2189
+ current: ClassPlanArtifact | null
2190
+ ): RegradePlanArtifact['provenance'] => ({
2191
+ fields: {
2192
+ classIds: 'authored',
2193
+ id: 'derived',
2194
+ kind: 'derived',
2195
+ ...(plan.intent === undefined ? {} : { intent: 'authored' }),
2196
+ ...(plan.name === undefined ? {} : { name: 'authored' }),
2197
+ ...(plan.scope === undefined
2198
+ ? {}
2199
+ : {
2200
+ scope:
2201
+ authoredScope || current?.provenance.fields['scope'] === 'authored'
2202
+ ? 'authored'
2203
+ : 'derived',
2204
+ }),
2205
+ },
27
2206
  });
28
2207
 
2208
+ /**
2209
+ * A named class plan keys its file on the name alone, so a reused name with
2210
+ * different class ids would silently overwrite an unrelated in-progress plan
2211
+ * (and later mix runs into its consolidated history). Refuse the collision;
2212
+ * unreadable or non-class artifacts keep their existing handling.
2213
+ */
2214
+ const classPlanIdentityConflict = (
2215
+ rootDir: string,
2216
+ currentPath: string,
2217
+ classIds: readonly string[]
2218
+ ): ValidationError | null => {
2219
+ if (!existsSync(currentPath)) {
2220
+ return null;
2221
+ }
2222
+ const existing = readRegradePlanArtifact(currentPath);
2223
+ if (existing.isErr() || existing.value.plan.kind !== 'class') {
2224
+ return null;
2225
+ }
2226
+ const existingIds = existing.value.plan.classIds;
2227
+ if (
2228
+ existingIds.length === classIds.length &&
2229
+ existingIds.every((id, index) => id === classIds[index])
2230
+ ) {
2231
+ return null;
2232
+ }
2233
+ return new ValidationError(
2234
+ 'An active class-mode Regrade plan with this name already runs different class ids. Pick a different `name`, or delete the existing plan file if it is abandoned.',
2235
+ {
2236
+ context: {
2237
+ existing: [...existingIds],
2238
+ path: rootRelativePath(rootDir, currentPath),
2239
+ planned: [...classIds],
2240
+ },
2241
+ }
2242
+ );
2243
+ };
2244
+
2245
+ const runClassPlanRegrade = async (
2246
+ input: RegradePlanInput,
2247
+ rootDir: string,
2248
+ configScope: RegradeConfigScope | undefined,
2249
+ shouldDryRun: boolean
2250
+ ): Promise<TrailsResult<RegradePlanArtifact, Error>> => {
2251
+ const invalid = validateClassPlanInput(input);
2252
+ if (invalid !== null) {
2253
+ return Result.err(invalid);
2254
+ }
2255
+ const classIds = input.classIds ?? [];
2256
+ if (!regradeRootIsReadable(rootDir)) {
2257
+ return regradeRootNotFound(rootDir);
2258
+ }
2259
+
2260
+ const inputScope = classPlanScopeForInput(input, configScope);
2261
+ const basePlan: ClassRegradePlan = {
2262
+ classIds: [...classIds],
2263
+ id: `class:${classIds.join('+')}`,
2264
+ ...(input.intent === undefined ? {} : { intent: input.intent }),
2265
+ kind: 'class',
2266
+ ...(input.name === undefined ? {} : { name: input.name }),
2267
+ ...(inputScope === undefined ? {} : { scope: inputScope }),
2268
+ };
2269
+ const currentPath = regradePlanPathForPlan(rootDir, basePlan);
2270
+ const conflict = classPlanIdentityConflict(rootDir, currentPath, classIds);
2271
+ if (conflict !== null) {
2272
+ return Result.err(conflict);
2273
+ }
2274
+ const currentResult = readCurrentClassPlanArtifact(input, currentPath);
2275
+ if (currentResult.isErr()) {
2276
+ return currentResult;
2277
+ }
2278
+ const current = currentResult.value;
2279
+ const authoredScope =
2280
+ input.exclude !== undefined ||
2281
+ input.extensions !== undefined ||
2282
+ input.include !== undefined;
2283
+ const plan = mergeAuthoredClassPlanFields(
2284
+ basePlan,
2285
+ input,
2286
+ authoredScope,
2287
+ current
2288
+ );
2289
+
2290
+ const report = await runClassPlanRegradeRun({
2291
+ apply: false,
2292
+ includeEntries: input.includeEntries,
2293
+ plan,
2294
+ rootDir,
2295
+ });
2296
+ if (report.isErr()) {
2297
+ return report;
2298
+ }
2299
+ if (report.value.unknownClassIds.length > 0) {
2300
+ return Result.err(
2301
+ new ValidationError('Unknown Regrade class ids.', {
2302
+ context: { unknownClassIds: report.value.unknownClassIds },
2303
+ })
2304
+ );
2305
+ }
2306
+
2307
+ const transitionId = priorTransitionId(currentPath, 'class');
2308
+ const artifact: RegradePlanArtifact = {
2309
+ kind: 'regrade-plan',
2310
+ path: rootRelativePath(rootDir, currentPath),
2311
+ plan,
2312
+ provenance: classPlanProvenance(plan, authoredScope, current),
2313
+ schemaVersion: REGRADE_PLAN_SCHEMA_VERSION,
2314
+ sourceHash: regradeSourceHash(report.value),
2315
+ ...(transitionId === undefined ? {} : { transitionId }),
2316
+ };
2317
+ if (shouldDryRun) {
2318
+ return validateRegradePlanArtifact(artifact);
2319
+ }
2320
+ return writeRegradePlanArtifact(rootDir, artifact);
2321
+ };
2322
+
2323
+ const readCurrentVocabularyPlanArtifact = (
2324
+ input: RegradePlanInput,
2325
+ currentPath: string
2326
+ ): TrailsResult<VocabularyRegradePlanArtifact | null, Error> => {
2327
+ if (input.fresh || !existsSync(currentPath)) {
2328
+ return Result.ok(null);
2329
+ }
2330
+ const currentResult = readRegradePlanArtifact(currentPath);
2331
+ if (currentResult.isErr()) {
2332
+ return currentResult;
2333
+ }
2334
+ const candidate = currentResult.value;
2335
+ if (candidate.plan.kind !== 'vocabulary') {
2336
+ return Result.ok(null);
2337
+ }
2338
+ return Result.ok({ ...candidate, plan: candidate.plan });
2339
+ };
2340
+
2341
+ const runPlanRegrade = async (
2342
+ input: RegradePlanInput,
2343
+ rootDir: string,
2344
+ configScope?: RegradeConfigScope | undefined,
2345
+ shouldDryRun = false
2346
+ ): Promise<TrailsResult<RegradePlanArtifact, Error>> => {
2347
+ if (input.classIds !== undefined || input.type === 'class') {
2348
+ return runClassPlanRegrade(input, rootDir, configScope, shouldDryRun);
2349
+ }
2350
+ if (input.type !== undefined && input.type !== 'vocabulary') {
2351
+ return Result.err(
2352
+ new ValidationError(`Unsupported Regrade plan type "${input.type}".`)
2353
+ );
2354
+ }
2355
+ if (input.name !== undefined) {
2356
+ return Result.err(
2357
+ new ValidationError(
2358
+ '`name` names a class-mode transition; vocabulary transitions are keyed by `from`/`to`.'
2359
+ )
2360
+ );
2361
+ }
2362
+ const planInput: RegradeInput = {
2363
+ ...input,
2364
+ apply: false,
2365
+ check: false,
2366
+ writeRecord: false,
2367
+ };
2368
+ const planResult = buildVocabularyPlan(
2369
+ planInput,
2370
+ vocabularyScopeFromConfig(configScope)
2371
+ );
2372
+ if (planResult.isErr()) {
2373
+ return planResult;
2374
+ }
2375
+ if (!regradeRootIsReadable(rootDir)) {
2376
+ return regradeRootNotFound(rootDir);
2377
+ }
2378
+ const currentPath = regradePlanPathForPlan(rootDir, planResult.value);
2379
+ const currentResult = readCurrentVocabularyPlanArtifact(input, currentPath);
2380
+ if (currentResult.isErr()) {
2381
+ return currentResult;
2382
+ }
2383
+ const current = currentResult.value ?? undefined;
2384
+ const plan =
2385
+ current === undefined
2386
+ ? planResult.value
2387
+ : mergeAuthoredPlanFields(current, planResult.value);
2388
+ const preserveInventory = await deriveLiveApiPreserveInventory(plan);
2389
+ const report = runResolvedVocabularyPlan({
2390
+ apply: false,
2391
+ includeEntries: input.includeEntries,
2392
+ plan,
2393
+ preserveInventory,
2394
+ rootDir,
2395
+ });
2396
+ if (report.isErr()) {
2397
+ return report;
2398
+ }
2399
+ const expansion = mergeRegradePlanExpansion(
2400
+ current?.expansion,
2401
+ input.expand ? expansionForReport(report.value) : undefined,
2402
+ plan
2403
+ );
2404
+ const transitionId = priorTransitionId(currentPath, 'vocabulary');
2405
+ const artifact = buildRegradePlanArtifact({
2406
+ ...(expansion === undefined ? {} : { expansion }),
2407
+ input,
2408
+ plan,
2409
+ report: report.value,
2410
+ rootDir,
2411
+ ...(transitionId === undefined ? {} : { transitionId }),
2412
+ });
2413
+ const mergedArtifact =
2414
+ current === undefined
2415
+ ? artifact
2416
+ : {
2417
+ ...artifact,
2418
+ provenance: preserveAuthoredPlanProvenance(
2419
+ current,
2420
+ artifact.provenance
2421
+ ),
2422
+ };
2423
+ if (shouldDryRun) {
2424
+ return validateRegradePlanArtifact(mergedArtifact);
2425
+ }
2426
+ return writeRegradePlanArtifact(rootDir, mergedArtifact);
2427
+ };
2428
+
2429
+ const loadPlanForInput = async (
2430
+ input: RegradePlanReferenceInput,
2431
+ rootDir: string
2432
+ ): Promise<
2433
+ TrailsResult<
2434
+ { readonly artifact: RegradePlanArtifact; readonly path: string },
2435
+ Error
2436
+ >
2437
+ > => {
2438
+ const path = resolveRegradePlanPath(rootDir, input.plan);
2439
+ if (path.isErr()) {
2440
+ return path;
2441
+ }
2442
+ const artifact = readRegradePlanArtifact(path.value);
2443
+ if (artifact.isErr()) {
2444
+ return artifact;
2445
+ }
2446
+ return Result.ok({ artifact: artifact.value, path: path.value });
2447
+ };
2448
+
2449
+ const reportWithCheckedHistorySummary = (
2450
+ report: RegradeReport,
2451
+ historyPath: string
2452
+ ): RegradeReport => ({
2453
+ ...report,
2454
+ history: {
2455
+ path: historyPath,
2456
+ schemaVersion: REGRADE_HISTORY_SCHEMA_VERSION,
2457
+ status: 'checked',
2458
+ },
2459
+ });
2460
+
2461
+ /**
2462
+ * Check a graduated transition: verify every recorded run in the
2463
+ * consolidated history at its own stamped lock. Historical runs are not
2464
+ * re-executed — per-run stamp verification is the machine acceptance.
2465
+ */
2466
+ const checkGraduatedRegradeHistory = (
2467
+ historyPath: string
2468
+ ): TrailsResult<RegradeReport, Error> => {
2469
+ const artifact = readRegradeHistoryArtifact(historyPath);
2470
+ if (artifact.isErr()) {
2471
+ return artifact;
2472
+ }
2473
+ const verified = verifyRegradeHistoryRuns(artifact.value);
2474
+ if (verified.isErr()) {
2475
+ return verified;
2476
+ }
2477
+ const lastRun = artifact.value.runs.at(-1);
2478
+ if (lastRun === undefined) {
2479
+ return Result.err(
2480
+ new ValidationError('Regrade history has no recorded runs.', {
2481
+ context: { path: artifact.value.path },
2482
+ })
2483
+ );
2484
+ }
2485
+ return validateRegradeReport(
2486
+ reportWithCheckedHistorySummary(lastRun.report, artifact.value.path)
2487
+ );
2488
+ };
2489
+
2490
+ const runCheckRegradePlan = async (
2491
+ input: RegradePlanReferenceInput,
2492
+ rootDir: string
2493
+ ): Promise<TrailsResult<RegradeReport, Error>> => {
2494
+ const planPath = resolveRegradePlanPath(rootDir, input.plan);
2495
+ if (planPath.isErr()) {
2496
+ if (input.plan !== undefined && !isPlanPathReference(input.plan)) {
2497
+ const historyPath = resolveRegradeHistoryPath(rootDir, input.plan);
2498
+ if (historyPath.isOk()) {
2499
+ return checkGraduatedRegradeHistory(historyPath.value);
2500
+ }
2501
+ }
2502
+ return planPath;
2503
+ }
2504
+ const artifact = readRegradePlanArtifact(planPath.value);
2505
+ if (artifact.isErr()) {
2506
+ return artifact;
2507
+ }
2508
+ const loaded = {
2509
+ value: { artifact: artifact.value, path: planPath.value },
2510
+ };
2511
+ const report = await runPlanArtifactDryRun({
2512
+ artifact: loaded.value.artifact,
2513
+ includeEntries: input.includeEntries,
2514
+ rootDir,
2515
+ });
2516
+ if (report.isErr()) {
2517
+ return report;
2518
+ }
2519
+ const status = planStatusForReport(loaded.value.artifact, report.value);
2520
+ const checked = reportWithPlanSummary(
2521
+ report.value,
2522
+ loaded.value.artifact,
2523
+ status
2524
+ );
2525
+ if (status === 'stale') {
2526
+ return Result.err(
2527
+ new ValidationError(
2528
+ 'Regrade plan is stale for the current source tree.',
2529
+ {
2530
+ context: { plan: loaded.value.artifact.path },
2531
+ }
2532
+ )
2533
+ );
2534
+ }
2535
+ const gateContext = regradePlanGateContext(checked);
2536
+ if (gateContext !== undefined) {
2537
+ return Result.err(
2538
+ new ValidationError('Regrade plan gate is open.', {
2539
+ context: {
2540
+ ...gateContext,
2541
+ plan: loaded.value.artifact.path,
2542
+ },
2543
+ })
2544
+ );
2545
+ }
2546
+ return validateRegradeReport(checked);
2547
+ };
2548
+
2549
+ const runPreviewRegradePlan = async (
2550
+ input: RegradePlanReferenceInput,
2551
+ rootDir: string
2552
+ ): Promise<TrailsResult<RegradeReport, Error>> => {
2553
+ const loaded = await loadPlanForInput(input, rootDir);
2554
+ if (loaded.isErr()) {
2555
+ return loaded;
2556
+ }
2557
+ const report = await runPlanArtifactDryRun({
2558
+ artifact: loaded.value.artifact,
2559
+ includeEntries: input.includeEntries,
2560
+ rootDir,
2561
+ });
2562
+ if (report.isErr()) {
2563
+ return report;
2564
+ }
2565
+ return validateRegradeReport(
2566
+ reportWithPlanSummary(
2567
+ report.value,
2568
+ loaded.value.artifact,
2569
+ planStatusForReport(loaded.value.artifact, report.value)
2570
+ )
2571
+ );
2572
+ };
2573
+
2574
+ const writeRegradeHistory = (params: {
2575
+ readonly artifact: RegradePlanArtifact;
2576
+ readonly planPath: string;
2577
+ readonly report: RegradeReport;
2578
+ readonly rootDir: string;
2579
+ }): TrailsResult<RegradeHistorySummary, Error> => {
2580
+ const absolutePath = regradeHistoryPathForPlan(
2581
+ params.rootDir,
2582
+ params.artifact.plan
2583
+ );
2584
+ let priorHistoryBytes: string | undefined;
2585
+ if (existsSync(absolutePath)) {
2586
+ try {
2587
+ priorHistoryBytes = readFileSync(absolutePath, 'utf8');
2588
+ } catch (error) {
2589
+ return Result.err(
2590
+ new InternalError('Failed to read Regrade history entry.', {
2591
+ ...(error instanceof Error ? { cause: error } : {}),
2592
+ context: { path: rootRelativePath(params.rootDir, absolutePath) },
2593
+ })
2594
+ );
2595
+ }
2596
+ }
2597
+ const appended = appendRegradeHistoryRun({
2598
+ artifact: params.artifact,
2599
+ report: params.report,
2600
+ rootDir: params.rootDir,
2601
+ });
2602
+ if (appended.isErr()) {
2603
+ return appended;
2604
+ }
2605
+ try {
2606
+ // Apply always consumes the active plan, replay included: the plan is a
2607
+ // single-use apply intent, and the consolidated history already records
2608
+ // the run the replay repeats.
2609
+ rmSync(params.planPath, { force: true });
2610
+ } catch (error) {
2611
+ try {
2612
+ if (priorHistoryBytes === undefined) {
2613
+ rmSync(absolutePath, { force: true });
2614
+ } else {
2615
+ writeFileSync(absolutePath, priorHistoryBytes);
2616
+ }
2617
+ } catch {
2618
+ // Best-effort rollback; the surfaced error below preserves the primary failure.
2619
+ }
2620
+ return Result.err(
2621
+ new InternalError('Failed to remove active Regrade plan.', {
2622
+ ...(error instanceof Error ? { cause: error } : {}),
2623
+ context: {
2624
+ history: appended.value.path,
2625
+ plan: rootRelativePath(params.rootDir, params.planPath),
2626
+ },
2627
+ })
2628
+ );
2629
+ }
2630
+ return appended;
2631
+ };
2632
+
2633
+ const runApplyRegradePlan = async (
2634
+ input: RegradeApplyPlanInput,
2635
+ rootDir: string,
2636
+ shouldDryRun: boolean
2637
+ ): Promise<TrailsResult<RegradeReport, Error>> => {
2638
+ const loaded = await loadPlanForInput(input, rootDir);
2639
+ if (loaded.isErr()) {
2640
+ return loaded;
2641
+ }
2642
+ const dryRunReport = await runPlanArtifactDryRun({
2643
+ artifact: loaded.value.artifact,
2644
+ includeEntries: input.includeEntries,
2645
+ rootDir,
2646
+ });
2647
+ if (dryRunReport.isErr()) {
2648
+ return dryRunReport;
2649
+ }
2650
+ const status = planStatusForReport(loaded.value.artifact, dryRunReport.value);
2651
+ if (status === 'stale') {
2652
+ return Result.err(
2653
+ new ValidationError(
2654
+ 'Regrade plan is stale for the current source tree.',
2655
+ {
2656
+ context: { plan: loaded.value.artifact.path },
2657
+ }
2658
+ )
2659
+ );
2660
+ }
2661
+ if (shouldDryRun) {
2662
+ return validateRegradeReport(
2663
+ reportWithPlanSummary(dryRunReport.value, loaded.value.artifact, status)
2664
+ );
2665
+ }
2666
+
2667
+ const planBody = loaded.value.artifact.plan;
2668
+ let applied: TrailsResult<RegradeReport, Error>;
2669
+ if (planBody.kind === 'class') {
2670
+ applied = await runClassPlanRegradeRun({
2671
+ apply: true,
2672
+ includeEntries: input.includeEntries,
2673
+ plan: planBody,
2674
+ rootDir,
2675
+ });
2676
+ } else {
2677
+ const preserveInventory = await deriveLiveApiPreserveInventory(planBody);
2678
+ applied = runResolvedVocabularyPlan({
2679
+ apply: true,
2680
+ includeEntries: input.includeEntries,
2681
+ plan: planBody,
2682
+ preserveInventory,
2683
+ rootDir,
2684
+ });
2685
+ }
2686
+ if (applied.isErr()) {
2687
+ return applied;
2688
+ }
2689
+ // The recorded run evidence is the freshness-gated dry-run report: its
2690
+ // source hash is the lock state this run ran against (the staleness gate
2691
+ // holds it equal to the plan's own sourceHash), and unlike the post-apply
2692
+ // rescan it preserves the rewrite entries the run graduated.
2693
+ const history = writeRegradeHistory({
2694
+ artifact: loaded.value.artifact,
2695
+ planPath: loaded.value.path,
2696
+ report: dryRunReport.value,
2697
+ rootDir,
2698
+ });
2699
+ if (history.isErr()) {
2700
+ return history;
2701
+ }
2702
+ return validateRegradeReport(
2703
+ reportWithHistorySummary(
2704
+ reportWithPlanSummary(applied.value, loaded.value.artifact, status),
2705
+ history.value
2706
+ )
2707
+ );
2708
+ };
2709
+
2710
+ /**
2711
+ * Pull a graduated transition back from consolidated history into an active
2712
+ * plan for adjustment. The pulled-back artifact is authored intent only —
2713
+ * plan body, provenance, and any staged expansion; the run ledger stays
2714
+ * behind in the graduated history file, which adjust never touches. The
2715
+ * transition's stable id is preserved so the re-run's apply appends to the
2716
+ * same consolidated history spine instead of forking it.
2717
+ */
2718
+ const runAdjustRegrade = async (
2719
+ input: RegradeAdjustInput,
2720
+ rootDir: string,
2721
+ shouldDryRun: boolean
2722
+ ): Promise<TrailsResult<RegradePlanArtifact, Error>> => {
2723
+ const historyPath = resolveRegradeHistoryPath(rootDir, input.transition);
2724
+ if (historyPath.isErr()) {
2725
+ return historyPath;
2726
+ }
2727
+ const history = readRegradeHistoryArtifact(historyPath.value);
2728
+ if (history.isErr()) {
2729
+ return history;
2730
+ }
2731
+ const lastRun = history.value.runs.at(-1);
2732
+ if (lastRun === undefined) {
2733
+ return Result.err(
2734
+ new ValidationError('Regrade history has no recorded runs.', {
2735
+ context: { path: history.value.path },
2736
+ })
2737
+ );
2738
+ }
2739
+ const lastPlan = lastRun.plan;
2740
+ const activePath = regradePlanPathForPlan(rootDir, lastPlan.plan);
2741
+ if (existsSync(activePath)) {
2742
+ return Result.err(
2743
+ new ValidationError(
2744
+ 'An active Regrade plan for this transition already exists; edit or apply it instead of adjusting again.',
2745
+ { context: { plan: rootRelativePath(rootDir, activePath) } }
2746
+ )
2747
+ );
2748
+ }
2749
+ const draft: RegradePlanArtifact = {
2750
+ ...(lastPlan.expansion === undefined
2751
+ ? {}
2752
+ : { expansion: lastPlan.expansion }),
2753
+ kind: 'regrade-plan',
2754
+ path: rootRelativePath(rootDir, activePath),
2755
+ plan: lastPlan.plan,
2756
+ provenance: lastPlan.provenance,
2757
+ schemaVersion: REGRADE_PLAN_SCHEMA_VERSION,
2758
+ sourceHash: lastPlan.sourceHash,
2759
+ transitionId: history.value.id,
2760
+ };
2761
+ // Re-derive the source hash against the current tree so the later apply's
2762
+ // staleness gate compares with today's occurrences, not the graduated
2763
+ // run's.
2764
+ const report = await runPlanArtifactDryRun({
2765
+ artifact: draft,
2766
+ includeEntries: 'actionable',
2767
+ rootDir,
2768
+ });
2769
+ if (report.isErr()) {
2770
+ return report;
2771
+ }
2772
+ const artifact: RegradePlanArtifact = {
2773
+ ...draft,
2774
+ sourceHash: regradeSourceHash(report.value),
2775
+ };
2776
+ if (shouldDryRun) {
2777
+ return validateRegradePlanArtifact(artifact);
2778
+ }
2779
+ return writeRegradePlanArtifact(rootDir, artifact);
2780
+ };
2781
+
2782
+ const listRegradePlans = async (
2783
+ rootDir: string
2784
+ ): Promise<TrailsResult<z.output<typeof regradePlansOutputSchema>, Error>> => {
2785
+ const plans: z.output<typeof regradePlanSummarySchema>[] = [];
2786
+ for (const path of collectActiveRegradePlanPaths(rootDir)) {
2787
+ const artifact = readRegradePlanArtifact(path);
2788
+ if (artifact.isErr()) {
2789
+ return artifact;
2790
+ }
2791
+ const report = await runPlanArtifactDryRun({
2792
+ artifact: artifact.value,
2793
+ includeEntries: 'actionable',
2794
+ rootDir,
2795
+ });
2796
+ if (report.isErr()) {
2797
+ return report;
2798
+ }
2799
+ const expansionPending = pendingExpansionCandidateCount(artifact.value);
2800
+ const body = artifact.value.plan;
2801
+ plans.push({
2802
+ ...(body.kind === 'class' ? { classIds: [...body.classIds] } : {}),
2803
+ ...(expansionPending === 0 ? {} : { expansionPending }),
2804
+ ...(body.kind === 'vocabulary' ? { from: body.from, to: body.to } : {}),
2805
+ kind: body.kind,
2806
+ path: artifact.value.path,
2807
+ schemaVersion: artifact.value.schemaVersion,
2808
+ status: planStatusForReport(artifact.value, report.value),
2809
+ });
2810
+ }
2811
+ return Result.ok({ plans });
2812
+ };
2813
+
2814
+ const runClassModeRegrade = (
2815
+ input: RegradeInput,
2816
+ rootDir: string,
2817
+ configScope?: RegradeConfigScope | undefined
2818
+ ): Promise<TrailsResult<RegradeReport, Error>> => {
2819
+ const collection = classModeCollection(input, configScope);
2820
+ return runClassRegradeCore({
2821
+ apply: input.apply,
2822
+ ...(input.classIds === undefined ? {} : { classIds: input.classIds }),
2823
+ ...(collection === undefined ? {} : { collection }),
2824
+ includeEntries: input.includeEntries,
2825
+ rootDir,
2826
+ });
2827
+ };
2828
+
29
2829
  export const regradeTrail = trail('regrade', {
30
- blaze: (input, ctx) => {
2830
+ args: ['from', 'to'],
2831
+ blaze: async (input, ctx) => {
31
2832
  const rootDirResult = resolveTrailRootDir(input.rootDir, ctx.cwd);
32
2833
  if (rootDirResult.isErr()) {
33
2834
  return rootDirResult;
34
2835
  }
35
2836
 
36
- const reportResult: TrailsResult<RegradeReport | null, Error> = runRegrade({
37
- apply: input.apply,
38
- classes: wardenTermRewriteClasses,
39
- root: rootDirResult.value,
40
- ...(input.classIds === undefined
2837
+ const configResult = await loadRegradeConfig({
2838
+ ...(input.configPath === undefined
41
2839
  ? {}
42
- : { selection: { classIds: input.classIds } }),
2840
+ : { configPath: input.configPath }),
2841
+ env: ctx.env,
2842
+ rootDir: rootDirResult.value,
43
2843
  });
2844
+ if (configResult.isErr()) {
2845
+ return configResult;
2846
+ }
2847
+ const configScope = configResult.value.config?.scope;
2848
+
2849
+ const reportResult = hasVocabularyInput(input)
2850
+ ? await runVocabularyCommandRegrade(
2851
+ input,
2852
+ rootDirResult.value,
2853
+ configScope
2854
+ )
2855
+ : await runClassModeRegrade(input, rootDirResult.value, configScope);
44
2856
  if (reportResult.isErr()) {
45
- return reportResult;
2857
+ return Result.err(reportResult.error);
2858
+ }
2859
+ const outputResult = validateOutput(
2860
+ regradeReportOutput,
2861
+ reportResult.value
2862
+ );
2863
+ if (outputResult.isErr()) {
2864
+ return Result.err(outputResult.error);
2865
+ }
2866
+ return Result.ok(outputResult.value);
2867
+ },
2868
+ description: 'Run downstream migration checks and safe rewrites',
2869
+ input: regradeInputSchema,
2870
+ intent: 'write',
2871
+ output: regradeReportOutput,
2872
+ permit: 'public',
2873
+ });
2874
+
2875
+ export const planRegradeTrail = trail('plan.regrade', {
2876
+ args: ['from', 'to'],
2877
+ blaze: async (input, ctx) => {
2878
+ const rootDirResult = resolveTrailRootDir(input.rootDir, ctx.cwd);
2879
+ if (rootDirResult.isErr()) {
2880
+ return rootDirResult;
46
2881
  }
47
2882
 
48
- const report = reportResult.value;
49
- if (report === null) {
2883
+ const configResult = await loadRegradeConfig({
2884
+ ...(input.configPath === undefined
2885
+ ? {}
2886
+ : { configPath: input.configPath }),
2887
+ env: ctx.env,
2888
+ rootDir: rootDirResult.value,
2889
+ });
2890
+ if (configResult.isErr()) {
2891
+ return configResult;
2892
+ }
2893
+
2894
+ const result = await runPlanRegrade(
2895
+ input,
2896
+ rootDirResult.value,
2897
+ configResult.value.config?.scope,
2898
+ ctx.dryRun === true
2899
+ );
2900
+ if (result.isErr()) {
2901
+ return result;
2902
+ }
2903
+ const output = regradePlanArtifactSchema.safeParse(result.value);
2904
+ if (!output.success) {
50
2905
  return Result.err(
51
- new NotFoundError(
52
- `Regrade root "${rootDirResult.value}" could not be read as a directory.`
53
- )
2906
+ new ValidationError('Invalid Regrade plan output.', {
2907
+ context: { issues: output.error.issues },
2908
+ })
54
2909
  );
55
2910
  }
2911
+ return Result.ok(output.data);
2912
+ },
2913
+ cli: { path: ['regrade', 'plan'] },
2914
+ description: 'Write or update a reviewed Regrade plan',
2915
+ input: regradePlanInputSchema,
2916
+ intent: 'write',
2917
+ output: regradePlanArtifactSchema,
2918
+ permit: 'public',
2919
+ });
56
2920
 
57
- const validated: TrailsResult<
58
- z.output<typeof regradeReportOutput>,
59
- Error
60
- > = validateOutput(regradeReportOutput, report);
61
- if (validated.isErr()) {
62
- return validated;
2921
+ export const listRegradesTrail = trail('list.regrades', {
2922
+ blaze: async (input, ctx) => {
2923
+ const rootDirResult = resolveTrailRootDir(input.rootDir, ctx.cwd);
2924
+ if (rootDirResult.isErr()) {
2925
+ return rootDirResult;
2926
+ }
2927
+ const result = await listRegradePlans(rootDirResult.value);
2928
+ if (result.isErr()) {
2929
+ return result;
63
2930
  }
2931
+ return Result.ok(result.value);
2932
+ },
2933
+ cli: { path: ['regrade', 'plans'] },
2934
+ description: 'List active Regrade plans and freshness status',
2935
+ input: z.object({
2936
+ rootDir: z.string().optional().describe('Workspace root directory'),
2937
+ }),
2938
+ intent: 'read',
2939
+ output: regradePlansOutputSchema,
2940
+ permit: 'public',
2941
+ });
64
2942
 
65
- return Result.ok(validated.value);
2943
+ export const checkRegradeTrail = trail('check.regrade', {
2944
+ blaze: async (input, ctx) => {
2945
+ const rootDirResult = resolveTrailRootDir(input.rootDir, ctx.cwd);
2946
+ if (rootDirResult.isErr()) {
2947
+ return rootDirResult;
2948
+ }
2949
+ const result = await runCheckRegradePlan(input, rootDirResult.value);
2950
+ if (result.isErr()) {
2951
+ return result;
2952
+ }
2953
+ const checked = {
2954
+ ...result.value,
2955
+ check: {
2956
+ plan:
2957
+ result.value.plan?.path ??
2958
+ result.value.history?.path ??
2959
+ input.plan ??
2960
+ '',
2961
+ status: 'passed' as const,
2962
+ },
2963
+ };
2964
+ const output = validateOutput(regradeCheckOutputSchema, checked);
2965
+ if (output.isErr()) {
2966
+ return Result.err(output.error);
2967
+ }
2968
+ return Result.ok(output.value);
66
2969
  },
67
- description: 'Run downstream migration checks and safe rewrites',
68
- input: regradeInputSchema,
2970
+ cli: { path: ['regrade', 'check'] },
2971
+ description: 'Check a saved Regrade plan gate without writing source',
2972
+ input: regradePlanReferenceInputSchema,
2973
+ intent: 'read',
2974
+ output: regradeCheckOutputSchema,
2975
+ permit: 'public',
2976
+ });
2977
+
2978
+ export const previewRegradeTrail = trail('preview.regrade', {
2979
+ blaze: async (input, ctx) => {
2980
+ const rootDirResult = resolveTrailRootDir(input.rootDir, ctx.cwd);
2981
+ if (rootDirResult.isErr()) {
2982
+ return rootDirResult;
2983
+ }
2984
+ const result = await runPreviewRegradePlan(input, rootDirResult.value);
2985
+ if (result.isErr()) {
2986
+ return result;
2987
+ }
2988
+ const output = validateOutput(regradeReportOutput, result.value);
2989
+ if (output.isErr()) {
2990
+ return Result.err(output.error);
2991
+ }
2992
+ return Result.ok(output.value);
2993
+ },
2994
+ cli: { path: ['regrade', 'preview'] },
2995
+ description: 'Preview a saved Regrade plan without writing source',
2996
+ input: regradePlanReferenceInputSchema,
2997
+ intent: 'read',
2998
+ output: regradeReportOutput,
2999
+ permit: 'public',
3000
+ });
3001
+
3002
+ export const applyRegradeTrail = trail('apply.regrade', {
3003
+ blaze: async (input, ctx) => {
3004
+ const rootDirResult = resolveTrailRootDir(input.rootDir, ctx.cwd);
3005
+ if (rootDirResult.isErr()) {
3006
+ return rootDirResult;
3007
+ }
3008
+ const result = await runApplyRegradePlan(
3009
+ input,
3010
+ rootDirResult.value,
3011
+ ctx.dryRun === true
3012
+ );
3013
+ if (result.isErr()) {
3014
+ return result;
3015
+ }
3016
+ const output = validateOutput(regradeReportOutput, result.value);
3017
+ if (output.isErr()) {
3018
+ return Result.err(output.error);
3019
+ }
3020
+ return Result.ok(output.value);
3021
+ },
3022
+ cli: { path: ['regrade', 'apply'] },
3023
+ description: 'Apply a saved Regrade plan and move it to history',
3024
+ input: regradeApplyPlanInputSchema,
69
3025
  intent: 'write',
70
3026
  output: regradeReportOutput,
71
3027
  permit: 'public',
72
3028
  });
3029
+
3030
+ export const adjustRegradeTrail = trail('adjust.regrade', {
3031
+ args: ['transition'],
3032
+ blaze: async (input, ctx) => {
3033
+ const rootDirResult = resolveTrailRootDir(input.rootDir, ctx.cwd);
3034
+ if (rootDirResult.isErr()) {
3035
+ return rootDirResult;
3036
+ }
3037
+ const result = await runAdjustRegrade(
3038
+ input,
3039
+ rootDirResult.value,
3040
+ ctx.dryRun === true
3041
+ );
3042
+ if (result.isErr()) {
3043
+ return result;
3044
+ }
3045
+ const output = regradePlanArtifactSchema.safeParse(result.value);
3046
+ if (!output.success) {
3047
+ return Result.err(
3048
+ new ValidationError('Invalid Regrade plan output.', {
3049
+ context: { issues: output.error.issues },
3050
+ })
3051
+ );
3052
+ }
3053
+ return Result.ok(output.data);
3054
+ },
3055
+ cli: { path: ['regrade', 'adjust'] },
3056
+ description:
3057
+ 'Pull a graduated Regrade transition back to an active plan for adjustment',
3058
+ input: regradeAdjustInputSchema,
3059
+ intent: 'write',
3060
+ output: regradePlanArtifactSchema,
3061
+ permit: 'public',
3062
+ });