@ontrails/regrade 0.2.0

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,3094 @@
1
+ import {
2
+ InternalError,
3
+ Result,
4
+ ValidationError,
5
+ escapeRegExp,
6
+ isPlainObject,
7
+ matchesAnyPathGlob,
8
+ } from '@ontrails/core';
9
+ import { parseWithDiagnostics } from '@ontrails/source';
10
+ import type { SourceComment } from '@ontrails/source';
11
+ import { createHash } from 'node:crypto';
12
+ import {
13
+ dirname,
14
+ extname,
15
+ isAbsolute,
16
+ join,
17
+ normalize,
18
+ relative,
19
+ } from 'node:path';
20
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
21
+ import { z } from 'zod';
22
+
23
+ import {
24
+ DEFAULT_IGNORED_DIRECTORIES,
25
+ collectDownstreamSources,
26
+ } from './collect.js';
27
+ import type { DownstreamCollectionOptions, SkippedSource } from './collect.js';
28
+ import type {
29
+ PreparedRegradeRunIdentity,
30
+ RegradeApplySummary,
31
+ RegradeReport,
32
+ RegradeReportEntry,
33
+ } from './report.js';
34
+ import { buildRegradeScanSummary } from './scan-summary.js';
35
+
36
+ export type VocabularyVerdict = 'applied' | 'deferred' | 'modified' | 'skipped';
37
+
38
+ export type VocabularyOccurrenceSourceKind = 'source-comment' | 'tsdoc';
39
+
40
+ export const vocabularyDispositionValues = [
41
+ 'code-context-out-of-engine',
42
+ 'docs-only',
43
+ 'explicit-preserve',
44
+ 'forward-pointer',
45
+ 'historical-by-policy',
46
+ 'ignored-by-scope',
47
+ 'in-family-modified',
48
+ 'in-family-unresolved',
49
+ 'out-of-family',
50
+ 'preserve-current-live-api',
51
+ ] as const;
52
+
53
+ export type VocabularyDisposition =
54
+ (typeof vocabularyDispositionValues)[number];
55
+
56
+ const vocabularyDispositions = new Set<string>(vocabularyDispositionValues);
57
+
58
+ export interface VocabularyPreserveRule {
59
+ readonly disposition?: VocabularyDisposition;
60
+ readonly forms?: readonly string[];
61
+ readonly pattern: string;
62
+ readonly reason?: string;
63
+ readonly paths?: readonly string[];
64
+ }
65
+
66
+ export interface VocabularyPreserveInventoryEntry extends VocabularyPreserveRule {
67
+ readonly evidence: readonly string[];
68
+ readonly source: 'derived-live-api';
69
+ }
70
+
71
+ export interface VocabularyScopePolicy {
72
+ readonly disposition: VocabularyDisposition;
73
+ readonly expectMatches?: boolean | undefined;
74
+ readonly paths: readonly string[];
75
+ readonly reason: string;
76
+ }
77
+
78
+ export type VocabularyScopeTier = 'in-scope' | 'policy-classified';
79
+
80
+ export interface VocabularyRegradeScope {
81
+ readonly exclude?: readonly string[];
82
+ readonly extensions?: readonly string[];
83
+ /**
84
+ * @deprecated Use `exclude` path globs for new plans. This remains as a
85
+ * compatibility bridge for pre-path-scope plans that intentionally disabled
86
+ * the collector's default directory pruning.
87
+ */
88
+ readonly ignoredDirectories?: readonly string[];
89
+ readonly include?: readonly string[];
90
+ readonly policyClassified?: readonly VocabularyScopePolicy[];
91
+ readonly teachingSurfaces?: readonly string[];
92
+ }
93
+
94
+ /**
95
+ * One authored root-relative file move in a vocabulary plan.
96
+ *
97
+ * @example
98
+ * ```ts
99
+ * const rename: VocabularyFileRename = {
100
+ * from: 'docs/old.md',
101
+ * to: 'docs/new.md',
102
+ * };
103
+ * ```
104
+ */
105
+ export interface VocabularyFileRename {
106
+ readonly from: string;
107
+ readonly to: string;
108
+ }
109
+
110
+ /**
111
+ * Derived reference-closure totals for one governed file move.
112
+ *
113
+ * @example
114
+ * ```ts
115
+ * console.log(evidence.rewritten, evidence.historical);
116
+ * ```
117
+ */
118
+ export interface VocabularyFileRenameEvidence extends VocabularyFileRename {
119
+ readonly deferred: number;
120
+ readonly historical: number;
121
+ readonly preserved: number;
122
+ readonly rewritten: number;
123
+ readonly skipped: number;
124
+ }
125
+
126
+ /**
127
+ * One deterministic form proposal synthesized from a vocabulary seed.
128
+ *
129
+ * @example
130
+ * ```ts
131
+ * const proposal: VocabularyFormProposal = {
132
+ * from: 'legacies',
133
+ * kind: 'safe-rewrite',
134
+ * reason: 'default-morphology',
135
+ * source: 'default-morphology',
136
+ * to: 'currents',
137
+ * };
138
+ * ```
139
+ */
140
+ export interface VocabularyFormProposal {
141
+ readonly from: string;
142
+ readonly kind: 'review' | 'safe-rewrite';
143
+ readonly reason: string;
144
+ readonly source:
145
+ | 'default-morphology'
146
+ | 'plan-defer'
147
+ | 'plan-override'
148
+ | 'seed';
149
+ readonly to?: string;
150
+ }
151
+
152
+ export interface VocabularyRegradePlan {
153
+ readonly caseSensitive?: boolean;
154
+ readonly deferForms?: readonly string[];
155
+ readonly fileRenames?: readonly VocabularyFileRename[];
156
+ readonly from: string;
157
+ readonly id?: string;
158
+ readonly intent?: string;
159
+ readonly kind: 'vocabulary';
160
+ readonly overrides?: Readonly<Record<string, string>>;
161
+ readonly preserve?: readonly VocabularyPreserveRule[];
162
+ readonly scope?: VocabularyRegradeScope;
163
+ readonly to: string;
164
+ }
165
+
166
+ export interface VocabularyOccurrence {
167
+ readonly column: number;
168
+ readonly context: string;
169
+ readonly disposition: VocabularyDisposition;
170
+ readonly end: number;
171
+ readonly form: string;
172
+ readonly line: number;
173
+ readonly path: string;
174
+ readonly reason: string;
175
+ readonly replacement?: string;
176
+ readonly start: number;
177
+ readonly scopeTier: VocabularyScopeTier;
178
+ readonly sourceKind?: VocabularyOccurrenceSourceKind;
179
+ readonly verdict: VocabularyVerdict;
180
+ }
181
+
182
+ export interface VocabularyRunLedger {
183
+ readonly cycle: number;
184
+ readonly forms: Readonly<Record<string, VocabularyVerdict>>;
185
+ readonly occurrences: readonly VocabularyOccurrence[];
186
+ }
187
+
188
+ export interface VocabularyRunGate {
189
+ readonly remaining: number;
190
+ readonly remainingByDisposition: Partial<
191
+ Readonly<Record<VocabularyDisposition, number>>
192
+ >;
193
+ readonly reasons: readonly string[];
194
+ readonly status: 'green' | 'open';
195
+ }
196
+
197
+ export interface VocabularyRunReport {
198
+ readonly applied: number;
199
+ readonly deferred: number;
200
+ readonly dispositions: Partial<
201
+ Readonly<Record<VocabularyDisposition, number>>
202
+ >;
203
+ readonly filesChanged: number;
204
+ readonly fileRenames?: readonly VocabularyFileRenameEvidence[];
205
+ readonly gate: VocabularyRunGate;
206
+ readonly modified: number;
207
+ readonly open: number;
208
+ readonly skipped: number;
209
+ readonly scopeTiers: Readonly<Record<VocabularyScopeTier, number>>;
210
+ readonly teachingSurfaces: {
211
+ readonly expected: readonly string[];
212
+ readonly missing: readonly string[];
213
+ readonly touched: readonly string[];
214
+ };
215
+ }
216
+
217
+ export interface VocabularyRegradeRun {
218
+ readonly ledger: VocabularyRunLedger;
219
+ readonly plan: VocabularyRegradePlan;
220
+ readonly preserveInventory?: readonly VocabularyPreserveInventoryEntry[];
221
+ readonly report: VocabularyRunReport;
222
+ }
223
+
224
+ export const VOCABULARY_TRANSITION_RECORD_SCHEMA_VERSION = 1;
225
+
226
+ export interface VocabularyTransitionRecordEnvironment {
227
+ readonly commitSha?: string;
228
+ readonly engineVersion?: string;
229
+ readonly graphHash?: string;
230
+ readonly root: string;
231
+ }
232
+
233
+ export interface VocabularyTransitionRecord {
234
+ readonly environment: VocabularyTransitionRecordEnvironment;
235
+ readonly kind: 'vocabulary-transition-record';
236
+ readonly recordPath: string;
237
+ readonly report: Omit<RegradeReport, 'record'>;
238
+ readonly schemaVersion: typeof VOCABULARY_TRANSITION_RECORD_SCHEMA_VERSION;
239
+ readonly transition: {
240
+ readonly from: string;
241
+ readonly id: string;
242
+ readonly to: string;
243
+ };
244
+ }
245
+
246
+ export interface VocabularyTransitionRecordSummary {
247
+ readonly path: string;
248
+ readonly schemaVersion: typeof VOCABULARY_TRANSITION_RECORD_SCHEMA_VERSION;
249
+ readonly status: 'candidate' | 'applied' | 'checked';
250
+ }
251
+
252
+ interface SourceFile {
253
+ readonly absolutePath: string;
254
+ readonly path: string;
255
+ readonly source: string;
256
+ readonly sourceBytes: string;
257
+ }
258
+
259
+ interface SourceOccurrence extends VocabularyOccurrence {
260
+ readonly absolutePath: string;
261
+ }
262
+
263
+ type VocabularySourceKind = 'all' | 'comments';
264
+
265
+ const sourceCommentKind = (
266
+ file: SourceFile,
267
+ comment: SourceComment
268
+ ): VocabularyOccurrenceSourceKind =>
269
+ comment.type === 'Block' && file.source.startsWith('/**', comment.start)
270
+ ? 'tsdoc'
271
+ : 'source-comment';
272
+
273
+ interface SourceOccurrenceDraft extends Omit<
274
+ SourceOccurrence,
275
+ 'disposition' | 'reason' | 'scopeTier' | 'verdict'
276
+ > {
277
+ readonly contextColumn: number;
278
+ }
279
+
280
+ interface VocabularyEvaluation {
281
+ readonly entries: readonly RegradeReportEntry[];
282
+ readonly occurrences: readonly SourceOccurrence[];
283
+ readonly scanned: number;
284
+ readonly skipped: readonly SkippedSource[];
285
+ readonly run: VocabularyRegradeRun;
286
+ }
287
+
288
+ const VOCABULARY_SOURCE_EXTENSIONS = Object.freeze([
289
+ '.js',
290
+ '.jsx',
291
+ '.json',
292
+ '.jsonc',
293
+ '.md',
294
+ '.mdx',
295
+ '.mjs',
296
+ '.ts',
297
+ '.tsx',
298
+ '.txt',
299
+ '.yaml',
300
+ '.yml',
301
+ ]);
302
+
303
+ const uniqueSorted = (values: readonly string[]): readonly string[] =>
304
+ [...new Set(values)].toSorted((a, b) => a.localeCompare(b));
305
+
306
+ const vocabularyDispositionCounts = (
307
+ occurrences: readonly VocabularyOccurrence[]
308
+ ): Partial<Readonly<Record<VocabularyDisposition, number>>> => {
309
+ const counts = new Map<VocabularyDisposition, number>();
310
+ for (const occurrence of occurrences) {
311
+ counts.set(
312
+ occurrence.disposition,
313
+ (counts.get(occurrence.disposition) ?? 0) + 1
314
+ );
315
+ }
316
+ return Object.fromEntries(
317
+ [...counts.entries()].toSorted(([left], [right]) =>
318
+ left.localeCompare(right)
319
+ )
320
+ );
321
+ };
322
+
323
+ const vocabularyFormVerdicts = (
324
+ occurrences: readonly VocabularyOccurrence[]
325
+ ): Readonly<Record<string, VocabularyVerdict>> => {
326
+ const priority: Readonly<Record<VocabularyVerdict, number>> = {
327
+ applied: 1,
328
+ deferred: 3,
329
+ modified: 2,
330
+ skipped: 0,
331
+ };
332
+ const forms: Record<string, VocabularyVerdict> = {};
333
+ for (const occurrence of occurrences) {
334
+ if (occurrence.verdict === 'applied') {
335
+ continue;
336
+ }
337
+ const current = forms[occurrence.form];
338
+ if (
339
+ current === undefined ||
340
+ priority[occurrence.verdict] > priority[current]
341
+ ) {
342
+ forms[occurrence.form] = occurrence.verdict;
343
+ }
344
+ }
345
+ return forms;
346
+ };
347
+
348
+ const vocabularyScopeTierCounts = (
349
+ occurrences: readonly VocabularyOccurrence[]
350
+ ): Readonly<Record<VocabularyScopeTier, number>> => ({
351
+ 'in-scope': occurrences.filter(
352
+ (occurrence) => occurrence.scopeTier === 'in-scope'
353
+ ).length,
354
+ 'policy-classified': occurrences.filter(
355
+ (occurrence) => occurrence.scopeTier === 'policy-classified'
356
+ ).length,
357
+ });
358
+
359
+ const vocabularyScopeEvidence = (
360
+ plan: VocabularyRegradePlan,
361
+ occurrences: readonly VocabularyOccurrence[]
362
+ ): {
363
+ readonly gateReasons: readonly string[];
364
+ readonly scopeTiers: Readonly<Record<VocabularyScopeTier, number>>;
365
+ readonly teachingSurfaces: VocabularyRunReport['teachingSurfaces'];
366
+ } => {
367
+ const expected = uniqueSorted(plan.scope?.teachingSurfaces ?? []);
368
+ const touched = expected.filter((pattern) =>
369
+ occurrences.some(
370
+ (occurrence) =>
371
+ occurrence.scopeTier === 'in-scope' &&
372
+ matchesAnyPathGlob(occurrence.path, [pattern])
373
+ )
374
+ );
375
+ const missing = expected.filter((pattern) => !touched.includes(pattern));
376
+ const expectedPolicyMissing =
377
+ plan.scope?.policyClassified?.some(
378
+ (policy) =>
379
+ policy.expectMatches === true &&
380
+ !occurrences.some(
381
+ (occurrence) =>
382
+ occurrence.scopeTier === 'policy-classified' &&
383
+ matchesAnyPathGlob(occurrence.path, policy.paths)
384
+ )
385
+ ) ?? false;
386
+ return {
387
+ gateReasons: [
388
+ ...(expectedPolicyMissing
389
+ ? ['expected-policy-classified-evidence-missing']
390
+ : []),
391
+ ...(missing.length === 0 ? [] : ['expected-teaching-surfaces-missing']),
392
+ ],
393
+ scopeTiers: vocabularyScopeTierCounts(occurrences),
394
+ teachingSurfaces: { expected, missing, touched },
395
+ };
396
+ };
397
+
398
+ const isVocabularyTokenCharacter = (
399
+ value: string,
400
+ routeLike: boolean
401
+ ): boolean => /[A-Za-z0-9_$-]/.test(value) || (routeLike && value === '/');
402
+
403
+ const isVocabularyTokenCharacterAt = (
404
+ source: string,
405
+ index: number,
406
+ routeLike: boolean
407
+ ): boolean => {
408
+ if (index < 0 || index >= source.length) {
409
+ return false;
410
+ }
411
+ const value = source.at(index) ?? '';
412
+ if (isVocabularyTokenCharacter(value, routeLike)) {
413
+ return true;
414
+ }
415
+ if (!routeLike || value !== '.') {
416
+ return false;
417
+ }
418
+ return (
419
+ isVocabularyTokenCharacter(source.at(index - 1) ?? '', false) &&
420
+ isVocabularyTokenCharacter(source.at(index + 1) ?? '', false)
421
+ );
422
+ };
423
+
424
+ const hasWordBoundary = (
425
+ source: string,
426
+ start: number,
427
+ end: number,
428
+ form: string
429
+ ): boolean => {
430
+ const routeLike = form.includes('/');
431
+ return (
432
+ !isVocabularyTokenCharacterAt(source, start - 1, routeLike) &&
433
+ !isVocabularyTokenCharacterAt(source, end, routeLike)
434
+ );
435
+ };
436
+
437
+ const expandVocabularyNeighborSpan = (
438
+ source: string,
439
+ start: number,
440
+ end: number,
441
+ form: string
442
+ ): { readonly end: number; readonly start: number } => {
443
+ const routeLike = form.includes('/');
444
+ let expandedStart = start;
445
+ while (
446
+ expandedStart > 0 &&
447
+ isVocabularyTokenCharacterAt(source, expandedStart - 1, routeLike)
448
+ ) {
449
+ expandedStart -= 1;
450
+ }
451
+
452
+ let expandedEnd = end;
453
+ while (
454
+ expandedEnd < source.length &&
455
+ isVocabularyTokenCharacterAt(source, expandedEnd, routeLike)
456
+ ) {
457
+ expandedEnd += 1;
458
+ }
459
+
460
+ return { end: expandedEnd, start: expandedStart };
461
+ };
462
+
463
+ const lineColumnForOffset = (
464
+ source: string,
465
+ offset: number
466
+ ): { readonly column: number; readonly line: number } => {
467
+ let line = 1;
468
+ let column = 1;
469
+ for (let index = 0; index < offset; index += 1) {
470
+ const codePoint = source.codePointAt(index);
471
+ if (
472
+ codePoint === 10 ||
473
+ codePoint === 13 ||
474
+ codePoint === 0x20_28 ||
475
+ codePoint === 0x20_29
476
+ ) {
477
+ if (codePoint === 13 && source.codePointAt(index + 1) === 10) {
478
+ index += 1;
479
+ }
480
+ line += 1;
481
+ column = 1;
482
+ } else {
483
+ column += 1;
484
+ }
485
+ }
486
+ return { column, line };
487
+ };
488
+
489
+ const contextDetailsForOffset = (
490
+ source: string,
491
+ start: number,
492
+ end: number
493
+ ): { readonly context: string; readonly contextColumn: number } => {
494
+ let lineStart = start;
495
+ while (lineStart > 0) {
496
+ const codePoint = source.codePointAt(lineStart - 1);
497
+ if (
498
+ codePoint === 10 ||
499
+ codePoint === 13 ||
500
+ codePoint === 0x20_28 ||
501
+ codePoint === 0x20_29
502
+ ) {
503
+ break;
504
+ }
505
+ lineStart -= 1;
506
+ }
507
+ let lineEnd = end;
508
+ while (lineEnd < source.length) {
509
+ const codePoint = source.codePointAt(lineEnd);
510
+ if (
511
+ codePoint === 10 ||
512
+ codePoint === 13 ||
513
+ codePoint === 0x20_28 ||
514
+ codePoint === 0x20_29
515
+ ) {
516
+ break;
517
+ }
518
+ lineEnd += 1;
519
+ }
520
+ const rawLine = source.slice(lineStart, lineEnd);
521
+ const leadingTrimmed = rawLine.length - rawLine.trimStart().length;
522
+ return {
523
+ context: rawLine.trim(),
524
+ contextColumn: start - lineStart - leadingTrimmed + 1,
525
+ };
526
+ };
527
+
528
+ const isMarkdownPath = (path: string): boolean =>
529
+ path.endsWith('.md') || path.endsWith('.mdx');
530
+
531
+ const packageRouteCodeExtensions = new Set([
532
+ '.cjs',
533
+ '.cts',
534
+ '.js',
535
+ '.jsx',
536
+ '.mjs',
537
+ '.mts',
538
+ '.ts',
539
+ '.tsx',
540
+ ]);
541
+
542
+ const isPackageRouteCodeOccurrence = (path: string, form: string): boolean =>
543
+ packageRouteCodeExtensions.has(extname(path)) &&
544
+ /^@[^/]+\/[^/]+(?:\/.*)?$/.test(form);
545
+
546
+ const isPackageManifestPath = (path: string): boolean =>
547
+ path === 'package.json' || path.endsWith('/package.json');
548
+
549
+ const sourceLineBoundsForOffset = (
550
+ source: string,
551
+ start: number,
552
+ end: number
553
+ ): { readonly lineEnd: number; readonly lineStart: number } => {
554
+ const lineStart = source.lastIndexOf('\n', start - 1) + 1;
555
+ const nextLine = source.indexOf('\n', end);
556
+ return { lineEnd: nextLine === -1 ? source.length : nextLine, lineStart };
557
+ };
558
+
559
+ const markdownBacktickRuns = (value: string): readonly RegExpMatchArray[] => [
560
+ ...value.matchAll(/(?<!\\)`+/g),
561
+ ];
562
+
563
+ const isMarkdownInlineCodeContext = (
564
+ source: string,
565
+ start: number,
566
+ end: number
567
+ ): boolean => {
568
+ const { lineEnd, lineStart } = sourceLineBoundsForOffset(source, start, end);
569
+ const line = source.slice(lineStart, lineEnd);
570
+ const relativeStart = start - lineStart;
571
+ const relativeEnd = end - lineStart;
572
+ let openRun: { readonly length: number; readonly start: number } | undefined;
573
+
574
+ for (const run of markdownBacktickRuns(line)) {
575
+ const runStart = run.index ?? 0;
576
+ const [value] = run;
577
+ const runLength = value.length;
578
+ if (openRun === undefined) {
579
+ openRun = { length: runLength, start: runStart };
580
+ continue;
581
+ }
582
+ if (runLength !== openRun.length) {
583
+ continue;
584
+ }
585
+ if (
586
+ openRun.start + openRun.length <= relativeStart &&
587
+ relativeEnd <= runStart
588
+ ) {
589
+ return true;
590
+ }
591
+ openRun = undefined;
592
+ }
593
+
594
+ return false;
595
+ };
596
+
597
+ const markdownFenceLinePattern = /^\s*(?:>\s*){0,8}(```|~~~)/;
598
+
599
+ const isMarkdownFenceContext = (source: string, start: number): boolean => {
600
+ const before = source.slice(0, start);
601
+ let fenced = false;
602
+ for (const line of before.split('\n')) {
603
+ if (markdownFenceLinePattern.test(line)) {
604
+ fenced = !fenced;
605
+ }
606
+ }
607
+ return fenced;
608
+ };
609
+
610
+ const isMarkdownCodeContext = (
611
+ file: SourceFile,
612
+ start: number,
613
+ end: number
614
+ ): boolean =>
615
+ isMarkdownPath(file.path) &&
616
+ (isMarkdownInlineCodeContext(file.source, start, end) ||
617
+ isMarkdownFenceContext(file.source, start));
618
+
619
+ const vocabularyOccurrenceReason = (
620
+ preserveRule: VocabularyPreserveRule | undefined,
621
+ markdownCodeContext: boolean,
622
+ defaultReason: string
623
+ ): string => {
624
+ if (preserveRule !== undefined) {
625
+ return preserveRule.reason ?? 'preserved-by-plan';
626
+ }
627
+ if (markdownCodeContext) {
628
+ return 'markdown-code-context';
629
+ }
630
+ return defaultReason;
631
+ };
632
+
633
+ const capturedVocabularyVerdict = (
634
+ preserveRule: VocabularyPreserveRule | undefined,
635
+ markdownCodeContext: boolean
636
+ ): VocabularyVerdict => {
637
+ if (preserveRule !== undefined) {
638
+ return 'skipped';
639
+ }
640
+ if (markdownCodeContext) {
641
+ return 'deferred';
642
+ }
643
+ return 'modified';
644
+ };
645
+
646
+ const vocabularyOccurrenceDisposition = (
647
+ verdict: VocabularyVerdict,
648
+ preserveRule: VocabularyPreserveRule | undefined,
649
+ markdownCodeContext: boolean
650
+ ): VocabularyDisposition => {
651
+ if (preserveRule !== undefined) {
652
+ return preserveRule.disposition ?? 'explicit-preserve';
653
+ }
654
+ if (markdownCodeContext) {
655
+ return 'code-context-out-of-engine';
656
+ }
657
+ if (verdict === 'modified') {
658
+ return 'in-family-modified';
659
+ }
660
+ return 'in-family-unresolved';
661
+ };
662
+
663
+ const preserveCase = (sourceForm: string, replacement: string): string => {
664
+ if (sourceForm.toUpperCase() === sourceForm) {
665
+ return replacement.toUpperCase();
666
+ }
667
+ const first = sourceForm.at(0);
668
+ if (first !== undefined && first.toUpperCase() === first) {
669
+ return replacement.at(0)?.toUpperCase() + replacement.slice(1);
670
+ }
671
+ return replacement;
672
+ };
673
+
674
+ const isSimpleVocabularyWord = (value: string): boolean =>
675
+ /^[A-Za-z]+$/.test(value);
676
+
677
+ const endsWithConsonantY = (value: string): boolean => {
678
+ const penultimate = value.at(-2);
679
+ return (
680
+ value.endsWith('y') &&
681
+ penultimate !== undefined &&
682
+ !/[aeiou]/.test(penultimate)
683
+ );
684
+ };
685
+
686
+ const pluralize = (value: string): string => {
687
+ const lower = value.toLowerCase();
688
+ let lowerForm: string;
689
+ if (endsWithConsonantY(lower)) {
690
+ lowerForm = `${lower.slice(0, -1)}ies`;
691
+ } else if (
692
+ lower.endsWith('s') ||
693
+ lower.endsWith('x') ||
694
+ lower.endsWith('ch')
695
+ ) {
696
+ lowerForm = `${lower}es`;
697
+ } else {
698
+ lowerForm = `${lower}s`;
699
+ }
700
+ return preserveCase(value, lowerForm);
701
+ };
702
+
703
+ const pastTenseForm = (value: string): string => {
704
+ const lower = value.toLowerCase();
705
+ let lowerForm: string;
706
+ if (endsWithConsonantY(lower)) {
707
+ lowerForm = `${lower.slice(0, -1)}ied`;
708
+ } else if (lower.endsWith('e')) {
709
+ lowerForm = `${lower}d`;
710
+ } else {
711
+ lowerForm = `${lower}ed`;
712
+ }
713
+ return preserveCase(value, lowerForm);
714
+ };
715
+
716
+ const presentParticipleForm = (value: string): string => {
717
+ const lower = value.toLowerCase();
718
+ let lowerForm: string;
719
+ if (lower.endsWith('ie')) {
720
+ lowerForm = `${lower.slice(0, -2)}ying`;
721
+ } else if (lower.endsWith('e') && !lower.endsWith('ee')) {
722
+ lowerForm = `${lower.slice(0, -1)}ing`;
723
+ } else {
724
+ lowerForm = `${lower}ing`;
725
+ }
726
+ return preserveCase(value, lowerForm);
727
+ };
728
+
729
+ const defaultDeferredVocabularyForms = (from: string): readonly string[] => {
730
+ if (!isSimpleVocabularyWord(from)) {
731
+ return [];
732
+ }
733
+ return uniqueSorted([
734
+ pastTenseForm(from),
735
+ presentParticipleForm(from),
736
+ ]).filter((form) => form !== from && form !== pluralize(from));
737
+ };
738
+
739
+ const defaultVocabularyForms = (from: string, to: string) => {
740
+ const forms = new Map<string, string>([[from, to]]);
741
+ if (isSimpleVocabularyWord(from) && isSimpleVocabularyWord(to)) {
742
+ forms.set(pluralize(from), pluralize(to));
743
+ }
744
+ return forms;
745
+ };
746
+
747
+ const normalizedOverrideEntries = (
748
+ overrides: Readonly<Record<string, string>> | undefined
749
+ ): readonly [string, string][] =>
750
+ Object.entries(overrides ?? {}).toSorted(([left], [right]) =>
751
+ left.localeCompare(right)
752
+ );
753
+
754
+ const formIdentityForPlan = (
755
+ plan: VocabularyRegradePlan,
756
+ form: string
757
+ ): string => (plan.caseSensitive === true ? form : form.toLowerCase());
758
+
759
+ const targetFormsForPlan = (
760
+ plan: VocabularyRegradePlan
761
+ ): Map<string, string> => {
762
+ const forms = defaultVocabularyForms(plan.from, plan.to);
763
+ for (const [form, replacement] of normalizedOverrideEntries(plan.overrides)) {
764
+ forms.set(form, replacement);
765
+ }
766
+ return forms;
767
+ };
768
+
769
+ export const vocabularyRewriteFormsForPlan = (
770
+ plan: VocabularyRegradePlan
771
+ ): readonly (readonly [string, string])[] => [...targetFormsForPlan(plan)];
772
+
773
+ const deferFormsForPlan = (plan: VocabularyRegradePlan): readonly string[] => {
774
+ const overrideForms = new Set(
775
+ normalizedOverrideEntries(plan.overrides).map(([form]) =>
776
+ formIdentityForPlan(plan, form)
777
+ )
778
+ );
779
+ return uniqueSorted([
780
+ ...defaultDeferredVocabularyForms(plan.from).filter(
781
+ (form) => !overrideForms.has(formIdentityForPlan(plan, form))
782
+ ),
783
+ ...(plan.deferForms ?? []),
784
+ ]);
785
+ };
786
+
787
+ const titleCaseVocabularyForm = (form: string): string => {
788
+ const first = form.at(0);
789
+ return first === undefined ? form : `${first.toUpperCase()}${form.slice(1)}`;
790
+ };
791
+
792
+ const commentReviewPlan = (
793
+ plan: VocabularyRegradePlan
794
+ ): VocabularyRegradePlan => ({
795
+ ...plan,
796
+ deferForms: uniqueSorted([
797
+ ...(plan.deferForms ?? []),
798
+ ...(plan.deferForms ?? []).map(titleCaseVocabularyForm),
799
+ ]),
800
+ });
801
+
802
+ /**
803
+ * Synthesize the deterministic form proposal carried by a minimal plan seed.
804
+ *
805
+ * @example
806
+ * ```ts
807
+ * const forms = deriveVocabularyFormProposals({
808
+ * from: 'legacy',
809
+ * kind: 'vocabulary',
810
+ * to: 'current',
811
+ * });
812
+ * ```
813
+ */
814
+ export const deriveVocabularyFormProposals = (
815
+ plan: VocabularyRegradePlan
816
+ ): readonly VocabularyFormProposal[] => {
817
+ const overrideForms = new Set(Object.keys(plan.overrides ?? {}));
818
+ const explicitDefers = new Set(
819
+ (plan.deferForms ?? []).map((form) => formIdentityForPlan(plan, form))
820
+ );
821
+ const safeProposals = [...targetFormsForPlan(plan).entries()].flatMap(
822
+ ([from, to]): readonly VocabularyFormProposal[] => {
823
+ if (explicitDefers.has(formIdentityForPlan(plan, from))) {
824
+ return [];
825
+ }
826
+ if (from === plan.from) {
827
+ return [
828
+ {
829
+ from,
830
+ kind: 'safe-rewrite',
831
+ reason: 'minimal-seed',
832
+ source: 'seed',
833
+ to,
834
+ },
835
+ ];
836
+ }
837
+ if (overrideForms.has(from)) {
838
+ return [
839
+ {
840
+ from,
841
+ kind: 'safe-rewrite',
842
+ reason: 'authored-or-governed-override',
843
+ source: 'plan-override',
844
+ to,
845
+ },
846
+ ];
847
+ }
848
+ return [
849
+ {
850
+ from,
851
+ kind: 'safe-rewrite',
852
+ reason: 'default-morphology',
853
+ source: 'default-morphology',
854
+ to,
855
+ },
856
+ ];
857
+ }
858
+ );
859
+ const casingProposals: VocabularyFormProposal[] = [];
860
+ if (isSimpleVocabularyWord(plan.from) && isSimpleVocabularyWord(plan.to)) {
861
+ const from = `${plan.from.slice(0, 1).toUpperCase()}${plan.from.slice(1)}`;
862
+ const to = `${plan.to.slice(0, 1).toUpperCase()}${plan.to.slice(1)}`;
863
+ if (from !== plan.from && !explicitDefers.has(from)) {
864
+ casingProposals.push({
865
+ from,
866
+ kind: 'review',
867
+ reason: 'uncertain-casing-or-public-name',
868
+ source: 'default-morphology',
869
+ to,
870
+ });
871
+ }
872
+ }
873
+ return [
874
+ ...safeProposals,
875
+ ...casingProposals,
876
+ ...deferFormsForPlan(plan).map((from) => ({
877
+ from,
878
+ kind: 'review' as const,
879
+ reason: explicitDefers.has(from)
880
+ ? 'authored-or-governed-defer'
881
+ : 'uncertain-morphology',
882
+ source: explicitDefers.has(from)
883
+ ? ('plan-defer' as const)
884
+ : ('default-morphology' as const),
885
+ })),
886
+ ].toSorted((left, right) =>
887
+ left.from === right.from
888
+ ? left.kind.localeCompare(right.kind)
889
+ : left.from.localeCompare(right.from)
890
+ );
891
+ };
892
+
893
+ const validateVocabularyScope = (
894
+ scope: VocabularyRegradeScope | undefined
895
+ ): Result<void, ValidationError> => {
896
+ const excludedDocsPattern = scope?.exclude?.find(
897
+ (pattern) =>
898
+ /(^|\/)docs(?:\/|$)/.test(pattern) ||
899
+ matchesAnyPathGlob('docs/regrade-teaching.md', [pattern]) ||
900
+ matchesAnyPathGlob('docs/regrade-teaching.mdx', [pattern])
901
+ );
902
+ if (excludedDocsPattern !== undefined) {
903
+ return Result.err(
904
+ new ValidationError(
905
+ `Vocabulary Regrade plans cannot hard-exclude docs with "${excludedDocsPattern}"; use a policyClassified rule with a reason.`
906
+ )
907
+ );
908
+ }
909
+ for (const policy of scope?.policyClassified ?? []) {
910
+ if (policy.paths.length === 0 || policy.reason.trim().length === 0) {
911
+ return Result.err(
912
+ new ValidationError(
913
+ 'Vocabulary Regrade policyClassified rules require paths and a reason.'
914
+ )
915
+ );
916
+ }
917
+ const excludedPolicyPath = policy.paths.find((policyPath) =>
918
+ scope?.exclude?.some(
919
+ (excludedPath) =>
920
+ excludedPath === policyPath ||
921
+ matchesAnyPathGlob(policyPath, [excludedPath]) ||
922
+ matchesAnyPathGlob(excludedPath, [policyPath])
923
+ )
924
+ );
925
+ if (excludedPolicyPath !== undefined) {
926
+ return Result.err(
927
+ new ValidationError(
928
+ `Vocabulary Regrade scope cannot both exclude and policy-classify "${excludedPolicyPath}".`
929
+ )
930
+ );
931
+ }
932
+ }
933
+ return Result.ok();
934
+ };
935
+
936
+ const validateVocabularyPlan = (
937
+ plan: VocabularyRegradePlan
938
+ ): Result<void, ValidationError> => {
939
+ if (plan.from.trim().length === 0) {
940
+ return Result.err(
941
+ new ValidationError('Vocabulary Regrade plan `from` cannot be empty.')
942
+ );
943
+ }
944
+ if (plan.to.trim().length === 0) {
945
+ return Result.err(
946
+ new ValidationError('Vocabulary Regrade plan `to` cannot be empty.')
947
+ );
948
+ }
949
+ for (const [form, replacement] of normalizedOverrideEntries(plan.overrides)) {
950
+ if (form.trim().length === 0) {
951
+ return Result.err(
952
+ new ValidationError(
953
+ 'Vocabulary Regrade plan override keys cannot be empty.'
954
+ )
955
+ );
956
+ }
957
+ if (replacement.trim().length === 0) {
958
+ return Result.err(
959
+ new ValidationError(
960
+ `Vocabulary Regrade plan override "${form}" cannot map to an empty replacement.`
961
+ )
962
+ );
963
+ }
964
+ }
965
+ for (const form of deferFormsForPlan(plan)) {
966
+ if (form.trim().length === 0) {
967
+ return Result.err(
968
+ new ValidationError(
969
+ 'Vocabulary Regrade plan deferForms entries cannot be empty.'
970
+ )
971
+ );
972
+ }
973
+ }
974
+ for (const rule of plan.preserve ?? []) {
975
+ if (rule.pattern.trim().length === 0) {
976
+ return Result.err(
977
+ new ValidationError(
978
+ 'Vocabulary Regrade plan preserve patterns cannot be empty.'
979
+ )
980
+ );
981
+ }
982
+ if (
983
+ rule.disposition !== undefined &&
984
+ !vocabularyDispositions.has(rule.disposition)
985
+ ) {
986
+ return Result.err(
987
+ new ValidationError(
988
+ `Vocabulary Regrade plan preserve disposition "${rule.disposition}" is not supported.`
989
+ )
990
+ );
991
+ }
992
+ if (rule.forms?.some((form) => form.trim().length === 0) === true) {
993
+ return Result.err(
994
+ new ValidationError(
995
+ 'Vocabulary Regrade plan preserve forms cannot be empty.'
996
+ )
997
+ );
998
+ }
999
+ }
1000
+ return validateVocabularyScope(plan.scope);
1001
+ };
1002
+
1003
+ const validatePreserveInventory = (
1004
+ inventory: readonly VocabularyPreserveInventoryEntry[] | undefined
1005
+ ): Result<void, ValidationError> => {
1006
+ for (const entry of inventory ?? []) {
1007
+ if (entry.pattern.trim().length === 0) {
1008
+ return Result.err(
1009
+ new ValidationError(
1010
+ 'Vocabulary Regrade preserve inventory patterns cannot be empty.'
1011
+ )
1012
+ );
1013
+ }
1014
+ if (entry.forms?.some((form) => form.trim().length === 0) === true) {
1015
+ return Result.err(
1016
+ new ValidationError(
1017
+ 'Vocabulary Regrade preserve inventory forms cannot be empty.'
1018
+ )
1019
+ );
1020
+ }
1021
+ if (
1022
+ entry.disposition !== undefined &&
1023
+ !vocabularyDispositions.has(entry.disposition)
1024
+ ) {
1025
+ return Result.err(
1026
+ new ValidationError(
1027
+ `Vocabulary Regrade preserve inventory disposition "${entry.disposition}" is not supported.`
1028
+ )
1029
+ );
1030
+ }
1031
+ if (entry.evidence.length === 0) {
1032
+ return Result.err(
1033
+ new ValidationError(
1034
+ 'Vocabulary Regrade preserve inventory entries need evidence.'
1035
+ )
1036
+ );
1037
+ }
1038
+ }
1039
+ return Result.ok();
1040
+ };
1041
+
1042
+ const effectivePlanForRun = (
1043
+ plan: VocabularyRegradePlan,
1044
+ preserveInventory: readonly VocabularyPreserveInventoryEntry[] | undefined
1045
+ ): VocabularyRegradePlan => {
1046
+ if (preserveInventory === undefined || preserveInventory.length === 0) {
1047
+ return plan;
1048
+ }
1049
+
1050
+ return {
1051
+ ...plan,
1052
+ preserve: [...(plan.preserve ?? []), ...preserveInventory],
1053
+ };
1054
+ };
1055
+
1056
+ const vocabularyScanFlags = (plan: VocabularyRegradePlan): string =>
1057
+ plan.caseSensitive === true ? 'g' : 'gi';
1058
+
1059
+ const includedByScope = (
1060
+ path: string,
1061
+ scope: VocabularyRegradeScope | undefined
1062
+ ): boolean =>
1063
+ (scope?.include === undefined ||
1064
+ scope.include.length === 0 ||
1065
+ matchesAnyPathGlob(path, scope.include)) &&
1066
+ !matchesAnyPathGlob(path, scope?.exclude);
1067
+
1068
+ const compilePreservePattern = (pattern: string): RegExp => {
1069
+ try {
1070
+ return new RegExp(pattern);
1071
+ } catch {
1072
+ return new RegExp(escapeRegExp(pattern));
1073
+ }
1074
+ };
1075
+
1076
+ const globalPreservePattern = (pattern: RegExp): RegExp => {
1077
+ const flags = pattern.flags.includes('g')
1078
+ ? pattern.flags
1079
+ : `${pattern.flags}g`;
1080
+ return new RegExp(pattern.source, flags);
1081
+ };
1082
+
1083
+ const patternOverlapsOccurrence = (
1084
+ pattern: RegExp,
1085
+ occurrence: SourceOccurrenceDraft
1086
+ ): boolean => {
1087
+ const occurrenceStart = occurrence.contextColumn - 1;
1088
+ const occurrenceEnd = occurrenceStart + occurrence.form.length;
1089
+
1090
+ for (const match of occurrence.context.matchAll(
1091
+ globalPreservePattern(pattern)
1092
+ )) {
1093
+ const matchStart = match.index ?? 0;
1094
+ const matchEnd = matchStart + match[0].length;
1095
+ if (
1096
+ matchStart !== matchEnd &&
1097
+ occurrenceStart < matchEnd &&
1098
+ matchStart < occurrenceEnd
1099
+ ) {
1100
+ return true;
1101
+ }
1102
+ }
1103
+
1104
+ return false;
1105
+ };
1106
+
1107
+ const preserveRuleForOccurrence = (
1108
+ occurrence: SourceOccurrenceDraft,
1109
+ plan: VocabularyRegradePlan
1110
+ ): VocabularyPreserveRule | undefined =>
1111
+ plan.preserve?.find((rule) => {
1112
+ if (rule.forms !== undefined && !rule.forms.includes(occurrence.form)) {
1113
+ return false;
1114
+ }
1115
+ if (
1116
+ rule.paths !== undefined &&
1117
+ !matchesAnyPathGlob(occurrence.path, rule.paths)
1118
+ ) {
1119
+ return false;
1120
+ }
1121
+ const pattern = compilePreservePattern(rule.pattern);
1122
+ if (
1123
+ pattern.test(occurrence.form) ||
1124
+ patternOverlapsOccurrence(pattern, occurrence)
1125
+ ) {
1126
+ return true;
1127
+ }
1128
+ return rule.forms === undefined && pattern.test(occurrence.context);
1129
+ });
1130
+
1131
+ const scopePolicyForPath = (
1132
+ path: string,
1133
+ scope: VocabularyRegradeScope | undefined
1134
+ ): VocabularyScopePolicy | undefined =>
1135
+ scope?.policyClassified?.find((policy) =>
1136
+ matchesAnyPathGlob(path, policy.paths)
1137
+ );
1138
+
1139
+ const scopePolicyForOccurrence = (
1140
+ occurrence: SourceOccurrenceDraft,
1141
+ plan: VocabularyRegradePlan
1142
+ ): VocabularyScopePolicy | undefined =>
1143
+ scopePolicyForPath(occurrence.path, plan.scope);
1144
+
1145
+ const occurrenceClassification = (
1146
+ occurrence: SourceOccurrenceDraft,
1147
+ plan: VocabularyRegradePlan
1148
+ ): {
1149
+ readonly preserveRule: VocabularyPreserveRule | undefined;
1150
+ readonly scopeTier: VocabularyScopeTier;
1151
+ } => {
1152
+ const policy = scopePolicyForOccurrence(occurrence, plan);
1153
+ const preserveRule = preserveRuleForOccurrence(occurrence, plan);
1154
+ return {
1155
+ preserveRule:
1156
+ preserveRule ??
1157
+ (policy === undefined
1158
+ ? undefined
1159
+ : {
1160
+ disposition: policy.disposition,
1161
+ pattern: occurrence.form,
1162
+ reason: policy.reason,
1163
+ }),
1164
+ scopeTier: policy === undefined ? 'in-scope' : 'policy-classified',
1165
+ };
1166
+ };
1167
+
1168
+ const occurrenceOverlaps = (
1169
+ occurrences: readonly {
1170
+ readonly end: number;
1171
+ readonly start: number;
1172
+ }[],
1173
+ start: number,
1174
+ end: number
1175
+ ): boolean =>
1176
+ occurrences.some(
1177
+ (occurrence) => start < occurrence.end && occurrence.start < end
1178
+ );
1179
+
1180
+ const occurrenceDraftForSpan = (
1181
+ file: SourceFile,
1182
+ start: number,
1183
+ end: number,
1184
+ form = file.source.slice(start, end)
1185
+ ): SourceOccurrenceDraft => {
1186
+ const { column, line } = lineColumnForOffset(file.source, start);
1187
+ const context = contextDetailsForOffset(file.source, start, end);
1188
+ return {
1189
+ absolutePath: file.absolutePath,
1190
+ column,
1191
+ context: context.context,
1192
+ contextColumn: context.contextColumn,
1193
+ end,
1194
+ form,
1195
+ line,
1196
+ path: file.path,
1197
+ start,
1198
+ };
1199
+ };
1200
+
1201
+ const deferredOccurrenceFromDraft = (
1202
+ file: SourceFile,
1203
+ plan: VocabularyRegradePlan,
1204
+ baseOccurrence: SourceOccurrenceDraft,
1205
+ reason = 'unclassified-neighbor'
1206
+ ): SourceOccurrence => {
1207
+ const { preserveRule, scopeTier } = occurrenceClassification(
1208
+ baseOccurrence,
1209
+ plan
1210
+ );
1211
+ const markdownCodeContext = isMarkdownCodeContext(
1212
+ file,
1213
+ baseOccurrence.start,
1214
+ baseOccurrence.end
1215
+ );
1216
+ const verdict = preserveRule === undefined ? 'deferred' : 'skipped';
1217
+ return {
1218
+ absolutePath: baseOccurrence.absolutePath,
1219
+ column: baseOccurrence.column,
1220
+ context: baseOccurrence.context,
1221
+ disposition: vocabularyOccurrenceDisposition(
1222
+ verdict,
1223
+ preserveRule,
1224
+ markdownCodeContext
1225
+ ),
1226
+ end: baseOccurrence.end,
1227
+ form: baseOccurrence.form,
1228
+ line: baseOccurrence.line,
1229
+ path: baseOccurrence.path,
1230
+ reason: vocabularyOccurrenceReason(
1231
+ preserveRule,
1232
+ markdownCodeContext,
1233
+ reason
1234
+ ),
1235
+ scopeTier,
1236
+ start: baseOccurrence.start,
1237
+ verdict,
1238
+ };
1239
+ };
1240
+
1241
+ const exactDeferredFormOccurrencesForFile = (
1242
+ file: SourceFile,
1243
+ plan: VocabularyRegradePlan,
1244
+ deferForms: readonly string[],
1245
+ targetFormSpans: readonly {
1246
+ readonly end: number;
1247
+ readonly start: number;
1248
+ }[]
1249
+ ): readonly SourceOccurrence[] => {
1250
+ const occurrences: SourceOccurrence[] = [];
1251
+ const authoredDeferForms = new Set(
1252
+ (plan.deferForms ?? []).map((form) => formIdentityForPlan(plan, form))
1253
+ );
1254
+ for (const form of deferForms) {
1255
+ const pattern = new RegExp(escapeRegExp(form), vocabularyScanFlags(plan));
1256
+ for (const match of file.source.matchAll(pattern)) {
1257
+ const start = match.index ?? 0;
1258
+ const end = start + match[0].length;
1259
+ const isAuthoredDefer = authoredDeferForms.has(
1260
+ formIdentityForPlan(plan, form)
1261
+ );
1262
+ if (
1263
+ !hasWordBoundary(file.source, start, end, form) ||
1264
+ occurrenceOverlaps(occurrences, start, end) ||
1265
+ (!isAuthoredDefer && occurrenceOverlaps(targetFormSpans, start, end))
1266
+ ) {
1267
+ continue;
1268
+ }
1269
+ occurrences.push(
1270
+ deferredOccurrenceFromDraft(
1271
+ file,
1272
+ plan,
1273
+ occurrenceDraftForSpan(file, start, end, match[0]),
1274
+ 'deferred-form'
1275
+ )
1276
+ );
1277
+ }
1278
+ }
1279
+ return occurrences;
1280
+ };
1281
+
1282
+ const targetFormSpansForFile = (
1283
+ file: SourceFile,
1284
+ plan: VocabularyRegradePlan,
1285
+ targetForms: Map<string, string>
1286
+ ): readonly {
1287
+ readonly end: number;
1288
+ readonly start: number;
1289
+ }[] => {
1290
+ const spans: { end: number; start: number }[] = [];
1291
+ for (const form of targetForms.keys()) {
1292
+ const pattern = new RegExp(escapeRegExp(form), vocabularyScanFlags(plan));
1293
+ for (const match of file.source.matchAll(pattern)) {
1294
+ const start = match.index ?? 0;
1295
+ const end = start + match[0].length;
1296
+ if (
1297
+ !hasWordBoundary(file.source, start, end, form) ||
1298
+ occurrenceOverlaps(spans, start, end)
1299
+ ) {
1300
+ continue;
1301
+ }
1302
+ spans.push({ end, start });
1303
+ }
1304
+ }
1305
+ return spans;
1306
+ };
1307
+
1308
+ const occurrencesForFile = (
1309
+ file: SourceFile,
1310
+ plan: VocabularyRegradePlan,
1311
+ targetForms: Map<string, string>,
1312
+ deferredOccurrences: readonly SourceOccurrence[]
1313
+ ): readonly SourceOccurrence[] => {
1314
+ const occurrences: SourceOccurrence[] = [];
1315
+ const candidates: SourceOccurrence[] = [];
1316
+ const forms = [...targetForms.entries()].toSorted(
1317
+ ([left], [right]) => right.length - left.length || left.localeCompare(right)
1318
+ );
1319
+
1320
+ for (const [form, replacement] of forms) {
1321
+ const pattern = new RegExp(escapeRegExp(form), vocabularyScanFlags(plan));
1322
+ for (const match of file.source.matchAll(pattern)) {
1323
+ const start = match.index ?? 0;
1324
+ const end = start + match[0].length;
1325
+ if (
1326
+ !hasWordBoundary(file.source, start, end, form) ||
1327
+ occurrenceOverlaps(deferredOccurrences, start, end)
1328
+ ) {
1329
+ continue;
1330
+ }
1331
+ const { column, line } = lineColumnForOffset(file.source, start);
1332
+ const context = contextDetailsForOffset(file.source, start, end);
1333
+ const baseOccurrence = {
1334
+ absolutePath: file.absolutePath,
1335
+ column,
1336
+ context: context.context,
1337
+ contextColumn: context.contextColumn,
1338
+ end,
1339
+ form: match[0],
1340
+ line,
1341
+ path: file.path,
1342
+ start,
1343
+ };
1344
+ const { preserveRule, scopeTier } = occurrenceClassification(
1345
+ baseOccurrence,
1346
+ plan
1347
+ );
1348
+ const markdownCodeContext = isMarkdownCodeContext(file, start, end);
1349
+ const packageRouteCodeContext = isPackageRouteCodeOccurrence(
1350
+ file.path,
1351
+ form
1352
+ );
1353
+ let verdict = capturedVocabularyVerdict(
1354
+ preserveRule,
1355
+ markdownCodeContext
1356
+ );
1357
+ const packageManifestContext = isPackageManifestPath(file.path);
1358
+ if (
1359
+ preserveRule === undefined &&
1360
+ (packageRouteCodeContext || packageManifestContext)
1361
+ ) {
1362
+ verdict = 'deferred';
1363
+ }
1364
+ let capturedReason = 'captured-form';
1365
+ if (packageRouteCodeContext) {
1366
+ capturedReason = 'package-route-ast-required';
1367
+ } else if (packageManifestContext) {
1368
+ capturedReason = 'package-manifest-structured-edit-required';
1369
+ }
1370
+ candidates.push({
1371
+ absolutePath: baseOccurrence.absolutePath,
1372
+ column: baseOccurrence.column,
1373
+ context: baseOccurrence.context,
1374
+ disposition: vocabularyOccurrenceDisposition(
1375
+ verdict,
1376
+ preserveRule,
1377
+ markdownCodeContext
1378
+ ),
1379
+ end: baseOccurrence.end,
1380
+ form: baseOccurrence.form,
1381
+ line: baseOccurrence.line,
1382
+ path: baseOccurrence.path,
1383
+ reason: vocabularyOccurrenceReason(
1384
+ preserveRule,
1385
+ markdownCodeContext,
1386
+ capturedReason
1387
+ ),
1388
+ scopeTier,
1389
+ ...(preserveRule === undefined &&
1390
+ !markdownCodeContext &&
1391
+ !packageRouteCodeContext &&
1392
+ !packageManifestContext
1393
+ ? { replacement: preserveCase(match[0], replacement) }
1394
+ : {}),
1395
+ start: baseOccurrence.start,
1396
+ verdict,
1397
+ });
1398
+ }
1399
+ }
1400
+
1401
+ for (const candidate of candidates.toSorted(
1402
+ (left, right) =>
1403
+ right.end - right.start - (left.end - left.start) ||
1404
+ left.start - right.start
1405
+ )) {
1406
+ const overlaps = occurrences.some(
1407
+ (occurrence) =>
1408
+ candidate.start < occurrence.end && occurrence.start < candidate.end
1409
+ );
1410
+ if (!overlaps) {
1411
+ occurrences.push(candidate);
1412
+ }
1413
+ }
1414
+
1415
+ return occurrences.toSorted((left, right) =>
1416
+ left.path === right.path
1417
+ ? left.start - right.start
1418
+ : left.path.localeCompare(right.path)
1419
+ );
1420
+ };
1421
+
1422
+ const deferredOccurrencesForFile = (
1423
+ file: SourceFile,
1424
+ plan: VocabularyRegradePlan,
1425
+ targetForms: Map<string, string>
1426
+ ): readonly SourceOccurrence[] => {
1427
+ const deferForms = deferFormsForPlan(plan);
1428
+ const targetFormSpans = targetFormSpansForFile(file, plan, targetForms);
1429
+ const knownForms = new Set(
1430
+ plan.caseSensitive === true
1431
+ ? [...targetForms.keys(), ...deferForms]
1432
+ : [...targetForms.keys(), ...deferForms].flatMap((form) => [
1433
+ form,
1434
+ form.toLowerCase(),
1435
+ ])
1436
+ );
1437
+ const lowerFrom = plan.from.toLowerCase();
1438
+ const tokenPattern = /[A-Za-z_$][A-Za-z0-9_$-]*/g;
1439
+ const occurrences = [
1440
+ ...exactDeferredFormOccurrencesForFile(
1441
+ file,
1442
+ plan,
1443
+ deferForms,
1444
+ targetFormSpans
1445
+ ),
1446
+ ];
1447
+
1448
+ for (const form of targetForms.keys()) {
1449
+ const pattern = new RegExp(escapeRegExp(form), vocabularyScanFlags(plan));
1450
+ for (const match of file.source.matchAll(pattern)) {
1451
+ const matchStart = match.index ?? 0;
1452
+ const matchEnd = matchStart + match[0].length;
1453
+ if (hasWordBoundary(file.source, matchStart, matchEnd, form)) {
1454
+ continue;
1455
+ }
1456
+ const { end, start } = expandVocabularyNeighborSpan(
1457
+ file.source,
1458
+ matchStart,
1459
+ matchEnd,
1460
+ form
1461
+ );
1462
+ const matchedForm = file.source.slice(start, end);
1463
+ const lowerMatchedForm = matchedForm.toLowerCase();
1464
+ if (
1465
+ occurrenceOverlaps(occurrences, start, end) ||
1466
+ knownForms.has(matchedForm) ||
1467
+ (plan.caseSensitive !== true && knownForms.has(lowerMatchedForm)) ||
1468
+ !lowerMatchedForm.includes(lowerFrom)
1469
+ ) {
1470
+ continue;
1471
+ }
1472
+ occurrences.push(
1473
+ deferredOccurrenceFromDraft(
1474
+ file,
1475
+ plan,
1476
+ occurrenceDraftForSpan(file, start, end, matchedForm)
1477
+ )
1478
+ );
1479
+ }
1480
+ }
1481
+
1482
+ for (const match of file.source.matchAll(tokenPattern)) {
1483
+ const [form] = match;
1484
+ const lower = form.toLowerCase();
1485
+ if (
1486
+ knownForms.has(form) ||
1487
+ (plan.caseSensitive !== true && knownForms.has(lower))
1488
+ ) {
1489
+ continue;
1490
+ }
1491
+ if (!lower.includes(lowerFrom)) {
1492
+ continue;
1493
+ }
1494
+ const start = match.index ?? 0;
1495
+ const end = start + form.length;
1496
+ if (occurrenceOverlaps(occurrences, start, end)) {
1497
+ continue;
1498
+ }
1499
+ occurrences.push(
1500
+ deferredOccurrenceFromDraft(
1501
+ file,
1502
+ plan,
1503
+ occurrenceDraftForSpan(file, start, end, form)
1504
+ )
1505
+ );
1506
+ }
1507
+ return occurrences.toSorted((left, right) =>
1508
+ left.path === right.path
1509
+ ? left.start - right.start
1510
+ : left.path.localeCompare(right.path)
1511
+ );
1512
+ };
1513
+
1514
+ const entryForOccurrences = (
1515
+ path: string,
1516
+ occurrences: readonly SourceOccurrence[]
1517
+ ): RegradeReportEntry | null => {
1518
+ if (occurrences.length === 0) {
1519
+ return null;
1520
+ }
1521
+ const hasDeferred = occurrences.some(
1522
+ (occurrence) => occurrence.verdict === 'deferred'
1523
+ );
1524
+ const hasModified = occurrences.some(
1525
+ (occurrence) => occurrence.verdict === 'modified'
1526
+ );
1527
+ if (hasDeferred) {
1528
+ return {
1529
+ notes: [
1530
+ `Found ${occurrences.length} vocabulary occurrence(s); judgment deferred.`,
1531
+ ],
1532
+ outcome: 'needs-review',
1533
+ path,
1534
+ reason: 'vocabulary-judgment-deferred',
1535
+ reviewDetails: occurrences
1536
+ .filter((occurrence) => occurrence.verdict === 'deferred')
1537
+ .map((occurrence) => ({
1538
+ context: occurrence.context,
1539
+ expectedTarget:
1540
+ 'Add an override or preserve rule to the regrade plan.',
1541
+ judgment: 'unresolved' as const,
1542
+ matchedForm: occurrence.form,
1543
+ ...(occurrence.sourceKind === undefined
1544
+ ? {}
1545
+ : {
1546
+ nodeKind:
1547
+ occurrence.sourceKind === 'tsdoc'
1548
+ ? 'TSDocComment'
1549
+ : 'SourceComment',
1550
+ }),
1551
+ reason: occurrence.reason,
1552
+ span: {
1553
+ column: occurrence.column,
1554
+ end: occurrence.end,
1555
+ line: occurrence.line,
1556
+ start: occurrence.start,
1557
+ },
1558
+ symbol: occurrence.form,
1559
+ })),
1560
+ };
1561
+ }
1562
+ if (hasModified) {
1563
+ return {
1564
+ notes: [
1565
+ `Found ${occurrences.length} vocabulary occurrence(s); safe modifications available.`,
1566
+ ],
1567
+ outcome: 'rewrite',
1568
+ path,
1569
+ };
1570
+ }
1571
+ return {
1572
+ notes: [`Skipped ${occurrences.length} vocabulary occurrence(s).`],
1573
+ outcome: 'no-op',
1574
+ path,
1575
+ };
1576
+ };
1577
+
1578
+ const applyOccurrenceRewrites = (
1579
+ file: SourceFile,
1580
+ occurrences: readonly SourceOccurrence[]
1581
+ ): string => {
1582
+ let nextSource = file.source;
1583
+ for (const occurrence of occurrences.toReversed()) {
1584
+ if (
1585
+ occurrence.verdict !== 'modified' ||
1586
+ occurrence.replacement === undefined
1587
+ ) {
1588
+ continue;
1589
+ }
1590
+ nextSource =
1591
+ nextSource.slice(0, occurrence.start) +
1592
+ occurrence.replacement +
1593
+ nextSource.slice(occurrence.end);
1594
+ }
1595
+ return nextSource;
1596
+ };
1597
+
1598
+ const buildVocabularyEvaluation = (params: {
1599
+ readonly apply?: boolean;
1600
+ readonly effectivePlan?: VocabularyRegradePlan | undefined;
1601
+ readonly files: readonly SourceFile[];
1602
+ readonly plan: VocabularyRegradePlan;
1603
+ readonly preserveInventory?: readonly VocabularyPreserveInventoryEntry[];
1604
+ readonly root: string;
1605
+ readonly skipped: readonly SkippedSource[];
1606
+ readonly sourceKindForPath?:
1607
+ | ((path: string) => VocabularySourceKind)
1608
+ | undefined;
1609
+ }): VocabularyEvaluation => {
1610
+ const effectivePlan = params.effectivePlan ?? params.plan;
1611
+ const targetForms = targetFormsForPlan(effectivePlan);
1612
+ const scopedFiles = params.files.filter((file) =>
1613
+ includedByScope(file.path, effectivePlan.scope)
1614
+ );
1615
+ const scopeSkipped: SkippedSource[] = params.files
1616
+ .filter((file) => !includedByScope(file.path, effectivePlan.scope))
1617
+ .map((file) => ({ path: file.path, reason: 'excluded-by-regrade-scope' }));
1618
+ const commentParseSkipped: SkippedSource[] = [];
1619
+ const occurrences = scopedFiles.flatMap((file) => {
1620
+ const sourceKind = params.sourceKindForPath?.(file.path) ?? 'all';
1621
+ const scanPlan =
1622
+ sourceKind === 'comments'
1623
+ ? commentReviewPlan(effectivePlan)
1624
+ : effectivePlan;
1625
+ const deferredOccurrences = deferredOccurrencesForFile(
1626
+ file,
1627
+ scanPlan,
1628
+ targetForms
1629
+ );
1630
+ const fileOccurrences = [
1631
+ ...occurrencesForFile(file, scanPlan, targetForms, deferredOccurrences),
1632
+ ...deferredOccurrences,
1633
+ ];
1634
+ if (sourceKind === 'all') {
1635
+ return fileOccurrences;
1636
+ }
1637
+ if (fileOccurrences.length === 0) {
1638
+ return [];
1639
+ }
1640
+ const parsed = parseWithDiagnostics(file.path, file.source);
1641
+ if (parsed.diagnostics.length > 0 || parsed.ast === null) {
1642
+ commentParseSkipped.push({
1643
+ path: file.path,
1644
+ reason: 'source-comment-parse-diagnostics',
1645
+ });
1646
+ return [];
1647
+ }
1648
+ return fileOccurrences.flatMap((occurrence) => {
1649
+ const comment = parsed.comments.find(
1650
+ (candidate) =>
1651
+ candidate.start <= occurrence.start && occurrence.end <= candidate.end
1652
+ );
1653
+ if (comment === undefined) {
1654
+ return [];
1655
+ }
1656
+ const sourceKindForOccurrence = sourceCommentKind(file, comment);
1657
+ if (occurrence.verdict === 'skipped') {
1658
+ return [{ ...occurrence, sourceKind: sourceKindForOccurrence }];
1659
+ }
1660
+ const { replacement: _replacement, ...reviewOccurrence } = occurrence;
1661
+ return [
1662
+ {
1663
+ ...reviewOccurrence,
1664
+ disposition: 'in-family-unresolved' as const,
1665
+ reason: 'source-comment-requires-review',
1666
+ sourceKind: sourceKindForOccurrence,
1667
+ verdict: 'deferred' as const,
1668
+ },
1669
+ ];
1670
+ });
1671
+ });
1672
+ const occurrencesByPath = new Map<string, SourceOccurrence[]>();
1673
+ for (const occurrence of occurrences) {
1674
+ const existing = occurrencesByPath.get(occurrence.path) ?? [];
1675
+ occurrencesByPath.set(occurrence.path, [...existing, occurrence]);
1676
+ }
1677
+ const entries = [...occurrencesByPath.entries()]
1678
+ .flatMap(([path, pathOccurrences]) => {
1679
+ const entry = entryForOccurrences(path, pathOccurrences);
1680
+ return entry === null ? [] : [entry];
1681
+ })
1682
+ .toSorted((left, right) => left.path.localeCompare(right.path));
1683
+ const rewrittenPaths = new Set(
1684
+ occurrences
1685
+ .filter((occurrence) => occurrence.verdict === 'modified')
1686
+ .map((occurrence) => occurrence.path)
1687
+ );
1688
+ const deferredForms = uniqueSorted(
1689
+ occurrences
1690
+ .filter((occurrence) => occurrence.verdict === 'deferred')
1691
+ .map((occurrence) => occurrence.form)
1692
+ );
1693
+ const modifiedOccurrences = occurrences.filter(
1694
+ (occurrence) => occurrence.verdict === 'modified'
1695
+ );
1696
+ const skippedOccurrences = occurrences.filter(
1697
+ (occurrence) => occurrence.verdict === 'skipped'
1698
+ );
1699
+ const deferredOccurrences = occurrences.filter(
1700
+ (occurrence) => occurrence.verdict === 'deferred'
1701
+ );
1702
+ const unresolvedOccurrences = occurrences.filter(
1703
+ (occurrence) =>
1704
+ occurrence.verdict === 'modified' || occurrence.verdict === 'deferred'
1705
+ );
1706
+ const forms = vocabularyFormVerdicts(occurrences);
1707
+ const gateReasons: string[] = [];
1708
+ if (modifiedOccurrences.length > 0) {
1709
+ gateReasons.push(
1710
+ params.apply === true
1711
+ ? 'source-forms-remain-after-apply'
1712
+ : 'safe-modifications-not-yet-applied'
1713
+ );
1714
+ }
1715
+ if (deferredForms.length > 0) {
1716
+ gateReasons.push('deferred-forms-or-occurrences');
1717
+ }
1718
+ if (commentParseSkipped.length > 0) {
1719
+ gateReasons.push('source-comment-parse-diagnostics');
1720
+ }
1721
+ const open = unresolvedOccurrences.length;
1722
+ const scopeEvidence = vocabularyScopeEvidence(effectivePlan, occurrences);
1723
+ gateReasons.push(...scopeEvidence.gateReasons);
1724
+
1725
+ return {
1726
+ entries: [
1727
+ ...entries,
1728
+ ...params.skipped.map((entry) => ({
1729
+ outcome: 'skip' as const,
1730
+ path: entry.path,
1731
+ reason: entry.reason,
1732
+ })),
1733
+ ...scopeSkipped.map((entry) => ({
1734
+ outcome: 'skip' as const,
1735
+ path: entry.path,
1736
+ reason: entry.reason,
1737
+ })),
1738
+ ...commentParseSkipped.map((entry) => ({
1739
+ outcome: 'skip' as const,
1740
+ path: entry.path,
1741
+ reason: entry.reason,
1742
+ })),
1743
+ ].toSorted((left, right) => left.path.localeCompare(right.path)),
1744
+ occurrences,
1745
+ run: {
1746
+ ledger: {
1747
+ cycle: 1,
1748
+ forms,
1749
+ occurrences: occurrences.map(
1750
+ ({ absolutePath: _absolutePath, ...occurrence }) => occurrence
1751
+ ),
1752
+ },
1753
+ plan: params.plan,
1754
+ ...(params.preserveInventory === undefined ||
1755
+ params.preserveInventory.length === 0
1756
+ ? {}
1757
+ : { preserveInventory: params.preserveInventory }),
1758
+ report: {
1759
+ applied: params.apply === true ? modifiedOccurrences.length : 0,
1760
+ deferred: deferredOccurrences.length,
1761
+ dispositions: vocabularyDispositionCounts(occurrences),
1762
+ filesChanged: params.apply === true ? rewrittenPaths.size : 0,
1763
+ gate: {
1764
+ reasons: gateReasons,
1765
+ remaining: open,
1766
+ remainingByDisposition: vocabularyDispositionCounts(
1767
+ unresolvedOccurrences
1768
+ ),
1769
+ status: gateReasons.length === 0 ? 'green' : 'open',
1770
+ },
1771
+ modified: modifiedOccurrences.length,
1772
+ open,
1773
+ scopeTiers: scopeEvidence.scopeTiers,
1774
+ skipped: skippedOccurrences.length,
1775
+ teachingSurfaces: scopeEvidence.teachingSurfaces,
1776
+ },
1777
+ },
1778
+ scanned: scopedFiles.length,
1779
+ skipped: [...params.skipped, ...scopeSkipped, ...commentParseSkipped],
1780
+ };
1781
+ };
1782
+
1783
+ const skippedByReason = (
1784
+ skipped: readonly SkippedSource[]
1785
+ ): Readonly<Record<string, number>> => {
1786
+ const counts = new Map<string, number>();
1787
+ for (const entry of skipped) {
1788
+ counts.set(entry.reason, (counts.get(entry.reason) ?? 0) + 1);
1789
+ }
1790
+ return Object.fromEntries(
1791
+ [...counts.entries()].toSorted(([left], [right]) =>
1792
+ left.localeCompare(right)
1793
+ )
1794
+ );
1795
+ };
1796
+
1797
+ const withScannedPaths = (
1798
+ report: RegradeReport,
1799
+ paths: readonly string[]
1800
+ ): RegradeReport => {
1801
+ Object.defineProperty(report, 'scannedPaths', {
1802
+ configurable: false,
1803
+ enumerable: false,
1804
+ value: Object.freeze([...paths]),
1805
+ writable: false,
1806
+ });
1807
+ return report;
1808
+ };
1809
+
1810
+ const cloneVocabularyReport = (report: RegradeReport): RegradeReport => {
1811
+ const clone = structuredClone(report);
1812
+ return report.scannedPaths === undefined
1813
+ ? clone
1814
+ : withScannedPaths(clone, report.scannedPaths);
1815
+ };
1816
+
1817
+ const withApplySummary = (
1818
+ report: RegradeReport,
1819
+ apply: RegradeApplySummary
1820
+ ): RegradeReport =>
1821
+ withScannedPaths(
1822
+ {
1823
+ ...report,
1824
+ apply,
1825
+ },
1826
+ report.scannedPaths ?? []
1827
+ );
1828
+
1829
+ const appliedVocabularyRunReport = (
1830
+ postApply: VocabularyRegradeRun,
1831
+ occurrences: readonly VocabularyOccurrence[],
1832
+ apply: RegradeApplySummary
1833
+ ): VocabularyRunReport => {
1834
+ const scopeEvidence = vocabularyScopeEvidence(postApply.plan, occurrences);
1835
+ const reasons = uniqueSorted([
1836
+ ...postApply.report.gate.reasons.filter(
1837
+ (reason) =>
1838
+ reason !== 'expected-policy-classified-evidence-missing' &&
1839
+ reason !== 'expected-teaching-surfaces-missing'
1840
+ ),
1841
+ ...scopeEvidence.gateReasons,
1842
+ ]);
1843
+ return {
1844
+ ...postApply.report,
1845
+ applied: apply.applied,
1846
+ dispositions: vocabularyDispositionCounts(occurrences),
1847
+ filesChanged: apply.filesChanged,
1848
+ gate: {
1849
+ ...postApply.report.gate,
1850
+ reasons,
1851
+ status: reasons.length === 0 ? 'green' : 'open',
1852
+ },
1853
+ scopeTiers: scopeEvidence.scopeTiers,
1854
+ teachingSurfaces: scopeEvidence.teachingSurfaces,
1855
+ };
1856
+ };
1857
+
1858
+ const vocabularyRunWithAppliedOccurrences = (
1859
+ postApply: VocabularyRegradeRun,
1860
+ dryRun: VocabularyRegradeRun,
1861
+ apply: RegradeApplySummary
1862
+ ): VocabularyRegradeRun => {
1863
+ const appliedOccurrences = dryRun.ledger.occurrences
1864
+ .filter((occurrence) => occurrence.verdict === 'modified')
1865
+ .map((occurrence) => ({ ...occurrence, verdict: 'applied' as const }));
1866
+ const occurrences = [
1867
+ ...postApply.ledger.occurrences,
1868
+ ...appliedOccurrences,
1869
+ ].toSorted(
1870
+ (left, right) =>
1871
+ left.path.localeCompare(right.path) ||
1872
+ left.start - right.start ||
1873
+ left.verdict.localeCompare(right.verdict)
1874
+ );
1875
+ return {
1876
+ ...postApply,
1877
+ ledger: {
1878
+ ...postApply.ledger,
1879
+ forms: vocabularyFormVerdicts(occurrences),
1880
+ occurrences,
1881
+ },
1882
+ report: appliedVocabularyRunReport(postApply, occurrences, apply),
1883
+ };
1884
+ };
1885
+
1886
+ const applyVocabularyEvaluation = (
1887
+ files: readonly SourceFile[],
1888
+ evaluation: VocabularyEvaluation
1889
+ ): Result<RegradeApplySummary, InternalError> => {
1890
+ const changedFiles = new Set<string>();
1891
+ let applied = 0;
1892
+ for (const file of files) {
1893
+ const fileOccurrences = evaluation.occurrences.filter(
1894
+ (occurrence) =>
1895
+ occurrence.path === file.path && occurrence.verdict === 'modified'
1896
+ );
1897
+ if (fileOccurrences.length === 0) {
1898
+ continue;
1899
+ }
1900
+ const nextSource = applyOccurrenceRewrites(file, fileOccurrences);
1901
+ if (nextSource === file.source) {
1902
+ continue;
1903
+ }
1904
+ try {
1905
+ writeFileSync(file.absolutePath, nextSource, 'utf8');
1906
+ } catch (error: unknown) {
1907
+ return Result.err(
1908
+ new InternalError(
1909
+ `Failed to apply vocabulary regrade rewrite for "${file.path}".`,
1910
+ {
1911
+ cause: error instanceof Error ? error : new Error(String(error)),
1912
+ context: {
1913
+ applied,
1914
+ filesChanged: changedFiles.size,
1915
+ path: file.path,
1916
+ },
1917
+ }
1918
+ )
1919
+ );
1920
+ }
1921
+ applied += fileOccurrences.length;
1922
+ changedFiles.add(file.path);
1923
+ }
1924
+
1925
+ const reviewFiles = new Set(
1926
+ evaluation.occurrences
1927
+ .filter((occurrence) => occurrence.verdict === 'deferred')
1928
+ .map((occurrence) => occurrence.path)
1929
+ );
1930
+ const skippedOccurrences = evaluation.occurrences.filter(
1931
+ (occurrence) => occurrence.verdict === 'skipped'
1932
+ );
1933
+
1934
+ return Result.ok({
1935
+ applied,
1936
+ filesChanged: changedFiles.size,
1937
+ review: reviewFiles.size,
1938
+ skipped: skippedOccurrences.length + evaluation.skipped.length,
1939
+ unknown: 0,
1940
+ });
1941
+ };
1942
+
1943
+ const readVocabularySourceFiles = (
1944
+ collected: NonNullable<ReturnType<typeof collectDownstreamSources>>
1945
+ ): {
1946
+ readonly files: readonly SourceFile[];
1947
+ readonly skipped: readonly SkippedSource[];
1948
+ } => {
1949
+ const files: SourceFile[] = [];
1950
+ const skipped: SkippedSource[] = [...collected.skipped];
1951
+ for (const file of collected.files) {
1952
+ try {
1953
+ const bytes = readFileSync(file.absolutePath);
1954
+ files.push({
1955
+ absolutePath: file.absolutePath,
1956
+ path: file.path,
1957
+ source: bytes.toString('utf8'),
1958
+ sourceBytes: bytes.toString('base64'),
1959
+ });
1960
+ } catch {
1961
+ skipped.push({ path: file.path, reason: 'unreadable-file' });
1962
+ }
1963
+ }
1964
+ return { files, skipped };
1965
+ };
1966
+
1967
+ const buildRunVocabularyEvaluation = (params: {
1968
+ readonly apply: boolean;
1969
+ readonly effectivePlan: VocabularyRegradePlan;
1970
+ readonly files: readonly SourceFile[];
1971
+ readonly plan: VocabularyRegradePlan;
1972
+ readonly preserveInventory:
1973
+ | readonly VocabularyPreserveInventoryEntry[]
1974
+ | undefined;
1975
+ readonly root: string;
1976
+ readonly skipped: readonly SkippedSource[];
1977
+ readonly sourceKindForPath?:
1978
+ | ((path: string) => VocabularySourceKind)
1979
+ | undefined;
1980
+ }): VocabularyEvaluation =>
1981
+ buildVocabularyEvaluation({
1982
+ apply: params.apply,
1983
+ effectivePlan: params.effectivePlan,
1984
+ files: params.files,
1985
+ plan: params.plan,
1986
+ ...(params.preserveInventory === undefined
1987
+ ? {}
1988
+ : { preserveInventory: params.preserveInventory }),
1989
+ root: params.root,
1990
+ skipped: params.skipped,
1991
+ sourceKindForPath: params.sourceKindForPath,
1992
+ });
1993
+
1994
+ const ignoredDirectoriesOpenedByPolicy = (
1995
+ scope: VocabularyRegradeScope | undefined
1996
+ ): readonly string[] =>
1997
+ (scope?.ignoredDirectories ?? DEFAULT_IGNORED_DIRECTORIES).filter(
1998
+ (directory) =>
1999
+ scope?.policyClassified?.some((policy) =>
2000
+ policy.paths.some((pattern) => pattern.split('/').includes(directory))
2001
+ )
2002
+ );
2003
+
2004
+ const vocabularyIgnoredDirectories = (
2005
+ scope: VocabularyRegradeScope | undefined
2006
+ ): readonly string[] => {
2007
+ const opened = ignoredDirectoriesOpenedByPolicy(scope);
2008
+ return (scope?.ignoredDirectories ?? DEFAULT_IGNORED_DIRECTORIES).filter(
2009
+ (directory) => !opened.includes(directory)
2010
+ );
2011
+ };
2012
+
2013
+ const filterVocabularyCollection = (
2014
+ files: readonly { readonly absolutePath: string; readonly path: string }[],
2015
+ scope: VocabularyRegradeScope | undefined,
2016
+ sourceFilter: ((path: string) => boolean) | undefined
2017
+ ): {
2018
+ readonly files: readonly {
2019
+ readonly absolutePath: string;
2020
+ readonly path: string;
2021
+ }[];
2022
+ readonly skipped: readonly SkippedSource[];
2023
+ } => {
2024
+ const opened = ignoredDirectoriesOpenedByPolicy(scope);
2025
+ const selected = files.filter((file) => {
2026
+ const openedDirectory = file.path
2027
+ .split('/')
2028
+ .some((segment) => opened.includes(segment));
2029
+ return (
2030
+ (!openedDirectory ||
2031
+ scopePolicyForPath(file.path, scope) !== undefined) &&
2032
+ (sourceFilter?.(file.path) ?? true)
2033
+ );
2034
+ });
2035
+ const selectedPaths = new Set(selected.map((file) => file.path));
2036
+ return {
2037
+ files: selected,
2038
+ skipped: files
2039
+ .filter((file) => !selectedPaths.has(file.path))
2040
+ .map((file) => ({
2041
+ path: file.path,
2042
+ reason:
2043
+ sourceFilter?.(file.path) === false
2044
+ ? 'not-selected-source'
2045
+ : 'ignored-directory',
2046
+ })),
2047
+ };
2048
+ };
2049
+
2050
+ interface VocabularyRunInputs {
2051
+ readonly collectedRoot: string;
2052
+ readonly effectivePlan: VocabularyRegradePlan;
2053
+ readonly files: readonly SourceFile[];
2054
+ readonly skipped: readonly SkippedSource[];
2055
+ }
2056
+
2057
+ interface PrepareVocabularyRegradeRunParams {
2058
+ readonly includeEntries?: 'actionable' | 'all';
2059
+ readonly plan: VocabularyRegradePlan;
2060
+ readonly preserveInventory?: readonly VocabularyPreserveInventoryEntry[];
2061
+ readonly root: string;
2062
+ readonly sourceFilter?: (path: string) => boolean;
2063
+ readonly sourceKindForPath?: (path: string) => VocabularySourceKind;
2064
+ }
2065
+
2066
+ const snapshotPrepareVocabularyRegradeRunParams = (
2067
+ params: PrepareVocabularyRegradeRunParams
2068
+ ): PrepareVocabularyRegradeRunParams => ({
2069
+ ...(params.includeEntries === undefined
2070
+ ? {}
2071
+ : { includeEntries: params.includeEntries }),
2072
+ plan: structuredClone(params.plan),
2073
+ ...(params.preserveInventory === undefined
2074
+ ? {}
2075
+ : { preserveInventory: structuredClone(params.preserveInventory) }),
2076
+ root: params.root,
2077
+ ...(params.sourceFilter === undefined
2078
+ ? {}
2079
+ : { sourceFilter: params.sourceFilter }),
2080
+ ...(params.sourceKindForPath === undefined
2081
+ ? {}
2082
+ : { sourceKindForPath: params.sourceKindForPath }),
2083
+ });
2084
+
2085
+ const collectVocabularyRunInputs = (
2086
+ params: PrepareVocabularyRegradeRunParams
2087
+ ): VocabularyRunInputs | null => {
2088
+ const effectivePlan = effectivePlanForRun(
2089
+ params.plan,
2090
+ params.preserveInventory
2091
+ );
2092
+ const collected = collectDownstreamSources(params.root, {
2093
+ extensions: effectivePlan.scope?.extensions ?? VOCABULARY_SOURCE_EXTENSIONS,
2094
+ ...(effectivePlan.scope?.exclude === undefined
2095
+ ? {}
2096
+ : { exclude: effectivePlan.scope.exclude }),
2097
+ ...(effectivePlan.scope?.include === undefined
2098
+ ? {}
2099
+ : { include: effectivePlan.scope.include }),
2100
+ ignoredDirectories: vocabularyIgnoredDirectories(effectivePlan.scope),
2101
+ } satisfies DownstreamCollectionOptions);
2102
+ if (collected === null) {
2103
+ return null;
2104
+ }
2105
+ const filtered = filterVocabularyCollection(
2106
+ collected.files,
2107
+ effectivePlan.scope,
2108
+ params.sourceFilter
2109
+ );
2110
+ const { files, skipped } = readVocabularySourceFiles({
2111
+ ...collected,
2112
+ files: filtered.files,
2113
+ skipped: [...collected.skipped, ...filtered.skipped],
2114
+ });
2115
+ return { collectedRoot: collected.root, effectivePlan, files, skipped };
2116
+ };
2117
+
2118
+ const vocabularyReportFromEvaluation = (params: {
2119
+ readonly applySummary?: RegradeApplySummary;
2120
+ readonly collectedRoot: string;
2121
+ readonly dryRunEvaluation: VocabularyEvaluation;
2122
+ readonly effectivePlan: VocabularyRegradePlan;
2123
+ readonly entrySelection: 'actionable' | 'all';
2124
+ readonly files: readonly SourceFile[];
2125
+ readonly plan: VocabularyRegradePlan;
2126
+ readonly reportEvaluation: VocabularyEvaluation;
2127
+ readonly skipped: readonly SkippedSource[];
2128
+ }): RegradeReport => {
2129
+ const reportEntries = params.reportEvaluation.entries;
2130
+ const actionableEntries = reportEntries.filter(
2131
+ (entry) => entry.outcome === 'rewrite' || entry.outcome === 'needs-review'
2132
+ );
2133
+ const reportSkippedByReason = skippedByReason(
2134
+ params.reportEvaluation.skipped
2135
+ );
2136
+ const report: RegradeReport = withScannedPaths(
2137
+ {
2138
+ entries:
2139
+ params.entrySelection === 'all' ? reportEntries : actionableEntries,
2140
+ matched: actionableEntries.length,
2141
+ review: reportEntries.filter((entry) => entry.outcome === 'needs-review')
2142
+ .length,
2143
+ rewritten: reportEntries.filter((entry) => entry.outcome === 'rewrite')
2144
+ .length,
2145
+ root: params.collectedRoot,
2146
+ run:
2147
+ params.applySummary === undefined
2148
+ ? params.reportEvaluation.run
2149
+ : vocabularyRunWithAppliedOccurrences(
2150
+ params.reportEvaluation.run,
2151
+ params.dryRunEvaluation.run,
2152
+ params.applySummary
2153
+ ),
2154
+ scan: buildRegradeScanSummary({
2155
+ matchedPaths: actionableEntries.map((entry) => entry.path),
2156
+ occurrencePaths: params.reportEvaluation.occurrences.map(
2157
+ (occurrence) => occurrence.path
2158
+ ),
2159
+ scanned: params.reportEvaluation.scanned,
2160
+ skipped: params.reportEvaluation.skipped.length,
2161
+ skippedByReason: reportSkippedByReason,
2162
+ }),
2163
+ scanned: params.reportEvaluation.scanned,
2164
+ selectedClassIds: [
2165
+ params.plan.id ?? `vocabulary:${params.plan.from}->${params.plan.to}`,
2166
+ ],
2167
+ skipped: params.reportEvaluation.skipped.length,
2168
+ skipsByReason: reportSkippedByReason,
2169
+ unknownClassIds: [],
2170
+ },
2171
+ params.files
2172
+ .filter((file) => includedByScope(file.path, params.effectivePlan.scope))
2173
+ .map((file) => file.path)
2174
+ );
2175
+ return params.applySummary === undefined
2176
+ ? report
2177
+ : withApplySummary(report, params.applySummary);
2178
+ };
2179
+
2180
+ /**
2181
+ * In-memory vocabulary evaluation ready for freshness-checked apply.
2182
+ *
2183
+ * @example
2184
+ * ```ts
2185
+ * const prepared = prepareVocabularyRegradeRun({ identity, plan, root });
2186
+ * if (prepared.isOk() && prepared.value !== null) {
2187
+ * console.log(prepared.value.report.run?.gate.status);
2188
+ * }
2189
+ * ```
2190
+ */
2191
+ export interface PreparedVocabularyRegradeRun {
2192
+ readonly identity: PreparedRegradeRunIdentity;
2193
+ readonly report: RegradeReport;
2194
+ readonly sourceStateHash: string;
2195
+ }
2196
+
2197
+ interface PreparedVocabularyRegradeRunState {
2198
+ readonly dryRunEvaluation: VocabularyEvaluation;
2199
+ readonly identity: PreparedRegradeRunIdentity;
2200
+ readonly inputs: VocabularyRunInputs;
2201
+ readonly params: PrepareVocabularyRegradeRunParams;
2202
+ readonly sourceStateHash: string;
2203
+ }
2204
+
2205
+ const preparedVocabularyRunStates = new WeakMap<
2206
+ PreparedVocabularyRegradeRun,
2207
+ PreparedVocabularyRegradeRunState
2208
+ >();
2209
+
2210
+ const vocabularySourceStateHash = (files: readonly SourceFile[]): string =>
2211
+ createHash('sha256')
2212
+ .update(
2213
+ JSON.stringify({
2214
+ sources: files
2215
+ .map((file) => ({ bytes: file.sourceBytes, path: file.path }))
2216
+ .toSorted((left, right) => {
2217
+ if (left.path < right.path) {
2218
+ return -1;
2219
+ }
2220
+ if (left.path > right.path) {
2221
+ return 1;
2222
+ }
2223
+ return 0;
2224
+ }),
2225
+ })
2226
+ )
2227
+ .digest('hex');
2228
+
2229
+ const unreadableVocabularyPaths = (
2230
+ skipped: readonly SkippedSource[]
2231
+ ): readonly string[] =>
2232
+ skipped
2233
+ .filter(
2234
+ (entry) =>
2235
+ entry.reason === 'unreadable-file' ||
2236
+ entry.reason === 'unreadable-directory'
2237
+ )
2238
+ .map((entry) => entry.path);
2239
+
2240
+ const validateVocabularyPreparedIdentity = (
2241
+ expected: PreparedRegradeRunIdentity,
2242
+ actual: PreparedRegradeRunIdentity
2243
+ ): Result<void, ValidationError> => {
2244
+ for (const field of [
2245
+ 'planContentHash',
2246
+ 'policyHash',
2247
+ 'scopeHash',
2248
+ 'lockStateHash',
2249
+ 'toolVersion',
2250
+ ] as const) {
2251
+ if (expected[field] !== actual[field]) {
2252
+ return Result.err(
2253
+ new ValidationError(
2254
+ `Prepared vocabulary Regrade identity field \`${field}\` is stale.`,
2255
+ {
2256
+ context: {
2257
+ actual: actual[field],
2258
+ expected: expected[field],
2259
+ field,
2260
+ },
2261
+ }
2262
+ )
2263
+ );
2264
+ }
2265
+ }
2266
+ return Result.ok();
2267
+ };
2268
+
2269
+ /**
2270
+ * Classify a vocabulary Regrade run once and retain it for checked apply.
2271
+ *
2272
+ * @example
2273
+ * ```ts
2274
+ * const prepared = prepareVocabularyRegradeRun({ identity, plan, root });
2275
+ * if (prepared.isErr()) throw prepared.error;
2276
+ * ```
2277
+ */
2278
+ export const prepareVocabularyRegradeRun = (
2279
+ params: PrepareVocabularyRegradeRunParams & {
2280
+ readonly identity: PreparedRegradeRunIdentity;
2281
+ }
2282
+ ): Result<PreparedVocabularyRegradeRun | null, ValidationError> => {
2283
+ const preparedParams = snapshotPrepareVocabularyRegradeRunParams(params);
2284
+ const planValidation = validateVocabularyPlan(preparedParams.plan);
2285
+ if (planValidation.isErr()) {
2286
+ return planValidation;
2287
+ }
2288
+ const inventoryValidation = validatePreserveInventory(
2289
+ preparedParams.preserveInventory
2290
+ );
2291
+ if (inventoryValidation.isErr()) {
2292
+ return inventoryValidation;
2293
+ }
2294
+ const inputs = collectVocabularyRunInputs(preparedParams);
2295
+ if (inputs === null) {
2296
+ return Result.ok(null);
2297
+ }
2298
+ const unreadable = unreadableVocabularyPaths(inputs.skipped);
2299
+ if (unreadable.length > 0) {
2300
+ return Result.err(
2301
+ new ValidationError(
2302
+ 'Prepared vocabulary Regrade sources must all be readable.',
2303
+ { context: { paths: unreadable } }
2304
+ )
2305
+ );
2306
+ }
2307
+ const dryRunEvaluation = buildRunVocabularyEvaluation({
2308
+ apply: false,
2309
+ effectivePlan: inputs.effectivePlan,
2310
+ files: inputs.files,
2311
+ plan: preparedParams.plan,
2312
+ preserveInventory: preparedParams.preserveInventory,
2313
+ root: preparedParams.root,
2314
+ skipped: inputs.skipped,
2315
+ sourceKindForPath: preparedParams.sourceKindForPath,
2316
+ });
2317
+ const report = vocabularyReportFromEvaluation({
2318
+ collectedRoot: inputs.collectedRoot,
2319
+ dryRunEvaluation,
2320
+ effectivePlan: inputs.effectivePlan,
2321
+ entrySelection: preparedParams.includeEntries ?? 'actionable',
2322
+ files: inputs.files,
2323
+ plan: preparedParams.plan,
2324
+ reportEvaluation: dryRunEvaluation,
2325
+ skipped: inputs.skipped,
2326
+ });
2327
+ const prepared: PreparedVocabularyRegradeRun = {
2328
+ identity: { ...params.identity },
2329
+ report: cloneVocabularyReport(report),
2330
+ sourceStateHash: vocabularySourceStateHash(inputs.files),
2331
+ };
2332
+ preparedVocabularyRunStates.set(prepared, {
2333
+ dryRunEvaluation,
2334
+ identity: { ...params.identity },
2335
+ inputs,
2336
+ params: preparedParams,
2337
+ sourceStateHash: prepared.sourceStateHash,
2338
+ });
2339
+ return Result.ok(prepared);
2340
+ };
2341
+
2342
+ /**
2343
+ * Apply an in-memory vocabulary evaluation after identity and source checks.
2344
+ *
2345
+ * @example
2346
+ * ```ts
2347
+ * const applied = applyPreparedVocabularyRegradeRun(prepared, identity);
2348
+ * if (applied.isErr()) throw applied.error;
2349
+ * ```
2350
+ */
2351
+ export const applyPreparedVocabularyRegradeRun = (
2352
+ prepared: PreparedVocabularyRegradeRun,
2353
+ identity: PreparedRegradeRunIdentity
2354
+ ): Result<RegradeReport, InternalError | ValidationError> => {
2355
+ const state = preparedVocabularyRunStates.get(prepared);
2356
+ if (state === undefined) {
2357
+ return Result.err(
2358
+ new ValidationError(
2359
+ 'Prepared vocabulary Regrade run is not the original in-memory evaluation.'
2360
+ )
2361
+ );
2362
+ }
2363
+ const identityValidation = validateVocabularyPreparedIdentity(
2364
+ state.identity,
2365
+ identity
2366
+ );
2367
+ if (identityValidation.isErr()) {
2368
+ return identityValidation;
2369
+ }
2370
+ const current = collectVocabularyRunInputs(state.params);
2371
+ if (current === null) {
2372
+ return Result.err(
2373
+ new ValidationError(
2374
+ 'Prepared vocabulary Regrade root is no longer readable.'
2375
+ )
2376
+ );
2377
+ }
2378
+ const unreadable = unreadableVocabularyPaths(current.skipped);
2379
+ if (unreadable.length > 0) {
2380
+ return Result.err(
2381
+ new ValidationError(
2382
+ 'Prepared vocabulary Regrade sources are no longer readable.',
2383
+ { context: { paths: unreadable } }
2384
+ )
2385
+ );
2386
+ }
2387
+ const currentSourceStateHash = vocabularySourceStateHash(current.files);
2388
+ if (currentSourceStateHash !== state.sourceStateHash) {
2389
+ return Result.err(
2390
+ new ValidationError(
2391
+ 'Prepared vocabulary Regrade source state is stale.',
2392
+ {
2393
+ context: {
2394
+ actual: currentSourceStateHash,
2395
+ expected: state.sourceStateHash,
2396
+ },
2397
+ }
2398
+ )
2399
+ );
2400
+ }
2401
+ const applyResult = applyVocabularyEvaluation(
2402
+ state.inputs.files,
2403
+ state.dryRunEvaluation
2404
+ );
2405
+ if (applyResult.isErr()) {
2406
+ return applyResult;
2407
+ }
2408
+ const appliedFiles = state.inputs.files.map((file) => ({
2409
+ ...file,
2410
+ source: readFileSync(file.absolutePath, 'utf8'),
2411
+ }));
2412
+ const postApplyEvaluation = buildRunVocabularyEvaluation({
2413
+ apply: true,
2414
+ effectivePlan: state.inputs.effectivePlan,
2415
+ files: appliedFiles,
2416
+ plan: state.params.plan,
2417
+ preserveInventory: state.params.preserveInventory,
2418
+ root: state.params.root,
2419
+ skipped: state.inputs.skipped,
2420
+ sourceKindForPath: state.params.sourceKindForPath,
2421
+ });
2422
+ return Result.ok(
2423
+ vocabularyReportFromEvaluation({
2424
+ applySummary: applyResult.value,
2425
+ collectedRoot: state.inputs.collectedRoot,
2426
+ dryRunEvaluation: state.dryRunEvaluation,
2427
+ effectivePlan: state.inputs.effectivePlan,
2428
+ entrySelection: state.params.includeEntries ?? 'actionable',
2429
+ files: appliedFiles,
2430
+ plan: state.params.plan,
2431
+ reportEvaluation: postApplyEvaluation,
2432
+ skipped: state.inputs.skipped,
2433
+ })
2434
+ );
2435
+ };
2436
+
2437
+ export const runVocabularyRegrade = (params: {
2438
+ readonly apply?: boolean;
2439
+ readonly includeEntries?: 'actionable' | 'all';
2440
+ readonly plan: VocabularyRegradePlan;
2441
+ readonly preserveInventory?: readonly VocabularyPreserveInventoryEntry[];
2442
+ readonly root: string;
2443
+ readonly sourceFilter?: (path: string) => boolean;
2444
+ readonly sourceKindForPath?: (path: string) => VocabularySourceKind;
2445
+ }): Result<RegradeReport | null, InternalError | ValidationError> => {
2446
+ const planValidation = validateVocabularyPlan(params.plan);
2447
+ if (planValidation.isErr()) {
2448
+ return planValidation;
2449
+ }
2450
+ const inventoryValidation = validatePreserveInventory(
2451
+ params.preserveInventory
2452
+ );
2453
+ if (inventoryValidation.isErr()) {
2454
+ return inventoryValidation;
2455
+ }
2456
+
2457
+ const inputs = collectVocabularyRunInputs(params);
2458
+ if (inputs === null) {
2459
+ return Result.ok(null);
2460
+ }
2461
+ const { collectedRoot, effectivePlan, files, skipped } = inputs;
2462
+
2463
+ const dryRunEffectiveEvaluation = buildRunVocabularyEvaluation({
2464
+ apply: false,
2465
+ effectivePlan,
2466
+ files,
2467
+ plan: params.plan,
2468
+ preserveInventory: params.preserveInventory,
2469
+ root: params.root,
2470
+ skipped,
2471
+ sourceKindForPath: params.sourceKindForPath,
2472
+ });
2473
+ let reportEvaluation = dryRunEffectiveEvaluation;
2474
+ let applySummary: RegradeApplySummary | undefined;
2475
+
2476
+ if (params.apply === true) {
2477
+ const applyResult = applyVocabularyEvaluation(
2478
+ files,
2479
+ dryRunEffectiveEvaluation
2480
+ );
2481
+ if (applyResult.isErr()) {
2482
+ return applyResult;
2483
+ }
2484
+ applySummary = applyResult.value;
2485
+ const appliedFiles = files.map((file) => ({
2486
+ ...file,
2487
+ source: readFileSync(file.absolutePath, 'utf8'),
2488
+ }));
2489
+ reportEvaluation = buildRunVocabularyEvaluation({
2490
+ apply: true,
2491
+ effectivePlan,
2492
+ files: appliedFiles,
2493
+ plan: params.plan,
2494
+ preserveInventory: params.preserveInventory,
2495
+ root: params.root,
2496
+ skipped,
2497
+ sourceKindForPath: params.sourceKindForPath,
2498
+ });
2499
+ }
2500
+
2501
+ return Result.ok(
2502
+ vocabularyReportFromEvaluation({
2503
+ ...(applySummary === undefined ? {} : { applySummary }),
2504
+ collectedRoot,
2505
+ dryRunEvaluation: dryRunEffectiveEvaluation,
2506
+ effectivePlan,
2507
+ entrySelection: params.includeEntries ?? 'actionable',
2508
+ files,
2509
+ plan: params.plan,
2510
+ reportEvaluation,
2511
+ skipped,
2512
+ })
2513
+ );
2514
+ };
2515
+
2516
+ const vocabularyPreserveRuleSchema = z.object({
2517
+ disposition: z
2518
+ .enum(vocabularyDispositionValues)
2519
+ .optional()
2520
+ .describe('Classification for occurrences preserved by this rule'),
2521
+ forms: z
2522
+ .array(z.string().min(1))
2523
+ .optional()
2524
+ .describe('Matched forms this preserve rule applies to'),
2525
+ paths: z
2526
+ .array(z.string())
2527
+ .optional()
2528
+ .describe('Root-relative path patterns where the preserve rule applies'),
2529
+ pattern: z.string().describe('Regex or literal pattern to preserve'),
2530
+ reason: z.string().optional().describe('Why this form is preserved'),
2531
+ });
2532
+
2533
+ const vocabularyScopePolicySchema = z.object({
2534
+ disposition: z
2535
+ .enum(vocabularyDispositionValues)
2536
+ .describe('Occurrence disposition assigned within this protected scope'),
2537
+ expectMatches: z
2538
+ .boolean()
2539
+ .optional()
2540
+ .describe('Require this policy scope to contribute occurrence evidence'),
2541
+ paths: z
2542
+ .array(z.string().min(1))
2543
+ .min(1)
2544
+ .describe('Root-relative protected path patterns'),
2545
+ reason: z.string().min(1).describe('Why matching files are classified'),
2546
+ });
2547
+
2548
+ const vocabularyPreserveInventoryEntrySchema =
2549
+ vocabularyPreserveRuleSchema.extend({
2550
+ evidence: z
2551
+ .array(z.string().min(1))
2552
+ .describe('Graph or surface facts that justify this derived preserve'),
2553
+ source: z.literal('derived-live-api').describe('Derived inventory source'),
2554
+ });
2555
+
2556
+ const vocabularyDispositionCountSchema = z.object(
2557
+ Object.fromEntries(
2558
+ vocabularyDispositionValues.map((disposition) => [
2559
+ disposition,
2560
+ z.number().optional(),
2561
+ ])
2562
+ ) as Record<VocabularyDisposition, z.ZodOptional<z.ZodNumber>>
2563
+ );
2564
+
2565
+ const vocabularyRegradeScopeSchema = z.object({
2566
+ exclude: z
2567
+ .array(z.string())
2568
+ .optional()
2569
+ .describe('Root-relative path patterns to exclude from this regrade'),
2570
+ extensions: z
2571
+ .array(z.string())
2572
+ .optional()
2573
+ .describe('Source file extensions to scan for this regrade'),
2574
+ ignoredDirectories: z
2575
+ .array(z.string())
2576
+ .optional()
2577
+ .describe(
2578
+ 'Deprecated compatibility override for legacy plans. Use exclude globs for new plans.'
2579
+ ),
2580
+ include: z
2581
+ .array(z.string())
2582
+ .optional()
2583
+ .describe('Root-relative path patterns to include in this regrade'),
2584
+ policyClassified: z
2585
+ .array(vocabularyScopePolicySchema)
2586
+ .optional()
2587
+ .describe(
2588
+ 'Protected paths that remain scanned and counted but are never rewritten by default'
2589
+ ),
2590
+ teachingSurfaces: z
2591
+ .array(z.string().min(1))
2592
+ .optional()
2593
+ .describe('Expected current teaching-surface path patterns'),
2594
+ });
2595
+
2596
+ export const vocabularyRegradePlanSchema = z.object({
2597
+ caseSensitive: z
2598
+ .boolean()
2599
+ .optional()
2600
+ .describe('Whether source form scanning preserves case exactly'),
2601
+ deferForms: z
2602
+ .array(z.string().min(1))
2603
+ .optional()
2604
+ .describe('Known forms that must be inventoried for review, not rewritten'),
2605
+ fileRenames: z
2606
+ .array(
2607
+ z.object({
2608
+ from: z.string().min(1).describe('Root-relative source file path'),
2609
+ to: z.string().min(1).describe('Root-relative target file path'),
2610
+ })
2611
+ )
2612
+ .optional()
2613
+ .describe('Governed file moves whose references are derived from scope'),
2614
+ from: z.string().min(1).describe('Source vocabulary term or phrase'),
2615
+ id: z.string().optional().describe('Stable authored regrade plan id'),
2616
+ intent: z.string().optional().describe('Human-authored migration intent'),
2617
+ kind: z.literal('vocabulary').describe('Regrade plan kind'),
2618
+ overrides: z
2619
+ .record(z.string().min(1), z.string().min(1))
2620
+ .optional()
2621
+ .describe('Explicit source-form to target-form mappings'),
2622
+ preserve: z
2623
+ .array(vocabularyPreserveRuleSchema)
2624
+ .optional()
2625
+ .describe('Forms or contexts that are intentionally preserved'),
2626
+ scope: vocabularyRegradeScopeSchema
2627
+ .optional()
2628
+ .describe('Source scope for this regrade plan'),
2629
+ to: z.string().min(1).describe('Target vocabulary term or phrase'),
2630
+ });
2631
+
2632
+ export const vocabularyRegradeRunOutput = z.object({
2633
+ ledger: z
2634
+ .object({
2635
+ cycle: z.number().describe('Observed regrade run cycle'),
2636
+ forms: z
2637
+ .record(
2638
+ z.string(),
2639
+ z.enum(['applied', 'deferred', 'modified', 'skipped'])
2640
+ )
2641
+ .describe('Observed per-form triage verdicts for this run'),
2642
+ occurrences: z
2643
+ .array(
2644
+ z.object({
2645
+ column: z.number().describe('One-based source column'),
2646
+ context: z.string().describe('Source-line context'),
2647
+ disposition: z
2648
+ .enum(vocabularyDispositionValues)
2649
+ .describe('Occurrence-level classification beside the verdict'),
2650
+ end: z.number().describe('Source end offset'),
2651
+ form: z.string().describe('Matched vocabulary form'),
2652
+ line: z.number().describe('One-based source line'),
2653
+ path: z.string().describe('Root-relative POSIX path'),
2654
+ reason: z.string().describe('Why the occurrence got this verdict'),
2655
+ replacement: z
2656
+ .string()
2657
+ .optional()
2658
+ .describe('Replacement text for modified verdicts'),
2659
+ scopeTier: z
2660
+ .enum(['in-scope', 'policy-classified'])
2661
+ .describe('Three-tier scope classification for this occurrence'),
2662
+ sourceKind: z
2663
+ .enum(['source-comment', 'tsdoc'])
2664
+ .optional()
2665
+ .describe('Exact source-comment construct for comment inventory'),
2666
+ start: z.number().describe('Source start offset'),
2667
+ verdict: z
2668
+ .enum(['applied', 'deferred', 'modified', 'skipped'])
2669
+ .describe('Occurrence-level verdict'),
2670
+ })
2671
+ )
2672
+ .describe('Observed occurrence-level ledger for this run'),
2673
+ })
2674
+ .describe('Observed run ledger'),
2675
+ plan: vocabularyRegradePlanSchema.describe('Authored regrade plan'),
2676
+ preserveInventory: z
2677
+ .array(vocabularyPreserveInventoryEntrySchema)
2678
+ .optional()
2679
+ .describe(
2680
+ 'Derived live-API preserve inventory applied at run time without changing the authored plan'
2681
+ ),
2682
+ report: z
2683
+ .object({
2684
+ applied: z.number().describe('Modified occurrences applied to disk'),
2685
+ deferred: z.number().describe('Deferred occurrence count'),
2686
+ dispositions: z
2687
+ .object(vocabularyDispositionCountSchema.shape)
2688
+ .describe('Occurrence counts grouped by disposition'),
2689
+ fileRenames: z
2690
+ .array(
2691
+ z.object({
2692
+ deferred: z.number(),
2693
+ from: z.string(),
2694
+ historical: z.number(),
2695
+ preserved: z.number(),
2696
+ rewritten: z.number(),
2697
+ skipped: z.number(),
2698
+ to: z.string(),
2699
+ })
2700
+ )
2701
+ .optional()
2702
+ .describe('Governed file moves and derived reference outcomes'),
2703
+ filesChanged: z.number().describe('Distinct files changed on disk'),
2704
+ gate: z
2705
+ .object({
2706
+ reasons: z.array(z.string()).describe('Open-gate reasons'),
2707
+ remaining: z.number().describe('Unresolved occurrence count'),
2708
+ remainingByDisposition: z
2709
+ .object(vocabularyDispositionCountSchema.shape)
2710
+ .describe('Unresolved occurrence counts grouped by disposition'),
2711
+ status: z
2712
+ .enum(['green', 'open'])
2713
+ .describe('Whether the run is complete'),
2714
+ })
2715
+ .describe('Completion gate derived from the run ledger'),
2716
+ modified: z.number().describe('Modified occurrence count'),
2717
+ open: z
2718
+ .number()
2719
+ .describe(
2720
+ 'Deferred or unapplied modified occurrences holding the gate open'
2721
+ ),
2722
+ scopeTiers: z
2723
+ .object({
2724
+ 'in-scope': z.number(),
2725
+ 'policy-classified': z.number(),
2726
+ })
2727
+ .describe('Occurrence counts grouped by scope tier'),
2728
+ skipped: z.number().describe('Skipped occurrence count'),
2729
+ teachingSurfaces: z
2730
+ .object({
2731
+ expected: z.array(z.string()),
2732
+ missing: z.array(z.string()),
2733
+ touched: z.array(z.string()),
2734
+ })
2735
+ .describe('Expected and observed current teaching surfaces'),
2736
+ })
2737
+ .describe('Derived run report'),
2738
+ });
2739
+
2740
+ const vocabularyTransitionRecordEnvironmentSchema = z
2741
+ .object({
2742
+ commitSha: z.string().optional(),
2743
+ engineVersion: z.string().optional(),
2744
+ graphHash: z.string().optional(),
2745
+ root: z.string(),
2746
+ })
2747
+ .strict();
2748
+
2749
+ const vocabularyTransitionRecordReportSchema = z
2750
+ .object({
2751
+ apply: z.unknown().optional(),
2752
+ entries: z.array(z.unknown()),
2753
+ matched: z.number(),
2754
+ review: z.number(),
2755
+ rewritten: z.number(),
2756
+ root: z.string(),
2757
+ run: vocabularyRegradeRunOutput,
2758
+ scan: z.unknown(),
2759
+ scanned: z.number(),
2760
+ selectedClassIds: z.array(z.string()),
2761
+ skipped: z.number(),
2762
+ skipsByReason: z.record(z.string(), z.number()),
2763
+ unknownClassIds: z.array(z.string()),
2764
+ })
2765
+ .strict();
2766
+
2767
+ const normalizeTransitionRecordPath = (path: string): string =>
2768
+ normalize(path).replaceAll('\\', '/');
2769
+
2770
+ const isSafeRootRelativeRecordPath = (path: string): boolean => {
2771
+ const normalized = normalizeTransitionRecordPath(path);
2772
+ return (
2773
+ normalized.length > 0 &&
2774
+ !isAbsolute(normalized) &&
2775
+ normalized !== '..' &&
2776
+ !normalized.startsWith('../')
2777
+ );
2778
+ };
2779
+
2780
+ export const vocabularyTransitionRecordSchema = z
2781
+ .object({
2782
+ environment: vocabularyTransitionRecordEnvironmentSchema,
2783
+ kind: z.literal('vocabulary-transition-record'),
2784
+ recordPath: z.string().refine(isSafeRootRelativeRecordPath),
2785
+ report: vocabularyTransitionRecordReportSchema,
2786
+ schemaVersion: z.literal(VOCABULARY_TRANSITION_RECORD_SCHEMA_VERSION),
2787
+ transition: z
2788
+ .object({
2789
+ from: z.string(),
2790
+ id: z.string(),
2791
+ to: z.string(),
2792
+ })
2793
+ .strict(),
2794
+ })
2795
+ .strict();
2796
+
2797
+ const normalizeLegacyTransitionRecordScopeEvidence = (
2798
+ value: unknown
2799
+ ): unknown => {
2800
+ if (
2801
+ !isPlainObject(value) ||
2802
+ value['schemaVersion'] !== VOCABULARY_TRANSITION_RECORD_SCHEMA_VERSION
2803
+ ) {
2804
+ return value;
2805
+ }
2806
+ const { report } = value;
2807
+ if (!isPlainObject(report)) {
2808
+ return value;
2809
+ }
2810
+ const { run } = report;
2811
+ if (!isPlainObject(run)) {
2812
+ return value;
2813
+ }
2814
+ const { ledger, report: runReport } = run;
2815
+ if (
2816
+ !isPlainObject(ledger) ||
2817
+ !Array.isArray(ledger['occurrences']) ||
2818
+ !isPlainObject(runReport)
2819
+ ) {
2820
+ return value;
2821
+ }
2822
+ const plan = vocabularyRegradePlanSchema.safeParse(run['plan']);
2823
+ if (!plan.success) {
2824
+ return value;
2825
+ }
2826
+ const vocabularyPlan = plan.data as VocabularyRegradePlan;
2827
+ const occurrences = ledger['occurrences'].map((occurrence) =>
2828
+ isPlainObject(occurrence) &&
2829
+ typeof occurrence['path'] === 'string' &&
2830
+ occurrence['scopeTier'] === undefined
2831
+ ? {
2832
+ ...occurrence,
2833
+ scopeTier:
2834
+ scopePolicyForPath(occurrence['path'], vocabularyPlan.scope) ===
2835
+ undefined
2836
+ ? 'in-scope'
2837
+ : 'policy-classified',
2838
+ }
2839
+ : occurrence
2840
+ );
2841
+ const scopeEvidence = vocabularyScopeEvidence(
2842
+ vocabularyPlan,
2843
+ occurrences.filter(
2844
+ (occurrence) =>
2845
+ isPlainObject(occurrence) &&
2846
+ typeof occurrence['path'] === 'string' &&
2847
+ (occurrence['scopeTier'] === 'in-scope' ||
2848
+ occurrence['scopeTier'] === 'policy-classified')
2849
+ ) as unknown as readonly VocabularyOccurrence[]
2850
+ );
2851
+ return {
2852
+ ...value,
2853
+ report: {
2854
+ ...report,
2855
+ run: {
2856
+ ...run,
2857
+ ledger: { ...ledger, occurrences },
2858
+ report: {
2859
+ ...runReport,
2860
+ ...(runReport['scopeTiers'] === undefined
2861
+ ? { scopeTiers: scopeEvidence.scopeTiers }
2862
+ : {}),
2863
+ ...(runReport['teachingSurfaces'] === undefined
2864
+ ? { teachingSurfaces: scopeEvidence.teachingSurfaces }
2865
+ : {}),
2866
+ },
2867
+ },
2868
+ },
2869
+ };
2870
+ };
2871
+
2872
+ const transitionRecordSlug = (run: VocabularyRegradeRun): string =>
2873
+ `${run.plan.from}-to-${run.plan.to}`
2874
+ .toLowerCase()
2875
+ .replaceAll(/[^a-z0-9]+/g, '-')
2876
+ .replaceAll(/^-|-$/g, '');
2877
+
2878
+ const stableJson = (value: unknown): string =>
2879
+ JSON.stringify(value, (_key, nested) => {
2880
+ if (
2881
+ nested === null ||
2882
+ typeof nested !== 'object' ||
2883
+ Array.isArray(nested)
2884
+ ) {
2885
+ return nested as unknown;
2886
+ }
2887
+ return Object.fromEntries(
2888
+ Object.entries(nested as Record<string, unknown>).toSorted(
2889
+ ([left], [right]) => left.localeCompare(right)
2890
+ )
2891
+ );
2892
+ });
2893
+
2894
+ const shortHashForRun = (
2895
+ run: VocabularyRegradeRun,
2896
+ environment?: Partial<VocabularyTransitionRecordEnvironment>
2897
+ ): string => {
2898
+ const explicitHash = environment?.graphHash ?? environment?.commitSha;
2899
+ if (explicitHash !== undefined && explicitHash.length > 0) {
2900
+ return explicitHash.slice(0, 7);
2901
+ }
2902
+ return createHash('sha256')
2903
+ .update(stableJson({ ledger: run.ledger, plan: run.plan }))
2904
+ .digest('hex')
2905
+ .slice(0, 7);
2906
+ };
2907
+
2908
+ export const vocabularyTransitionRecordPath = (params: {
2909
+ readonly environment?: Partial<VocabularyTransitionRecordEnvironment>;
2910
+ readonly root: string;
2911
+ readonly run: VocabularyRegradeRun;
2912
+ }): string =>
2913
+ join(
2914
+ '.trails',
2915
+ 'regrade',
2916
+ 'history',
2917
+ `${transitionRecordSlug(params.run)}-${shortHashForRun(params.run, params.environment)}.json`
2918
+ );
2919
+
2920
+ const reportWithoutRecord = (
2921
+ report: RegradeReport
2922
+ ): Omit<RegradeReport, 'record'> => {
2923
+ const { record: _record, ...rest } = report;
2924
+ return rest;
2925
+ };
2926
+
2927
+ const transitionRecordPathForWrite = (params: {
2928
+ readonly environment: VocabularyTransitionRecordEnvironment;
2929
+ readonly recordPath?: string;
2930
+ readonly report: RegradeReport;
2931
+ readonly root: string;
2932
+ }): Result<string, ValidationError> => {
2933
+ const recordPath =
2934
+ params.recordPath ??
2935
+ vocabularyTransitionRecordPath({
2936
+ environment: params.environment,
2937
+ root: params.root,
2938
+ run: params.report.run as VocabularyRegradeRun,
2939
+ });
2940
+ const normalized = normalizeTransitionRecordPath(
2941
+ isAbsolute(recordPath) ? relative(params.root, recordPath) : recordPath
2942
+ );
2943
+ if (!isSafeRootRelativeRecordPath(normalized)) {
2944
+ return Result.err(
2945
+ new ValidationError(
2946
+ 'Vocabulary transition record path must stay inside the regrade root.',
2947
+ { context: { recordPath } }
2948
+ )
2949
+ );
2950
+ }
2951
+ return Result.ok(normalized);
2952
+ };
2953
+
2954
+ export const buildVocabularyTransitionRecord = (params: {
2955
+ readonly environment?: Partial<VocabularyTransitionRecordEnvironment>;
2956
+ readonly recordPath?: string;
2957
+ readonly report: RegradeReport;
2958
+ readonly root: string;
2959
+ }): Result<VocabularyTransitionRecord, ValidationError> => {
2960
+ if (params.report.run === undefined) {
2961
+ return Result.err(
2962
+ new ValidationError(
2963
+ 'Vocabulary transition records require a vocabulary Regrade report.'
2964
+ )
2965
+ );
2966
+ }
2967
+
2968
+ const environment: VocabularyTransitionRecordEnvironment = {
2969
+ ...(params.environment?.commitSha === undefined
2970
+ ? {}
2971
+ : { commitSha: params.environment.commitSha }),
2972
+ ...(params.environment?.engineVersion === undefined
2973
+ ? {}
2974
+ : { engineVersion: params.environment.engineVersion }),
2975
+ ...(params.environment?.graphHash === undefined
2976
+ ? {}
2977
+ : { graphHash: params.environment.graphHash }),
2978
+ root: params.root,
2979
+ };
2980
+ const recordPathResult = transitionRecordPathForWrite({
2981
+ environment,
2982
+ report: params.report,
2983
+ root: params.root,
2984
+ ...(params.recordPath === undefined
2985
+ ? {}
2986
+ : { recordPath: params.recordPath }),
2987
+ });
2988
+ if (recordPathResult.isErr()) {
2989
+ return recordPathResult;
2990
+ }
2991
+ const recordPath = recordPathResult.value;
2992
+ const record: VocabularyTransitionRecord = {
2993
+ environment,
2994
+ kind: 'vocabulary-transition-record',
2995
+ recordPath,
2996
+ report: reportWithoutRecord(params.report),
2997
+ schemaVersion: VOCABULARY_TRANSITION_RECORD_SCHEMA_VERSION,
2998
+ transition: {
2999
+ from: params.report.run.plan.from,
3000
+ id:
3001
+ params.report.run.plan.id ??
3002
+ `vocabulary:${params.report.run.plan.from}->${params.report.run.plan.to}`,
3003
+ to: params.report.run.plan.to,
3004
+ },
3005
+ };
3006
+ const parsed = vocabularyTransitionRecordSchema.safeParse(record);
3007
+ if (!parsed.success) {
3008
+ return Result.err(
3009
+ new ValidationError('Invalid vocabulary transition record.', {
3010
+ context: { issues: parsed.error.issues },
3011
+ })
3012
+ );
3013
+ }
3014
+ return Result.ok(parsed.data as VocabularyTransitionRecord);
3015
+ };
3016
+
3017
+ export const writeVocabularyTransitionRecord = (params: {
3018
+ readonly environment?: Partial<VocabularyTransitionRecordEnvironment>;
3019
+ readonly recordPath?: string;
3020
+ readonly report: RegradeReport;
3021
+ readonly root: string;
3022
+ readonly status: VocabularyTransitionRecordSummary['status'];
3023
+ }): Result<
3024
+ {
3025
+ readonly record: VocabularyTransitionRecord;
3026
+ readonly summary: VocabularyTransitionRecordSummary;
3027
+ },
3028
+ InternalError | ValidationError
3029
+ > => {
3030
+ const recordResult = buildVocabularyTransitionRecord(params);
3031
+ if (recordResult.isErr()) {
3032
+ return recordResult;
3033
+ }
3034
+ const record = recordResult.value;
3035
+ const absolutePath = isAbsolute(record.recordPath)
3036
+ ? record.recordPath
3037
+ : join(params.root, record.recordPath);
3038
+ try {
3039
+ mkdirSync(dirname(absolutePath), { recursive: true });
3040
+ writeFileSync(absolutePath, `${JSON.stringify(record, null, 2)}\n`);
3041
+ } catch (error) {
3042
+ return Result.err(
3043
+ new InternalError('Failed to write vocabulary transition record.', {
3044
+ ...(error instanceof Error ? { cause: error } : {}),
3045
+ context: { path: record.recordPath },
3046
+ })
3047
+ );
3048
+ }
3049
+ return Result.ok({
3050
+ record,
3051
+ summary: {
3052
+ path: record.recordPath,
3053
+ schemaVersion: record.schemaVersion,
3054
+ status: params.status,
3055
+ },
3056
+ });
3057
+ };
3058
+
3059
+ export const readVocabularyTransitionRecord = (
3060
+ path: string
3061
+ ): Result<VocabularyTransitionRecord, InternalError | ValidationError> => {
3062
+ if (!existsSync(path)) {
3063
+ return Result.err(
3064
+ new ValidationError(`Vocabulary transition record "${path}" not found.`)
3065
+ );
3066
+ }
3067
+ let parsedJson: unknown;
3068
+ try {
3069
+ parsedJson = JSON.parse(readFileSync(path, 'utf8'));
3070
+ } catch (error) {
3071
+ return Result.err(
3072
+ new InternalError('Failed to read vocabulary transition record.', {
3073
+ ...(error instanceof Error ? { cause: error } : {}),
3074
+ context: { path },
3075
+ })
3076
+ );
3077
+ }
3078
+ const parsed = vocabularyTransitionRecordSchema.safeParse(
3079
+ normalizeLegacyTransitionRecordScopeEvidence(parsedJson)
3080
+ );
3081
+ if (!parsed.success) {
3082
+ return Result.err(
3083
+ new ValidationError('Invalid vocabulary transition record.', {
3084
+ context: { issues: parsed.error.issues, path },
3085
+ })
3086
+ );
3087
+ }
3088
+ return Result.ok(parsed.data as VocabularyTransitionRecord);
3089
+ };
3090
+
3091
+ export const transitionRecordReportWithSummary = (
3092
+ report: RegradeReport,
3093
+ summary: VocabularyTransitionRecordSummary
3094
+ ): RegradeReport => ({ ...report, record: summary });