@ontrails/regrade 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.
@@ -0,0 +1,1933 @@
1
+ import {
2
+ InternalError,
3
+ Result,
4
+ ValidationError,
5
+ escapeRegExp,
6
+ matchesAnyPathGlob,
7
+ } from '@ontrails/core';
8
+ import { createHash } from 'node:crypto';
9
+ import { dirname, isAbsolute, join, normalize, relative } from 'node:path';
10
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
11
+ import { z } from 'zod';
12
+
13
+ import { collectDownstreamSources } from './collect.js';
14
+ import type { DownstreamCollectionOptions, SkippedSource } from './collect.js';
15
+ import type {
16
+ RegradeApplySummary,
17
+ RegradeReport,
18
+ RegradeReportEntry,
19
+ } from './report.js';
20
+ import { buildRegradeScanSummary } from './scan-summary.js';
21
+
22
+ export type VocabularyVerdict = 'deferred' | 'modified' | 'skipped';
23
+
24
+ export const vocabularyDispositionValues = [
25
+ 'code-context-out-of-engine',
26
+ 'docs-only',
27
+ 'explicit-preserve',
28
+ 'forward-pointer',
29
+ 'ignored-by-scope',
30
+ 'in-family-modified',
31
+ 'in-family-unresolved',
32
+ 'out-of-family',
33
+ 'preserve-current-live-api',
34
+ ] as const;
35
+
36
+ export type VocabularyDisposition =
37
+ (typeof vocabularyDispositionValues)[number];
38
+
39
+ const vocabularyDispositions = new Set<string>(vocabularyDispositionValues);
40
+
41
+ export interface VocabularyPreserveRule {
42
+ readonly disposition?: VocabularyDisposition;
43
+ readonly forms?: readonly string[];
44
+ readonly pattern: string;
45
+ readonly reason?: string;
46
+ readonly paths?: readonly string[];
47
+ }
48
+
49
+ export interface VocabularyPreserveInventoryEntry extends VocabularyPreserveRule {
50
+ readonly evidence: readonly string[];
51
+ readonly source: 'derived-live-api';
52
+ }
53
+
54
+ export interface VocabularyRegradeScope {
55
+ readonly exclude?: readonly string[];
56
+ readonly extensions?: readonly string[];
57
+ /**
58
+ * @deprecated Use `exclude` path globs for new plans. This remains as a
59
+ * compatibility bridge for pre-path-scope plans that intentionally disabled
60
+ * the collector's default directory pruning.
61
+ */
62
+ readonly ignoredDirectories?: readonly string[];
63
+ readonly include?: readonly string[];
64
+ }
65
+
66
+ export interface VocabularyRegradePlan {
67
+ readonly caseSensitive?: boolean;
68
+ readonly deferForms?: readonly string[];
69
+ readonly from: string;
70
+ readonly id?: string;
71
+ readonly intent?: string;
72
+ readonly kind: 'vocabulary';
73
+ readonly overrides?: Readonly<Record<string, string>>;
74
+ readonly preserve?: readonly VocabularyPreserveRule[];
75
+ readonly scope?: VocabularyRegradeScope;
76
+ readonly to: string;
77
+ }
78
+
79
+ export interface VocabularyOccurrence {
80
+ readonly column: number;
81
+ readonly context: string;
82
+ readonly disposition: VocabularyDisposition;
83
+ readonly end: number;
84
+ readonly form: string;
85
+ readonly line: number;
86
+ readonly path: string;
87
+ readonly reason: string;
88
+ readonly replacement?: string;
89
+ readonly start: number;
90
+ readonly verdict: VocabularyVerdict;
91
+ }
92
+
93
+ export interface VocabularyRunLedger {
94
+ readonly cycle: number;
95
+ readonly forms: Readonly<Record<string, VocabularyVerdict>>;
96
+ readonly occurrences: readonly VocabularyOccurrence[];
97
+ }
98
+
99
+ export interface VocabularyRunGate {
100
+ readonly remaining: number;
101
+ readonly remainingByDisposition: Partial<
102
+ Readonly<Record<VocabularyDisposition, number>>
103
+ >;
104
+ readonly reasons: readonly string[];
105
+ readonly status: 'green' | 'open';
106
+ }
107
+
108
+ export interface VocabularyRunReport {
109
+ readonly applied: number;
110
+ readonly deferred: number;
111
+ readonly dispositions: Partial<
112
+ Readonly<Record<VocabularyDisposition, number>>
113
+ >;
114
+ readonly filesChanged: number;
115
+ readonly gate: VocabularyRunGate;
116
+ readonly modified: number;
117
+ readonly open: number;
118
+ readonly skipped: number;
119
+ }
120
+
121
+ export interface VocabularyRegradeRun {
122
+ readonly ledger: VocabularyRunLedger;
123
+ readonly plan: VocabularyRegradePlan;
124
+ readonly preserveInventory?: readonly VocabularyPreserveInventoryEntry[];
125
+ readonly report: VocabularyRunReport;
126
+ }
127
+
128
+ export const VOCABULARY_TRANSITION_RECORD_SCHEMA_VERSION = 1;
129
+
130
+ export interface VocabularyTransitionRecordEnvironment {
131
+ readonly commitSha?: string;
132
+ readonly engineVersion?: string;
133
+ readonly graphHash?: string;
134
+ readonly root: string;
135
+ }
136
+
137
+ export interface VocabularyTransitionRecord {
138
+ readonly environment: VocabularyTransitionRecordEnvironment;
139
+ readonly kind: 'vocabulary-transition-record';
140
+ readonly recordPath: string;
141
+ readonly report: Omit<RegradeReport, 'record'>;
142
+ readonly schemaVersion: typeof VOCABULARY_TRANSITION_RECORD_SCHEMA_VERSION;
143
+ readonly transition: {
144
+ readonly from: string;
145
+ readonly id: string;
146
+ readonly to: string;
147
+ };
148
+ }
149
+
150
+ export interface VocabularyTransitionRecordSummary {
151
+ readonly path: string;
152
+ readonly schemaVersion: typeof VOCABULARY_TRANSITION_RECORD_SCHEMA_VERSION;
153
+ readonly status: 'candidate' | 'applied' | 'checked';
154
+ }
155
+
156
+ interface SourceFile {
157
+ readonly absolutePath: string;
158
+ readonly path: string;
159
+ readonly source: string;
160
+ }
161
+
162
+ interface SourceOccurrence extends VocabularyOccurrence {
163
+ readonly absolutePath: string;
164
+ }
165
+
166
+ interface SourceOccurrenceDraft extends Omit<
167
+ SourceOccurrence,
168
+ 'disposition' | 'reason' | 'verdict'
169
+ > {
170
+ readonly contextColumn: number;
171
+ }
172
+
173
+ interface VocabularyEvaluation {
174
+ readonly entries: readonly RegradeReportEntry[];
175
+ readonly occurrences: readonly SourceOccurrence[];
176
+ readonly scanned: number;
177
+ readonly skipped: readonly SkippedSource[];
178
+ readonly run: VocabularyRegradeRun;
179
+ }
180
+
181
+ const VOCABULARY_SOURCE_EXTENSIONS = Object.freeze([
182
+ '.js',
183
+ '.jsx',
184
+ '.json',
185
+ '.jsonc',
186
+ '.md',
187
+ '.mdx',
188
+ '.mjs',
189
+ '.ts',
190
+ '.tsx',
191
+ '.txt',
192
+ '.yaml',
193
+ '.yml',
194
+ ]);
195
+
196
+ const uniqueSorted = (values: readonly string[]): readonly string[] =>
197
+ [...new Set(values)].toSorted((a, b) => a.localeCompare(b));
198
+
199
+ const vocabularyDispositionCounts = (
200
+ occurrences: readonly VocabularyOccurrence[]
201
+ ): Partial<Readonly<Record<VocabularyDisposition, number>>> => {
202
+ const counts = new Map<VocabularyDisposition, number>();
203
+ for (const occurrence of occurrences) {
204
+ counts.set(
205
+ occurrence.disposition,
206
+ (counts.get(occurrence.disposition) ?? 0) + 1
207
+ );
208
+ }
209
+ return Object.fromEntries(
210
+ [...counts.entries()].toSorted(([left], [right]) =>
211
+ left.localeCompare(right)
212
+ )
213
+ );
214
+ };
215
+
216
+ const isVocabularyTokenCharacter = (value: string): boolean =>
217
+ /[A-Za-z0-9_$-]/.test(value);
218
+
219
+ const hasWordBoundary = (
220
+ source: string,
221
+ start: number,
222
+ end: number
223
+ ): boolean => {
224
+ const before = start === 0 ? '' : (source.at(start - 1) ?? '');
225
+ const after = end >= source.length ? '' : (source.at(end) ?? '');
226
+ return (
227
+ !isVocabularyTokenCharacter(before) && !isVocabularyTokenCharacter(after)
228
+ );
229
+ };
230
+
231
+ const expandVocabularyNeighborSpan = (
232
+ source: string,
233
+ start: number,
234
+ end: number
235
+ ): { readonly end: number; readonly start: number } => {
236
+ let expandedStart = start;
237
+ while (
238
+ expandedStart > 0 &&
239
+ isVocabularyTokenCharacter(source.at(expandedStart - 1) ?? '')
240
+ ) {
241
+ expandedStart -= 1;
242
+ }
243
+
244
+ let expandedEnd = end;
245
+ while (
246
+ expandedEnd < source.length &&
247
+ isVocabularyTokenCharacter(source.at(expandedEnd) ?? '')
248
+ ) {
249
+ expandedEnd += 1;
250
+ }
251
+
252
+ return { end: expandedEnd, start: expandedStart };
253
+ };
254
+
255
+ const lineColumnForOffset = (
256
+ source: string,
257
+ offset: number
258
+ ): { readonly column: number; readonly line: number } => {
259
+ let line = 1;
260
+ let column = 1;
261
+ for (let index = 0; index < offset; index += 1) {
262
+ if (source.codePointAt(index) === 10) {
263
+ line += 1;
264
+ column = 1;
265
+ } else {
266
+ column += 1;
267
+ }
268
+ }
269
+ return { column, line };
270
+ };
271
+
272
+ const contextDetailsForOffset = (
273
+ source: string,
274
+ start: number,
275
+ end: number
276
+ ): { readonly context: string; readonly contextColumn: number } => {
277
+ const lineStart = source.lastIndexOf('\n', start - 1) + 1;
278
+ const nextLine = source.indexOf('\n', end);
279
+ const lineEnd = nextLine === -1 ? source.length : nextLine;
280
+ const rawLine = source.slice(lineStart, lineEnd);
281
+ const leadingTrimmed = rawLine.length - rawLine.trimStart().length;
282
+ return {
283
+ context: rawLine.trim(),
284
+ contextColumn: start - lineStart - leadingTrimmed + 1,
285
+ };
286
+ };
287
+
288
+ const isMarkdownPath = (path: string): boolean =>
289
+ path.endsWith('.md') || path.endsWith('.mdx');
290
+
291
+ const sourceLineBoundsForOffset = (
292
+ source: string,
293
+ start: number,
294
+ end: number
295
+ ): { readonly lineEnd: number; readonly lineStart: number } => {
296
+ const lineStart = source.lastIndexOf('\n', start - 1) + 1;
297
+ const nextLine = source.indexOf('\n', end);
298
+ return { lineEnd: nextLine === -1 ? source.length : nextLine, lineStart };
299
+ };
300
+
301
+ const markdownBacktickRuns = (value: string): readonly RegExpMatchArray[] => [
302
+ ...value.matchAll(/(?<!\\)`+/g),
303
+ ];
304
+
305
+ const isMarkdownInlineCodeContext = (
306
+ source: string,
307
+ start: number,
308
+ end: number
309
+ ): boolean => {
310
+ const { lineEnd, lineStart } = sourceLineBoundsForOffset(source, start, end);
311
+ const line = source.slice(lineStart, lineEnd);
312
+ const relativeStart = start - lineStart;
313
+ const relativeEnd = end - lineStart;
314
+ let openRun: { readonly length: number; readonly start: number } | undefined;
315
+
316
+ for (const run of markdownBacktickRuns(line)) {
317
+ const runStart = run.index ?? 0;
318
+ const [value] = run;
319
+ const runLength = value.length;
320
+ if (openRun === undefined) {
321
+ openRun = { length: runLength, start: runStart };
322
+ continue;
323
+ }
324
+ if (runLength !== openRun.length) {
325
+ continue;
326
+ }
327
+ if (
328
+ openRun.start + openRun.length <= relativeStart &&
329
+ relativeEnd <= runStart
330
+ ) {
331
+ return true;
332
+ }
333
+ openRun = undefined;
334
+ }
335
+
336
+ return false;
337
+ };
338
+
339
+ const markdownFenceLinePattern = /^\s*(?:>\s*){0,8}(```|~~~)/;
340
+
341
+ const isMarkdownFenceContext = (source: string, start: number): boolean => {
342
+ const before = source.slice(0, start);
343
+ let fenced = false;
344
+ for (const line of before.split('\n')) {
345
+ if (markdownFenceLinePattern.test(line)) {
346
+ fenced = !fenced;
347
+ }
348
+ }
349
+ return fenced;
350
+ };
351
+
352
+ const isMarkdownCodeContext = (
353
+ file: SourceFile,
354
+ start: number,
355
+ end: number
356
+ ): boolean =>
357
+ isMarkdownPath(file.path) &&
358
+ (isMarkdownInlineCodeContext(file.source, start, end) ||
359
+ isMarkdownFenceContext(file.source, start));
360
+
361
+ const vocabularyOccurrenceReason = (
362
+ preserveRule: VocabularyPreserveRule | undefined,
363
+ markdownCodeContext: boolean,
364
+ defaultReason: string
365
+ ): string => {
366
+ if (preserveRule !== undefined) {
367
+ return preserveRule.reason ?? 'preserved-by-plan';
368
+ }
369
+ if (markdownCodeContext) {
370
+ return 'markdown-code-context';
371
+ }
372
+ return defaultReason;
373
+ };
374
+
375
+ const capturedVocabularyVerdict = (
376
+ preserveRule: VocabularyPreserveRule | undefined,
377
+ markdownCodeContext: boolean
378
+ ): VocabularyVerdict => {
379
+ if (preserveRule !== undefined) {
380
+ return 'skipped';
381
+ }
382
+ if (markdownCodeContext) {
383
+ return 'deferred';
384
+ }
385
+ return 'modified';
386
+ };
387
+
388
+ const vocabularyOccurrenceDisposition = (
389
+ verdict: VocabularyVerdict,
390
+ preserveRule: VocabularyPreserveRule | undefined,
391
+ markdownCodeContext: boolean
392
+ ): VocabularyDisposition => {
393
+ if (preserveRule !== undefined) {
394
+ return preserveRule.disposition ?? 'explicit-preserve';
395
+ }
396
+ if (markdownCodeContext) {
397
+ return 'code-context-out-of-engine';
398
+ }
399
+ if (verdict === 'modified') {
400
+ return 'in-family-modified';
401
+ }
402
+ return 'in-family-unresolved';
403
+ };
404
+
405
+ const preserveCase = (sourceForm: string, replacement: string): string => {
406
+ if (sourceForm.toUpperCase() === sourceForm) {
407
+ return replacement.toUpperCase();
408
+ }
409
+ const first = sourceForm.at(0);
410
+ if (first !== undefined && first.toUpperCase() === first) {
411
+ return replacement.at(0)?.toUpperCase() + replacement.slice(1);
412
+ }
413
+ return replacement;
414
+ };
415
+
416
+ const isSimpleVocabularyWord = (value: string): boolean =>
417
+ /^[A-Za-z]+$/.test(value);
418
+
419
+ const endsWithConsonantY = (value: string): boolean => {
420
+ const penultimate = value.at(-2);
421
+ return (
422
+ value.endsWith('y') &&
423
+ penultimate !== undefined &&
424
+ !/[aeiou]/.test(penultimate)
425
+ );
426
+ };
427
+
428
+ const pluralize = (value: string): string => {
429
+ const lower = value.toLowerCase();
430
+ let lowerForm: string;
431
+ if (endsWithConsonantY(lower)) {
432
+ lowerForm = `${lower.slice(0, -1)}ies`;
433
+ } else if (
434
+ lower.endsWith('s') ||
435
+ lower.endsWith('x') ||
436
+ lower.endsWith('ch')
437
+ ) {
438
+ lowerForm = `${lower}es`;
439
+ } else {
440
+ lowerForm = `${lower}s`;
441
+ }
442
+ return preserveCase(value, lowerForm);
443
+ };
444
+
445
+ const pastTenseForm = (value: string): string => {
446
+ const lower = value.toLowerCase();
447
+ let lowerForm: string;
448
+ if (endsWithConsonantY(lower)) {
449
+ lowerForm = `${lower.slice(0, -1)}ied`;
450
+ } else if (lower.endsWith('e')) {
451
+ lowerForm = `${lower}d`;
452
+ } else {
453
+ lowerForm = `${lower}ed`;
454
+ }
455
+ return preserveCase(value, lowerForm);
456
+ };
457
+
458
+ const presentParticipleForm = (value: string): string => {
459
+ const lower = value.toLowerCase();
460
+ let lowerForm: string;
461
+ if (lower.endsWith('ie')) {
462
+ lowerForm = `${lower.slice(0, -2)}ying`;
463
+ } else if (lower.endsWith('e') && !lower.endsWith('ee')) {
464
+ lowerForm = `${lower.slice(0, -1)}ing`;
465
+ } else {
466
+ lowerForm = `${lower}ing`;
467
+ }
468
+ return preserveCase(value, lowerForm);
469
+ };
470
+
471
+ const defaultDeferredVocabularyForms = (from: string): readonly string[] => {
472
+ if (!isSimpleVocabularyWord(from)) {
473
+ return [];
474
+ }
475
+ return uniqueSorted([
476
+ pastTenseForm(from),
477
+ presentParticipleForm(from),
478
+ ]).filter((form) => form !== from && form !== pluralize(from));
479
+ };
480
+
481
+ const defaultVocabularyForms = (from: string, to: string) =>
482
+ new Map<string, string>([
483
+ [from, to],
484
+ [pluralize(from), pluralize(to)],
485
+ ]);
486
+
487
+ const normalizedOverrideEntries = (
488
+ overrides: Readonly<Record<string, string>> | undefined
489
+ ): readonly [string, string][] =>
490
+ Object.entries(overrides ?? {}).toSorted(([left], [right]) =>
491
+ left.localeCompare(right)
492
+ );
493
+
494
+ const formIdentityForPlan = (
495
+ plan: VocabularyRegradePlan,
496
+ form: string
497
+ ): string => (plan.caseSensitive === true ? form : form.toLowerCase());
498
+
499
+ const targetFormsForPlan = (
500
+ plan: VocabularyRegradePlan
501
+ ): Map<string, string> => {
502
+ const forms = defaultVocabularyForms(plan.from, plan.to);
503
+ for (const [form, replacement] of normalizedOverrideEntries(plan.overrides)) {
504
+ forms.set(form, replacement);
505
+ }
506
+ return forms;
507
+ };
508
+
509
+ const deferFormsForPlan = (plan: VocabularyRegradePlan): readonly string[] => {
510
+ const overrideForms = new Set(
511
+ normalizedOverrideEntries(plan.overrides).map(([form]) =>
512
+ formIdentityForPlan(plan, form)
513
+ )
514
+ );
515
+ return uniqueSorted([
516
+ ...defaultDeferredVocabularyForms(plan.from).filter(
517
+ (form) => !overrideForms.has(formIdentityForPlan(plan, form))
518
+ ),
519
+ ...(plan.deferForms ?? []),
520
+ ]);
521
+ };
522
+
523
+ const validateVocabularyPlan = (
524
+ plan: VocabularyRegradePlan
525
+ ): Result<void, ValidationError> => {
526
+ if (plan.from.trim().length === 0) {
527
+ return Result.err(
528
+ new ValidationError('Vocabulary Regrade plan `from` cannot be empty.')
529
+ );
530
+ }
531
+ if (plan.to.trim().length === 0) {
532
+ return Result.err(
533
+ new ValidationError('Vocabulary Regrade plan `to` cannot be empty.')
534
+ );
535
+ }
536
+ for (const [form, replacement] of normalizedOverrideEntries(plan.overrides)) {
537
+ if (form.trim().length === 0) {
538
+ return Result.err(
539
+ new ValidationError(
540
+ 'Vocabulary Regrade plan override keys cannot be empty.'
541
+ )
542
+ );
543
+ }
544
+ if (replacement.trim().length === 0) {
545
+ return Result.err(
546
+ new ValidationError(
547
+ `Vocabulary Regrade plan override "${form}" cannot map to an empty replacement.`
548
+ )
549
+ );
550
+ }
551
+ }
552
+ for (const form of deferFormsForPlan(plan)) {
553
+ if (form.trim().length === 0) {
554
+ return Result.err(
555
+ new ValidationError(
556
+ 'Vocabulary Regrade plan deferForms entries cannot be empty.'
557
+ )
558
+ );
559
+ }
560
+ }
561
+ for (const rule of plan.preserve ?? []) {
562
+ if (rule.pattern.trim().length === 0) {
563
+ return Result.err(
564
+ new ValidationError(
565
+ 'Vocabulary Regrade plan preserve patterns cannot be empty.'
566
+ )
567
+ );
568
+ }
569
+ if (
570
+ rule.disposition !== undefined &&
571
+ !vocabularyDispositions.has(rule.disposition)
572
+ ) {
573
+ return Result.err(
574
+ new ValidationError(
575
+ `Vocabulary Regrade plan preserve disposition "${rule.disposition}" is not supported.`
576
+ )
577
+ );
578
+ }
579
+ if (rule.forms?.some((form) => form.trim().length === 0) === true) {
580
+ return Result.err(
581
+ new ValidationError(
582
+ 'Vocabulary Regrade plan preserve forms cannot be empty.'
583
+ )
584
+ );
585
+ }
586
+ }
587
+ return Result.ok();
588
+ };
589
+
590
+ const validatePreserveInventory = (
591
+ inventory: readonly VocabularyPreserveInventoryEntry[] | undefined
592
+ ): Result<void, ValidationError> => {
593
+ for (const entry of inventory ?? []) {
594
+ if (entry.pattern.trim().length === 0) {
595
+ return Result.err(
596
+ new ValidationError(
597
+ 'Vocabulary Regrade preserve inventory patterns cannot be empty.'
598
+ )
599
+ );
600
+ }
601
+ if (entry.forms?.some((form) => form.trim().length === 0) === true) {
602
+ return Result.err(
603
+ new ValidationError(
604
+ 'Vocabulary Regrade preserve inventory forms cannot be empty.'
605
+ )
606
+ );
607
+ }
608
+ if (
609
+ entry.disposition !== undefined &&
610
+ !vocabularyDispositions.has(entry.disposition)
611
+ ) {
612
+ return Result.err(
613
+ new ValidationError(
614
+ `Vocabulary Regrade preserve inventory disposition "${entry.disposition}" is not supported.`
615
+ )
616
+ );
617
+ }
618
+ if (entry.evidence.length === 0) {
619
+ return Result.err(
620
+ new ValidationError(
621
+ 'Vocabulary Regrade preserve inventory entries need evidence.'
622
+ )
623
+ );
624
+ }
625
+ }
626
+ return Result.ok();
627
+ };
628
+
629
+ const effectivePlanForRun = (
630
+ plan: VocabularyRegradePlan,
631
+ preserveInventory: readonly VocabularyPreserveInventoryEntry[] | undefined
632
+ ): VocabularyRegradePlan => {
633
+ if (preserveInventory === undefined || preserveInventory.length === 0) {
634
+ return plan;
635
+ }
636
+
637
+ return {
638
+ ...plan,
639
+ preserve: [...(plan.preserve ?? []), ...preserveInventory],
640
+ };
641
+ };
642
+
643
+ const vocabularyScanFlags = (plan: VocabularyRegradePlan): string =>
644
+ plan.caseSensitive === true ? 'g' : 'gi';
645
+
646
+ const includedByScope = (
647
+ path: string,
648
+ scope: VocabularyRegradeScope | undefined
649
+ ): boolean =>
650
+ (scope?.include === undefined ||
651
+ scope.include.length === 0 ||
652
+ matchesAnyPathGlob(path, scope.include)) &&
653
+ !matchesAnyPathGlob(path, scope?.exclude);
654
+
655
+ const compilePreservePattern = (pattern: string): RegExp => {
656
+ try {
657
+ return new RegExp(pattern);
658
+ } catch {
659
+ return new RegExp(escapeRegExp(pattern));
660
+ }
661
+ };
662
+
663
+ const globalPreservePattern = (pattern: RegExp): RegExp => {
664
+ const flags = pattern.flags.includes('g')
665
+ ? pattern.flags
666
+ : `${pattern.flags}g`;
667
+ return new RegExp(pattern.source, flags);
668
+ };
669
+
670
+ const patternOverlapsOccurrence = (
671
+ pattern: RegExp,
672
+ occurrence: SourceOccurrenceDraft
673
+ ): boolean => {
674
+ const occurrenceStart = occurrence.contextColumn - 1;
675
+ const occurrenceEnd = occurrenceStart + occurrence.form.length;
676
+
677
+ for (const match of occurrence.context.matchAll(
678
+ globalPreservePattern(pattern)
679
+ )) {
680
+ const matchStart = match.index ?? 0;
681
+ const matchEnd = matchStart + match[0].length;
682
+ if (
683
+ matchStart !== matchEnd &&
684
+ occurrenceStart < matchEnd &&
685
+ matchStart < occurrenceEnd
686
+ ) {
687
+ return true;
688
+ }
689
+ }
690
+
691
+ return false;
692
+ };
693
+
694
+ const preserveRuleForOccurrence = (
695
+ occurrence: SourceOccurrenceDraft,
696
+ plan: VocabularyRegradePlan
697
+ ): VocabularyPreserveRule | undefined =>
698
+ plan.preserve?.find((rule) => {
699
+ if (rule.forms !== undefined && !rule.forms.includes(occurrence.form)) {
700
+ return false;
701
+ }
702
+ if (
703
+ rule.paths !== undefined &&
704
+ !matchesAnyPathGlob(occurrence.path, rule.paths)
705
+ ) {
706
+ return false;
707
+ }
708
+ const pattern = compilePreservePattern(rule.pattern);
709
+ if (
710
+ pattern.test(occurrence.form) ||
711
+ patternOverlapsOccurrence(pattern, occurrence)
712
+ ) {
713
+ return true;
714
+ }
715
+ return rule.forms === undefined && pattern.test(occurrence.context);
716
+ });
717
+
718
+ const occurrenceOverlaps = (
719
+ occurrences: readonly {
720
+ readonly end: number;
721
+ readonly start: number;
722
+ }[],
723
+ start: number,
724
+ end: number
725
+ ): boolean =>
726
+ occurrences.some(
727
+ (occurrence) => start < occurrence.end && occurrence.start < end
728
+ );
729
+
730
+ const occurrenceDraftForSpan = (
731
+ file: SourceFile,
732
+ start: number,
733
+ end: number,
734
+ form = file.source.slice(start, end)
735
+ ): SourceOccurrenceDraft => {
736
+ const { column, line } = lineColumnForOffset(file.source, start);
737
+ const context = contextDetailsForOffset(file.source, start, end);
738
+ return {
739
+ absolutePath: file.absolutePath,
740
+ column,
741
+ context: context.context,
742
+ contextColumn: context.contextColumn,
743
+ end,
744
+ form,
745
+ line,
746
+ path: file.path,
747
+ start,
748
+ };
749
+ };
750
+
751
+ const deferredOccurrenceFromDraft = (
752
+ file: SourceFile,
753
+ plan: VocabularyRegradePlan,
754
+ baseOccurrence: SourceOccurrenceDraft,
755
+ reason = 'unclassified-neighbor'
756
+ ): SourceOccurrence => {
757
+ const preserveRule = preserveRuleForOccurrence(baseOccurrence, plan);
758
+ const markdownCodeContext = isMarkdownCodeContext(
759
+ file,
760
+ baseOccurrence.start,
761
+ baseOccurrence.end
762
+ );
763
+ const verdict = preserveRule === undefined ? 'deferred' : 'skipped';
764
+ return {
765
+ absolutePath: baseOccurrence.absolutePath,
766
+ column: baseOccurrence.column,
767
+ context: baseOccurrence.context,
768
+ disposition: vocabularyOccurrenceDisposition(
769
+ verdict,
770
+ preserveRule,
771
+ markdownCodeContext
772
+ ),
773
+ end: baseOccurrence.end,
774
+ form: baseOccurrence.form,
775
+ line: baseOccurrence.line,
776
+ path: baseOccurrence.path,
777
+ reason: vocabularyOccurrenceReason(
778
+ preserveRule,
779
+ markdownCodeContext,
780
+ reason
781
+ ),
782
+ start: baseOccurrence.start,
783
+ verdict,
784
+ };
785
+ };
786
+
787
+ const exactDeferredFormOccurrencesForFile = (
788
+ file: SourceFile,
789
+ plan: VocabularyRegradePlan,
790
+ deferForms: readonly string[],
791
+ targetFormSpans: readonly {
792
+ readonly end: number;
793
+ readonly start: number;
794
+ }[]
795
+ ): readonly SourceOccurrence[] => {
796
+ const occurrences: SourceOccurrence[] = [];
797
+ const authoredDeferForms = new Set(
798
+ (plan.deferForms ?? []).map((form) => formIdentityForPlan(plan, form))
799
+ );
800
+ for (const form of deferForms) {
801
+ const pattern = new RegExp(escapeRegExp(form), vocabularyScanFlags(plan));
802
+ for (const match of file.source.matchAll(pattern)) {
803
+ const start = match.index ?? 0;
804
+ const end = start + match[0].length;
805
+ const isAuthoredDefer = authoredDeferForms.has(
806
+ formIdentityForPlan(plan, form)
807
+ );
808
+ if (
809
+ !hasWordBoundary(file.source, start, end) ||
810
+ occurrenceOverlaps(occurrences, start, end) ||
811
+ (!isAuthoredDefer && occurrenceOverlaps(targetFormSpans, start, end))
812
+ ) {
813
+ continue;
814
+ }
815
+ occurrences.push(
816
+ deferredOccurrenceFromDraft(
817
+ file,
818
+ plan,
819
+ occurrenceDraftForSpan(file, start, end, match[0]),
820
+ 'deferred-form'
821
+ )
822
+ );
823
+ }
824
+ }
825
+ return occurrences;
826
+ };
827
+
828
+ const targetFormSpansForFile = (
829
+ file: SourceFile,
830
+ plan: VocabularyRegradePlan,
831
+ targetForms: Map<string, string>
832
+ ): readonly {
833
+ readonly end: number;
834
+ readonly start: number;
835
+ }[] => {
836
+ const spans: { end: number; start: number }[] = [];
837
+ for (const form of targetForms.keys()) {
838
+ const pattern = new RegExp(escapeRegExp(form), vocabularyScanFlags(plan));
839
+ for (const match of file.source.matchAll(pattern)) {
840
+ const start = match.index ?? 0;
841
+ const end = start + match[0].length;
842
+ if (
843
+ !hasWordBoundary(file.source, start, end) ||
844
+ occurrenceOverlaps(spans, start, end)
845
+ ) {
846
+ continue;
847
+ }
848
+ spans.push({ end, start });
849
+ }
850
+ }
851
+ return spans;
852
+ };
853
+
854
+ const occurrencesForFile = (
855
+ file: SourceFile,
856
+ plan: VocabularyRegradePlan,
857
+ targetForms: Map<string, string>,
858
+ deferredOccurrences: readonly SourceOccurrence[]
859
+ ): readonly SourceOccurrence[] => {
860
+ const occurrences: SourceOccurrence[] = [];
861
+ const candidates: SourceOccurrence[] = [];
862
+ const forms = [...targetForms.entries()].toSorted(
863
+ ([left], [right]) => right.length - left.length || left.localeCompare(right)
864
+ );
865
+
866
+ for (const [form, replacement] of forms) {
867
+ const pattern = new RegExp(escapeRegExp(form), vocabularyScanFlags(plan));
868
+ for (const match of file.source.matchAll(pattern)) {
869
+ const start = match.index ?? 0;
870
+ const end = start + match[0].length;
871
+ if (
872
+ !hasWordBoundary(file.source, start, end) ||
873
+ occurrenceOverlaps(deferredOccurrences, start, end)
874
+ ) {
875
+ continue;
876
+ }
877
+ const { column, line } = lineColumnForOffset(file.source, start);
878
+ const context = contextDetailsForOffset(file.source, start, end);
879
+ const baseOccurrence = {
880
+ absolutePath: file.absolutePath,
881
+ column,
882
+ context: context.context,
883
+ contextColumn: context.contextColumn,
884
+ end,
885
+ form: match[0],
886
+ line,
887
+ path: file.path,
888
+ start,
889
+ };
890
+ const preserveRule = preserveRuleForOccurrence(baseOccurrence, plan);
891
+ const markdownCodeContext = isMarkdownCodeContext(file, start, end);
892
+ const verdict = capturedVocabularyVerdict(
893
+ preserveRule,
894
+ markdownCodeContext
895
+ );
896
+ candidates.push({
897
+ absolutePath: baseOccurrence.absolutePath,
898
+ column: baseOccurrence.column,
899
+ context: baseOccurrence.context,
900
+ disposition: vocabularyOccurrenceDisposition(
901
+ verdict,
902
+ preserveRule,
903
+ markdownCodeContext
904
+ ),
905
+ end: baseOccurrence.end,
906
+ form: baseOccurrence.form,
907
+ line: baseOccurrence.line,
908
+ path: baseOccurrence.path,
909
+ reason: vocabularyOccurrenceReason(
910
+ preserveRule,
911
+ markdownCodeContext,
912
+ 'captured-form'
913
+ ),
914
+ ...(preserveRule === undefined && !markdownCodeContext
915
+ ? { replacement: preserveCase(match[0], replacement) }
916
+ : {}),
917
+ start: baseOccurrence.start,
918
+ verdict,
919
+ });
920
+ }
921
+ }
922
+
923
+ for (const candidate of candidates.toSorted(
924
+ (left, right) =>
925
+ right.end - right.start - (left.end - left.start) ||
926
+ left.start - right.start
927
+ )) {
928
+ const overlaps = occurrences.some(
929
+ (occurrence) =>
930
+ candidate.start < occurrence.end && occurrence.start < candidate.end
931
+ );
932
+ if (!overlaps) {
933
+ occurrences.push(candidate);
934
+ }
935
+ }
936
+
937
+ return occurrences.toSorted((left, right) =>
938
+ left.path === right.path
939
+ ? left.start - right.start
940
+ : left.path.localeCompare(right.path)
941
+ );
942
+ };
943
+
944
+ const deferredOccurrencesForFile = (
945
+ file: SourceFile,
946
+ plan: VocabularyRegradePlan,
947
+ targetForms: Map<string, string>
948
+ ): readonly SourceOccurrence[] => {
949
+ const deferForms = deferFormsForPlan(plan);
950
+ const targetFormSpans = targetFormSpansForFile(file, plan, targetForms);
951
+ const knownForms = new Set(
952
+ plan.caseSensitive === true
953
+ ? [...targetForms.keys(), ...deferForms]
954
+ : [...targetForms.keys(), ...deferForms].flatMap((form) => [
955
+ form,
956
+ form.toLowerCase(),
957
+ ])
958
+ );
959
+ const lowerFrom = plan.from.toLowerCase();
960
+ const tokenPattern = /[A-Za-z_$][A-Za-z0-9_$-]*/g;
961
+ const occurrences = [
962
+ ...exactDeferredFormOccurrencesForFile(
963
+ file,
964
+ plan,
965
+ deferForms,
966
+ targetFormSpans
967
+ ),
968
+ ];
969
+
970
+ for (const form of targetForms.keys()) {
971
+ const pattern = new RegExp(escapeRegExp(form), vocabularyScanFlags(plan));
972
+ for (const match of file.source.matchAll(pattern)) {
973
+ const matchStart = match.index ?? 0;
974
+ const matchEnd = matchStart + match[0].length;
975
+ if (hasWordBoundary(file.source, matchStart, matchEnd)) {
976
+ continue;
977
+ }
978
+ const { end, start } = expandVocabularyNeighborSpan(
979
+ file.source,
980
+ matchStart,
981
+ matchEnd
982
+ );
983
+ const matchedForm = file.source.slice(start, end);
984
+ const lowerMatchedForm = matchedForm.toLowerCase();
985
+ if (
986
+ occurrenceOverlaps(occurrences, start, end) ||
987
+ knownForms.has(matchedForm) ||
988
+ (plan.caseSensitive !== true && knownForms.has(lowerMatchedForm)) ||
989
+ !lowerMatchedForm.includes(lowerFrom)
990
+ ) {
991
+ continue;
992
+ }
993
+ occurrences.push(
994
+ deferredOccurrenceFromDraft(
995
+ file,
996
+ plan,
997
+ occurrenceDraftForSpan(file, start, end, matchedForm)
998
+ )
999
+ );
1000
+ }
1001
+ }
1002
+
1003
+ for (const match of file.source.matchAll(tokenPattern)) {
1004
+ const [form] = match;
1005
+ const lower = form.toLowerCase();
1006
+ if (
1007
+ knownForms.has(form) ||
1008
+ (plan.caseSensitive !== true && knownForms.has(lower))
1009
+ ) {
1010
+ continue;
1011
+ }
1012
+ if (!lower.includes(lowerFrom)) {
1013
+ continue;
1014
+ }
1015
+ const start = match.index ?? 0;
1016
+ const end = start + form.length;
1017
+ if (occurrenceOverlaps(occurrences, start, end)) {
1018
+ continue;
1019
+ }
1020
+ occurrences.push(
1021
+ deferredOccurrenceFromDraft(
1022
+ file,
1023
+ plan,
1024
+ occurrenceDraftForSpan(file, start, end, form)
1025
+ )
1026
+ );
1027
+ }
1028
+ return occurrences.toSorted((left, right) =>
1029
+ left.path === right.path
1030
+ ? left.start - right.start
1031
+ : left.path.localeCompare(right.path)
1032
+ );
1033
+ };
1034
+
1035
+ const entryForOccurrences = (
1036
+ path: string,
1037
+ occurrences: readonly SourceOccurrence[]
1038
+ ): RegradeReportEntry | null => {
1039
+ if (occurrences.length === 0) {
1040
+ return null;
1041
+ }
1042
+ const hasDeferred = occurrences.some(
1043
+ (occurrence) => occurrence.verdict === 'deferred'
1044
+ );
1045
+ const hasModified = occurrences.some(
1046
+ (occurrence) => occurrence.verdict === 'modified'
1047
+ );
1048
+ if (hasDeferred) {
1049
+ return {
1050
+ notes: [
1051
+ `Found ${occurrences.length} vocabulary occurrence(s); judgment deferred.`,
1052
+ ],
1053
+ outcome: 'needs-review',
1054
+ path,
1055
+ reason: 'vocabulary-judgment-deferred',
1056
+ reviewDetails: occurrences
1057
+ .filter((occurrence) => occurrence.verdict === 'deferred')
1058
+ .map((occurrence) => ({
1059
+ expectedTarget:
1060
+ 'Add an override or preserve rule to the regrade plan.',
1061
+ reason: occurrence.reason,
1062
+ span: {
1063
+ column: occurrence.column,
1064
+ end: occurrence.end,
1065
+ line: occurrence.line,
1066
+ start: occurrence.start,
1067
+ },
1068
+ symbol: occurrence.form,
1069
+ })),
1070
+ };
1071
+ }
1072
+ if (hasModified) {
1073
+ return {
1074
+ notes: [
1075
+ `Found ${occurrences.length} vocabulary occurrence(s); safe modifications available.`,
1076
+ ],
1077
+ outcome: 'rewrite',
1078
+ path,
1079
+ };
1080
+ }
1081
+ return {
1082
+ notes: [`Skipped ${occurrences.length} vocabulary occurrence(s).`],
1083
+ outcome: 'no-op',
1084
+ path,
1085
+ };
1086
+ };
1087
+
1088
+ const applyOccurrenceRewrites = (
1089
+ file: SourceFile,
1090
+ occurrences: readonly SourceOccurrence[]
1091
+ ): string => {
1092
+ let nextSource = file.source;
1093
+ for (const occurrence of occurrences.toReversed()) {
1094
+ if (
1095
+ occurrence.verdict !== 'modified' ||
1096
+ occurrence.replacement === undefined
1097
+ ) {
1098
+ continue;
1099
+ }
1100
+ nextSource =
1101
+ nextSource.slice(0, occurrence.start) +
1102
+ occurrence.replacement +
1103
+ nextSource.slice(occurrence.end);
1104
+ }
1105
+ return nextSource;
1106
+ };
1107
+
1108
+ const buildVocabularyEvaluation = (params: {
1109
+ readonly apply?: boolean;
1110
+ readonly effectivePlan?: VocabularyRegradePlan | undefined;
1111
+ readonly files: readonly SourceFile[];
1112
+ readonly plan: VocabularyRegradePlan;
1113
+ readonly preserveInventory?: readonly VocabularyPreserveInventoryEntry[];
1114
+ readonly root: string;
1115
+ readonly skipped: readonly SkippedSource[];
1116
+ }): VocabularyEvaluation => {
1117
+ const effectivePlan = params.effectivePlan ?? params.plan;
1118
+ const targetForms = targetFormsForPlan(effectivePlan);
1119
+ const scopedFiles = params.files.filter((file) =>
1120
+ includedByScope(file.path, effectivePlan.scope)
1121
+ );
1122
+ const scopeSkipped: SkippedSource[] = params.files
1123
+ .filter((file) => !includedByScope(file.path, effectivePlan.scope))
1124
+ .map((file) => ({ path: file.path, reason: 'excluded-by-regrade-scope' }));
1125
+ const occurrences = scopedFiles.flatMap((file) => {
1126
+ const deferredOccurrences = deferredOccurrencesForFile(
1127
+ file,
1128
+ effectivePlan,
1129
+ targetForms
1130
+ );
1131
+ return [
1132
+ ...occurrencesForFile(
1133
+ file,
1134
+ effectivePlan,
1135
+ targetForms,
1136
+ deferredOccurrences
1137
+ ),
1138
+ ...deferredOccurrences,
1139
+ ];
1140
+ });
1141
+ const occurrencesByPath = new Map<string, SourceOccurrence[]>();
1142
+ for (const occurrence of occurrences) {
1143
+ const existing = occurrencesByPath.get(occurrence.path) ?? [];
1144
+ occurrencesByPath.set(occurrence.path, [...existing, occurrence]);
1145
+ }
1146
+ const entries = [...occurrencesByPath.entries()]
1147
+ .flatMap(([path, pathOccurrences]) => {
1148
+ const entry = entryForOccurrences(path, pathOccurrences);
1149
+ return entry === null ? [] : [entry];
1150
+ })
1151
+ .toSorted((left, right) => left.path.localeCompare(right.path));
1152
+ const rewrittenPaths = new Set(
1153
+ occurrences
1154
+ .filter((occurrence) => occurrence.verdict === 'modified')
1155
+ .map((occurrence) => occurrence.path)
1156
+ );
1157
+ const deferredForms = uniqueSorted(
1158
+ occurrences
1159
+ .filter((occurrence) => occurrence.verdict === 'deferred')
1160
+ .map((occurrence) => occurrence.form)
1161
+ );
1162
+ const modifiedOccurrences = occurrences.filter(
1163
+ (occurrence) => occurrence.verdict === 'modified'
1164
+ );
1165
+ const skippedOccurrences = occurrences.filter(
1166
+ (occurrence) => occurrence.verdict === 'skipped'
1167
+ );
1168
+ const deferredOccurrences = occurrences.filter(
1169
+ (occurrence) => occurrence.verdict === 'deferred'
1170
+ );
1171
+ const unresolvedOccurrences = occurrences.filter(
1172
+ (occurrence) =>
1173
+ occurrence.verdict === 'modified' || occurrence.verdict === 'deferred'
1174
+ );
1175
+ const forms: Record<string, VocabularyVerdict> = {};
1176
+ for (const occurrence of occurrences) {
1177
+ const current = forms[occurrence.form];
1178
+ if (occurrence.verdict === 'deferred') {
1179
+ forms[occurrence.form] = 'deferred';
1180
+ continue;
1181
+ }
1182
+ if (occurrence.verdict === 'modified' && current !== 'deferred') {
1183
+ forms[occurrence.form] = 'modified';
1184
+ continue;
1185
+ }
1186
+ if (current === undefined) {
1187
+ forms[occurrence.form] = 'skipped';
1188
+ }
1189
+ }
1190
+ const gateReasons: string[] = [];
1191
+ if (modifiedOccurrences.length > 0) {
1192
+ gateReasons.push(
1193
+ params.apply === true
1194
+ ? 'source-forms-remain-after-apply'
1195
+ : 'safe-modifications-not-yet-applied'
1196
+ );
1197
+ }
1198
+ if (deferredForms.length > 0) {
1199
+ gateReasons.push('deferred-forms-or-occurrences');
1200
+ }
1201
+ const open = unresolvedOccurrences.length;
1202
+
1203
+ return {
1204
+ entries: [
1205
+ ...entries,
1206
+ ...params.skipped.map((entry) => ({
1207
+ outcome: 'skip' as const,
1208
+ path: entry.path,
1209
+ reason: entry.reason,
1210
+ })),
1211
+ ...scopeSkipped.map((entry) => ({
1212
+ outcome: 'skip' as const,
1213
+ path: entry.path,
1214
+ reason: entry.reason,
1215
+ })),
1216
+ ].toSorted((left, right) => left.path.localeCompare(right.path)),
1217
+ occurrences,
1218
+ run: {
1219
+ ledger: {
1220
+ cycle: 1,
1221
+ forms,
1222
+ occurrences: occurrences.map(
1223
+ ({ absolutePath: _absolutePath, ...occurrence }) => occurrence
1224
+ ),
1225
+ },
1226
+ plan: params.plan,
1227
+ ...(params.preserveInventory === undefined ||
1228
+ params.preserveInventory.length === 0
1229
+ ? {}
1230
+ : { preserveInventory: params.preserveInventory }),
1231
+ report: {
1232
+ applied: params.apply === true ? modifiedOccurrences.length : 0,
1233
+ deferred: deferredOccurrences.length,
1234
+ dispositions: vocabularyDispositionCounts(occurrences),
1235
+ filesChanged: params.apply === true ? rewrittenPaths.size : 0,
1236
+ gate: {
1237
+ reasons: gateReasons,
1238
+ remaining: open,
1239
+ remainingByDisposition: vocabularyDispositionCounts(
1240
+ unresolvedOccurrences
1241
+ ),
1242
+ status: gateReasons.length === 0 ? 'green' : 'open',
1243
+ },
1244
+ modified: modifiedOccurrences.length,
1245
+ open,
1246
+ skipped: skippedOccurrences.length,
1247
+ },
1248
+ },
1249
+ scanned: scopedFiles.length,
1250
+ skipped: [...params.skipped, ...scopeSkipped],
1251
+ };
1252
+ };
1253
+
1254
+ const skippedByReason = (
1255
+ skipped: readonly SkippedSource[]
1256
+ ): Readonly<Record<string, number>> => {
1257
+ const counts = new Map<string, number>();
1258
+ for (const entry of skipped) {
1259
+ counts.set(entry.reason, (counts.get(entry.reason) ?? 0) + 1);
1260
+ }
1261
+ return Object.fromEntries(
1262
+ [...counts.entries()].toSorted(([left], [right]) =>
1263
+ left.localeCompare(right)
1264
+ )
1265
+ );
1266
+ };
1267
+
1268
+ const withApplySummary = (
1269
+ report: RegradeReport,
1270
+ apply: RegradeApplySummary
1271
+ ): RegradeReport => ({
1272
+ ...report,
1273
+ apply,
1274
+ });
1275
+
1276
+ const applyVocabularyEvaluation = (
1277
+ files: readonly SourceFile[],
1278
+ evaluation: VocabularyEvaluation
1279
+ ): Result<RegradeApplySummary, InternalError> => {
1280
+ const changedFiles = new Set<string>();
1281
+ let applied = 0;
1282
+ for (const file of files) {
1283
+ const fileOccurrences = evaluation.occurrences.filter(
1284
+ (occurrence) =>
1285
+ occurrence.path === file.path && occurrence.verdict === 'modified'
1286
+ );
1287
+ if (fileOccurrences.length === 0) {
1288
+ continue;
1289
+ }
1290
+ const nextSource = applyOccurrenceRewrites(file, fileOccurrences);
1291
+ if (nextSource === file.source) {
1292
+ continue;
1293
+ }
1294
+ try {
1295
+ writeFileSync(file.absolutePath, nextSource, 'utf8');
1296
+ } catch (error: unknown) {
1297
+ return Result.err(
1298
+ new InternalError(
1299
+ `Failed to apply vocabulary regrade rewrite for "${file.path}".`,
1300
+ {
1301
+ cause: error instanceof Error ? error : new Error(String(error)),
1302
+ context: {
1303
+ applied,
1304
+ filesChanged: changedFiles.size,
1305
+ path: file.path,
1306
+ },
1307
+ }
1308
+ )
1309
+ );
1310
+ }
1311
+ applied += fileOccurrences.length;
1312
+ changedFiles.add(file.path);
1313
+ }
1314
+
1315
+ const reviewFiles = new Set(
1316
+ evaluation.occurrences
1317
+ .filter((occurrence) => occurrence.verdict === 'deferred')
1318
+ .map((occurrence) => occurrence.path)
1319
+ );
1320
+ const skippedOccurrences = evaluation.occurrences.filter(
1321
+ (occurrence) => occurrence.verdict === 'skipped'
1322
+ );
1323
+
1324
+ return Result.ok({
1325
+ applied,
1326
+ filesChanged: changedFiles.size,
1327
+ review: reviewFiles.size,
1328
+ skipped: skippedOccurrences.length + evaluation.skipped.length,
1329
+ unknown: 0,
1330
+ });
1331
+ };
1332
+
1333
+ const readVocabularySourceFiles = (
1334
+ collected: NonNullable<ReturnType<typeof collectDownstreamSources>>
1335
+ ): {
1336
+ readonly files: readonly SourceFile[];
1337
+ readonly skipped: readonly SkippedSource[];
1338
+ } => {
1339
+ const files: SourceFile[] = [];
1340
+ const skipped: SkippedSource[] = [...collected.skipped];
1341
+ for (const file of collected.files) {
1342
+ try {
1343
+ files.push({
1344
+ absolutePath: file.absolutePath,
1345
+ path: file.path,
1346
+ source: readFileSync(file.absolutePath, 'utf8'),
1347
+ });
1348
+ } catch {
1349
+ skipped.push({ path: file.path, reason: 'unreadable-file' });
1350
+ }
1351
+ }
1352
+ return { files, skipped };
1353
+ };
1354
+
1355
+ const buildRunVocabularyEvaluation = (params: {
1356
+ readonly apply: boolean;
1357
+ readonly effectivePlan: VocabularyRegradePlan;
1358
+ readonly files: readonly SourceFile[];
1359
+ readonly plan: VocabularyRegradePlan;
1360
+ readonly preserveInventory:
1361
+ | readonly VocabularyPreserveInventoryEntry[]
1362
+ | undefined;
1363
+ readonly root: string;
1364
+ readonly skipped: readonly SkippedSource[];
1365
+ }): VocabularyEvaluation =>
1366
+ buildVocabularyEvaluation({
1367
+ apply: params.apply,
1368
+ effectivePlan: params.effectivePlan,
1369
+ files: params.files,
1370
+ plan: params.plan,
1371
+ ...(params.preserveInventory === undefined
1372
+ ? {}
1373
+ : { preserveInventory: params.preserveInventory }),
1374
+ root: params.root,
1375
+ skipped: params.skipped,
1376
+ });
1377
+
1378
+ export const runVocabularyRegrade = (params: {
1379
+ readonly apply?: boolean;
1380
+ readonly includeEntries?: 'actionable' | 'all';
1381
+ readonly plan: VocabularyRegradePlan;
1382
+ readonly preserveInventory?: readonly VocabularyPreserveInventoryEntry[];
1383
+ readonly root: string;
1384
+ }): Result<RegradeReport | null, InternalError | ValidationError> => {
1385
+ const planValidation = validateVocabularyPlan(params.plan);
1386
+ if (planValidation.isErr()) {
1387
+ return planValidation;
1388
+ }
1389
+ const inventoryValidation = validatePreserveInventory(
1390
+ params.preserveInventory
1391
+ );
1392
+ if (inventoryValidation.isErr()) {
1393
+ return inventoryValidation;
1394
+ }
1395
+
1396
+ const effectivePlan = effectivePlanForRun(
1397
+ params.plan,
1398
+ params.preserveInventory
1399
+ );
1400
+
1401
+ const collected = collectDownstreamSources(params.root, {
1402
+ extensions: effectivePlan.scope?.extensions ?? VOCABULARY_SOURCE_EXTENSIONS,
1403
+ ...(effectivePlan.scope?.exclude === undefined
1404
+ ? {}
1405
+ : { exclude: effectivePlan.scope.exclude }),
1406
+ ...(effectivePlan.scope?.include === undefined
1407
+ ? {}
1408
+ : { include: effectivePlan.scope.include }),
1409
+ ...(effectivePlan.scope?.ignoredDirectories === undefined
1410
+ ? {}
1411
+ : { ignoredDirectories: effectivePlan.scope.ignoredDirectories }),
1412
+ } satisfies DownstreamCollectionOptions);
1413
+ if (collected === null) {
1414
+ return Result.ok(null);
1415
+ }
1416
+
1417
+ const { files, skipped } = readVocabularySourceFiles(collected);
1418
+
1419
+ const dryRunEffectiveEvaluation = buildRunVocabularyEvaluation({
1420
+ apply: false,
1421
+ effectivePlan,
1422
+ files,
1423
+ plan: params.plan,
1424
+ preserveInventory: params.preserveInventory,
1425
+ root: params.root,
1426
+ skipped,
1427
+ });
1428
+ let reportEvaluation = dryRunEffectiveEvaluation;
1429
+ let applySummary: RegradeApplySummary | undefined;
1430
+
1431
+ if (params.apply === true) {
1432
+ const applyResult = applyVocabularyEvaluation(
1433
+ files,
1434
+ dryRunEffectiveEvaluation
1435
+ );
1436
+ if (applyResult.isErr()) {
1437
+ return applyResult;
1438
+ }
1439
+ applySummary = applyResult.value;
1440
+ const appliedFiles = files.map((file) => ({
1441
+ ...file,
1442
+ source: readFileSync(file.absolutePath, 'utf8'),
1443
+ }));
1444
+ reportEvaluation = buildRunVocabularyEvaluation({
1445
+ apply: true,
1446
+ effectivePlan,
1447
+ files: appliedFiles,
1448
+ plan: params.plan,
1449
+ preserveInventory: params.preserveInventory,
1450
+ root: params.root,
1451
+ skipped,
1452
+ });
1453
+ }
1454
+
1455
+ const entrySelection = params.includeEntries ?? 'actionable';
1456
+ const reportEntries = reportEvaluation.entries;
1457
+ const actionableEntries = reportEntries.filter(
1458
+ (entry) => entry.outcome === 'rewrite' || entry.outcome === 'needs-review'
1459
+ );
1460
+ const reportSkippedByReason = skippedByReason(reportEvaluation.skipped);
1461
+ const report: RegradeReport = {
1462
+ entries: entrySelection === 'all' ? reportEntries : actionableEntries,
1463
+ matched: actionableEntries.length,
1464
+ review: reportEntries.filter((entry) => entry.outcome === 'needs-review')
1465
+ .length,
1466
+ rewritten: reportEntries.filter((entry) => entry.outcome === 'rewrite')
1467
+ .length,
1468
+ root: collected.root,
1469
+ run:
1470
+ applySummary === undefined
1471
+ ? reportEvaluation.run
1472
+ : {
1473
+ ...reportEvaluation.run,
1474
+ report: {
1475
+ ...reportEvaluation.run.report,
1476
+ applied: applySummary.applied,
1477
+ filesChanged: applySummary.filesChanged,
1478
+ },
1479
+ },
1480
+ scan: buildRegradeScanSummary({
1481
+ matchedPaths: actionableEntries.map((entry) => entry.path),
1482
+ occurrencePaths: reportEvaluation.occurrences.map(
1483
+ (occurrence) => occurrence.path
1484
+ ),
1485
+ scanned: reportEvaluation.scanned,
1486
+ skipped: reportEvaluation.skipped.length,
1487
+ skippedByReason: reportSkippedByReason,
1488
+ }),
1489
+ scanned: reportEvaluation.scanned,
1490
+ selectedClassIds: [
1491
+ params.plan.id ?? `vocabulary:${params.plan.from}->${params.plan.to}`,
1492
+ ],
1493
+ skipped: reportEvaluation.skipped.length,
1494
+ skipsByReason: reportSkippedByReason,
1495
+ unknownClassIds: [],
1496
+ };
1497
+
1498
+ return Result.ok(
1499
+ applySummary === undefined ? report : withApplySummary(report, applySummary)
1500
+ );
1501
+ };
1502
+
1503
+ const vocabularyPreserveRuleSchema = z.object({
1504
+ disposition: z
1505
+ .enum(vocabularyDispositionValues)
1506
+ .optional()
1507
+ .describe('Classification for occurrences preserved by this rule'),
1508
+ forms: z
1509
+ .array(z.string().min(1))
1510
+ .optional()
1511
+ .describe('Matched forms this preserve rule applies to'),
1512
+ paths: z
1513
+ .array(z.string())
1514
+ .optional()
1515
+ .describe('Root-relative path patterns where the preserve rule applies'),
1516
+ pattern: z.string().describe('Regex or literal pattern to preserve'),
1517
+ reason: z.string().optional().describe('Why this form is preserved'),
1518
+ });
1519
+
1520
+ const vocabularyPreserveInventoryEntrySchema =
1521
+ vocabularyPreserveRuleSchema.extend({
1522
+ evidence: z
1523
+ .array(z.string().min(1))
1524
+ .describe('Graph or surface facts that justify this derived preserve'),
1525
+ source: z.literal('derived-live-api').describe('Derived inventory source'),
1526
+ });
1527
+
1528
+ const vocabularyDispositionCountSchema = z.object(
1529
+ Object.fromEntries(
1530
+ vocabularyDispositionValues.map((disposition) => [
1531
+ disposition,
1532
+ z.number().optional(),
1533
+ ])
1534
+ ) as Record<VocabularyDisposition, z.ZodOptional<z.ZodNumber>>
1535
+ );
1536
+
1537
+ const vocabularyRegradeScopeSchema = z.object({
1538
+ exclude: z
1539
+ .array(z.string())
1540
+ .optional()
1541
+ .describe('Root-relative path patterns to exclude from this regrade'),
1542
+ extensions: z
1543
+ .array(z.string())
1544
+ .optional()
1545
+ .describe('Source file extensions to scan for this regrade'),
1546
+ ignoredDirectories: z
1547
+ .array(z.string())
1548
+ .optional()
1549
+ .describe(
1550
+ 'Deprecated compatibility override for legacy plans. Use exclude globs for new plans.'
1551
+ ),
1552
+ include: z
1553
+ .array(z.string())
1554
+ .optional()
1555
+ .describe('Root-relative path patterns to include in this regrade'),
1556
+ });
1557
+
1558
+ export const vocabularyRegradePlanSchema = z.object({
1559
+ caseSensitive: z
1560
+ .boolean()
1561
+ .optional()
1562
+ .describe('Whether source form scanning preserves case exactly'),
1563
+ deferForms: z
1564
+ .array(z.string().min(1))
1565
+ .optional()
1566
+ .describe('Known forms that must be inventoried for review, not rewritten'),
1567
+ from: z.string().min(1).describe('Source vocabulary term or phrase'),
1568
+ id: z.string().optional().describe('Stable authored regrade plan id'),
1569
+ intent: z.string().optional().describe('Human-authored migration intent'),
1570
+ kind: z.literal('vocabulary').describe('Regrade plan kind'),
1571
+ overrides: z
1572
+ .record(z.string().min(1), z.string().min(1))
1573
+ .optional()
1574
+ .describe('Explicit source-form to target-form mappings'),
1575
+ preserve: z
1576
+ .array(vocabularyPreserveRuleSchema)
1577
+ .optional()
1578
+ .describe('Forms or contexts that are intentionally preserved'),
1579
+ scope: vocabularyRegradeScopeSchema
1580
+ .optional()
1581
+ .describe('Source scope for this regrade plan'),
1582
+ to: z.string().min(1).describe('Target vocabulary term or phrase'),
1583
+ });
1584
+
1585
+ export const vocabularyRegradeRunOutput = z.object({
1586
+ ledger: z
1587
+ .object({
1588
+ cycle: z.number().describe('Observed regrade run cycle'),
1589
+ forms: z
1590
+ .record(z.string(), z.enum(['deferred', 'modified', 'skipped']))
1591
+ .describe('Observed per-form triage verdicts for this run'),
1592
+ occurrences: z
1593
+ .array(
1594
+ z.object({
1595
+ column: z.number().describe('One-based source column'),
1596
+ context: z.string().describe('Source-line context'),
1597
+ disposition: z
1598
+ .enum(vocabularyDispositionValues)
1599
+ .describe('Occurrence-level classification beside the verdict'),
1600
+ end: z.number().describe('Source end offset'),
1601
+ form: z.string().describe('Matched vocabulary form'),
1602
+ line: z.number().describe('One-based source line'),
1603
+ path: z.string().describe('Root-relative POSIX path'),
1604
+ reason: z.string().describe('Why the occurrence got this verdict'),
1605
+ replacement: z
1606
+ .string()
1607
+ .optional()
1608
+ .describe('Replacement text for modified verdicts'),
1609
+ start: z.number().describe('Source start offset'),
1610
+ verdict: z
1611
+ .enum(['deferred', 'modified', 'skipped'])
1612
+ .describe('Occurrence-level verdict'),
1613
+ })
1614
+ )
1615
+ .describe('Observed occurrence-level ledger for this run'),
1616
+ })
1617
+ .describe('Observed run ledger'),
1618
+ plan: vocabularyRegradePlanSchema.describe('Authored regrade plan'),
1619
+ preserveInventory: z
1620
+ .array(vocabularyPreserveInventoryEntrySchema)
1621
+ .optional()
1622
+ .describe(
1623
+ 'Derived live-API preserve inventory applied at run time without changing the authored plan'
1624
+ ),
1625
+ report: z
1626
+ .object({
1627
+ applied: z.number().describe('Modified occurrences applied to disk'),
1628
+ deferred: z.number().describe('Deferred occurrence count'),
1629
+ dispositions: z
1630
+ .object(vocabularyDispositionCountSchema.shape)
1631
+ .describe('Occurrence counts grouped by disposition'),
1632
+ filesChanged: z.number().describe('Distinct files changed on disk'),
1633
+ gate: z
1634
+ .object({
1635
+ reasons: z.array(z.string()).describe('Open-gate reasons'),
1636
+ remaining: z.number().describe('Unresolved occurrence count'),
1637
+ remainingByDisposition: z
1638
+ .object(vocabularyDispositionCountSchema.shape)
1639
+ .describe('Unresolved occurrence counts grouped by disposition'),
1640
+ status: z
1641
+ .enum(['green', 'open'])
1642
+ .describe('Whether the run is complete'),
1643
+ })
1644
+ .describe('Completion gate derived from the run ledger'),
1645
+ modified: z.number().describe('Modified occurrence count'),
1646
+ open: z
1647
+ .number()
1648
+ .describe(
1649
+ 'Deferred or unapplied modified occurrences holding the gate open'
1650
+ ),
1651
+ skipped: z.number().describe('Skipped occurrence count'),
1652
+ })
1653
+ .describe('Projected run report'),
1654
+ });
1655
+
1656
+ const vocabularyTransitionRecordEnvironmentSchema = z
1657
+ .object({
1658
+ commitSha: z.string().optional(),
1659
+ engineVersion: z.string().optional(),
1660
+ graphHash: z.string().optional(),
1661
+ root: z.string(),
1662
+ })
1663
+ .strict();
1664
+
1665
+ const vocabularyTransitionRecordReportSchema = z
1666
+ .object({
1667
+ apply: z.unknown().optional(),
1668
+ entries: z.array(z.unknown()),
1669
+ matched: z.number(),
1670
+ review: z.number(),
1671
+ rewritten: z.number(),
1672
+ root: z.string(),
1673
+ run: vocabularyRegradeRunOutput,
1674
+ scan: z.unknown(),
1675
+ scanned: z.number(),
1676
+ selectedClassIds: z.array(z.string()),
1677
+ skipped: z.number(),
1678
+ skipsByReason: z.record(z.string(), z.number()),
1679
+ unknownClassIds: z.array(z.string()),
1680
+ })
1681
+ .strict();
1682
+
1683
+ const normalizeTransitionRecordPath = (path: string): string =>
1684
+ normalize(path).replaceAll('\\', '/');
1685
+
1686
+ const isSafeRootRelativeRecordPath = (path: string): boolean => {
1687
+ const normalized = normalizeTransitionRecordPath(path);
1688
+ return (
1689
+ normalized.length > 0 &&
1690
+ !isAbsolute(normalized) &&
1691
+ normalized !== '..' &&
1692
+ !normalized.startsWith('../')
1693
+ );
1694
+ };
1695
+
1696
+ export const vocabularyTransitionRecordSchema = z
1697
+ .object({
1698
+ environment: vocabularyTransitionRecordEnvironmentSchema,
1699
+ kind: z.literal('vocabulary-transition-record'),
1700
+ recordPath: z.string().refine(isSafeRootRelativeRecordPath),
1701
+ report: vocabularyTransitionRecordReportSchema,
1702
+ schemaVersion: z.literal(VOCABULARY_TRANSITION_RECORD_SCHEMA_VERSION),
1703
+ transition: z
1704
+ .object({
1705
+ from: z.string(),
1706
+ id: z.string(),
1707
+ to: z.string(),
1708
+ })
1709
+ .strict(),
1710
+ })
1711
+ .strict();
1712
+
1713
+ const transitionRecordSlug = (run: VocabularyRegradeRun): string =>
1714
+ `${run.plan.from}-to-${run.plan.to}`
1715
+ .toLowerCase()
1716
+ .replaceAll(/[^a-z0-9]+/g, '-')
1717
+ .replaceAll(/^-|-$/g, '');
1718
+
1719
+ const stableJson = (value: unknown): string =>
1720
+ JSON.stringify(value, (_key, nested) => {
1721
+ if (
1722
+ nested === null ||
1723
+ typeof nested !== 'object' ||
1724
+ Array.isArray(nested)
1725
+ ) {
1726
+ return nested as unknown;
1727
+ }
1728
+ return Object.fromEntries(
1729
+ Object.entries(nested as Record<string, unknown>).toSorted(
1730
+ ([left], [right]) => left.localeCompare(right)
1731
+ )
1732
+ );
1733
+ });
1734
+
1735
+ const shortHashForRun = (
1736
+ run: VocabularyRegradeRun,
1737
+ environment?: Partial<VocabularyTransitionRecordEnvironment>
1738
+ ): string => {
1739
+ const explicitHash = environment?.graphHash ?? environment?.commitSha;
1740
+ if (explicitHash !== undefined && explicitHash.length > 0) {
1741
+ return explicitHash.slice(0, 7);
1742
+ }
1743
+ return createHash('sha256')
1744
+ .update(stableJson({ ledger: run.ledger, plan: run.plan }))
1745
+ .digest('hex')
1746
+ .slice(0, 7);
1747
+ };
1748
+
1749
+ export const vocabularyTransitionRecordPath = (params: {
1750
+ readonly environment?: Partial<VocabularyTransitionRecordEnvironment>;
1751
+ readonly root: string;
1752
+ readonly run: VocabularyRegradeRun;
1753
+ }): string =>
1754
+ join(
1755
+ '.trails',
1756
+ 'regrade',
1757
+ 'history',
1758
+ `${transitionRecordSlug(params.run)}-${shortHashForRun(params.run, params.environment)}.json`
1759
+ );
1760
+
1761
+ const reportWithoutRecord = (
1762
+ report: RegradeReport
1763
+ ): Omit<RegradeReport, 'record'> => {
1764
+ const { record: _record, ...rest } = report;
1765
+ return rest;
1766
+ };
1767
+
1768
+ const transitionRecordPathForWrite = (params: {
1769
+ readonly environment: VocabularyTransitionRecordEnvironment;
1770
+ readonly recordPath?: string;
1771
+ readonly report: RegradeReport;
1772
+ readonly root: string;
1773
+ }): Result<string, ValidationError> => {
1774
+ const recordPath =
1775
+ params.recordPath ??
1776
+ vocabularyTransitionRecordPath({
1777
+ environment: params.environment,
1778
+ root: params.root,
1779
+ run: params.report.run as VocabularyRegradeRun,
1780
+ });
1781
+ const normalized = normalizeTransitionRecordPath(
1782
+ isAbsolute(recordPath) ? relative(params.root, recordPath) : recordPath
1783
+ );
1784
+ if (!isSafeRootRelativeRecordPath(normalized)) {
1785
+ return Result.err(
1786
+ new ValidationError(
1787
+ 'Vocabulary transition record path must stay inside the regrade root.',
1788
+ { context: { recordPath } }
1789
+ )
1790
+ );
1791
+ }
1792
+ return Result.ok(normalized);
1793
+ };
1794
+
1795
+ export const buildVocabularyTransitionRecord = (params: {
1796
+ readonly environment?: Partial<VocabularyTransitionRecordEnvironment>;
1797
+ readonly recordPath?: string;
1798
+ readonly report: RegradeReport;
1799
+ readonly root: string;
1800
+ }): Result<VocabularyTransitionRecord, ValidationError> => {
1801
+ if (params.report.run === undefined) {
1802
+ return Result.err(
1803
+ new ValidationError(
1804
+ 'Vocabulary transition records require a vocabulary Regrade report.'
1805
+ )
1806
+ );
1807
+ }
1808
+
1809
+ const environment: VocabularyTransitionRecordEnvironment = {
1810
+ ...(params.environment?.commitSha === undefined
1811
+ ? {}
1812
+ : { commitSha: params.environment.commitSha }),
1813
+ ...(params.environment?.engineVersion === undefined
1814
+ ? {}
1815
+ : { engineVersion: params.environment.engineVersion }),
1816
+ ...(params.environment?.graphHash === undefined
1817
+ ? {}
1818
+ : { graphHash: params.environment.graphHash }),
1819
+ root: params.root,
1820
+ };
1821
+ const recordPathResult = transitionRecordPathForWrite({
1822
+ environment,
1823
+ report: params.report,
1824
+ root: params.root,
1825
+ ...(params.recordPath === undefined
1826
+ ? {}
1827
+ : { recordPath: params.recordPath }),
1828
+ });
1829
+ if (recordPathResult.isErr()) {
1830
+ return recordPathResult;
1831
+ }
1832
+ const recordPath = recordPathResult.value;
1833
+ const record: VocabularyTransitionRecord = {
1834
+ environment,
1835
+ kind: 'vocabulary-transition-record',
1836
+ recordPath,
1837
+ report: reportWithoutRecord(params.report),
1838
+ schemaVersion: VOCABULARY_TRANSITION_RECORD_SCHEMA_VERSION,
1839
+ transition: {
1840
+ from: params.report.run.plan.from,
1841
+ id:
1842
+ params.report.run.plan.id ??
1843
+ `vocabulary:${params.report.run.plan.from}->${params.report.run.plan.to}`,
1844
+ to: params.report.run.plan.to,
1845
+ },
1846
+ };
1847
+ const parsed = vocabularyTransitionRecordSchema.safeParse(record);
1848
+ if (!parsed.success) {
1849
+ return Result.err(
1850
+ new ValidationError('Invalid vocabulary transition record.', {
1851
+ context: { issues: parsed.error.issues },
1852
+ })
1853
+ );
1854
+ }
1855
+ return Result.ok(parsed.data as VocabularyTransitionRecord);
1856
+ };
1857
+
1858
+ export const writeVocabularyTransitionRecord = (params: {
1859
+ readonly environment?: Partial<VocabularyTransitionRecordEnvironment>;
1860
+ readonly recordPath?: string;
1861
+ readonly report: RegradeReport;
1862
+ readonly root: string;
1863
+ readonly status: VocabularyTransitionRecordSummary['status'];
1864
+ }): Result<
1865
+ {
1866
+ readonly record: VocabularyTransitionRecord;
1867
+ readonly summary: VocabularyTransitionRecordSummary;
1868
+ },
1869
+ InternalError | ValidationError
1870
+ > => {
1871
+ const recordResult = buildVocabularyTransitionRecord(params);
1872
+ if (recordResult.isErr()) {
1873
+ return recordResult;
1874
+ }
1875
+ const record = recordResult.value;
1876
+ const absolutePath = isAbsolute(record.recordPath)
1877
+ ? record.recordPath
1878
+ : join(params.root, record.recordPath);
1879
+ try {
1880
+ mkdirSync(dirname(absolutePath), { recursive: true });
1881
+ writeFileSync(absolutePath, `${JSON.stringify(record, null, 2)}\n`);
1882
+ } catch (error) {
1883
+ return Result.err(
1884
+ new InternalError('Failed to write vocabulary transition record.', {
1885
+ ...(error instanceof Error ? { cause: error } : {}),
1886
+ context: { path: record.recordPath },
1887
+ })
1888
+ );
1889
+ }
1890
+ return Result.ok({
1891
+ record,
1892
+ summary: {
1893
+ path: record.recordPath,
1894
+ schemaVersion: record.schemaVersion,
1895
+ status: params.status,
1896
+ },
1897
+ });
1898
+ };
1899
+
1900
+ export const readVocabularyTransitionRecord = (
1901
+ path: string
1902
+ ): Result<VocabularyTransitionRecord, InternalError | ValidationError> => {
1903
+ if (!existsSync(path)) {
1904
+ return Result.err(
1905
+ new ValidationError(`Vocabulary transition record "${path}" not found.`)
1906
+ );
1907
+ }
1908
+ let parsedJson: unknown;
1909
+ try {
1910
+ parsedJson = JSON.parse(readFileSync(path, 'utf8'));
1911
+ } catch (error) {
1912
+ return Result.err(
1913
+ new InternalError('Failed to read vocabulary transition record.', {
1914
+ ...(error instanceof Error ? { cause: error } : {}),
1915
+ context: { path },
1916
+ })
1917
+ );
1918
+ }
1919
+ const parsed = vocabularyTransitionRecordSchema.safeParse(parsedJson);
1920
+ if (!parsed.success) {
1921
+ return Result.err(
1922
+ new ValidationError('Invalid vocabulary transition record.', {
1923
+ context: { issues: parsed.error.issues, path },
1924
+ })
1925
+ );
1926
+ }
1927
+ return Result.ok(parsed.data as VocabularyTransitionRecord);
1928
+ };
1929
+
1930
+ export const transitionRecordReportWithSummary = (
1931
+ report: RegradeReport,
1932
+ summary: VocabularyTransitionRecordSummary
1933
+ ): RegradeReport => ({ ...report, record: summary });