@ontrails/regrade 1.0.0-beta.30 → 1.0.0-beta.32

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,965 @@
1
+ import {
2
+ InternalError,
3
+ Result,
4
+ ValidationError,
5
+ escapeRegExp,
6
+ matchesAnyPathGlob,
7
+ } from '@ontrails/core';
8
+ import { readFileSync, writeFileSync } from 'node:fs';
9
+ import { z } from 'zod';
10
+
11
+ import { collectDownstreamSources } from './collect.js';
12
+ import type { DownstreamCollectionOptions, SkippedSource } from './collect.js';
13
+ import type {
14
+ RegradeApplySummary,
15
+ RegradeReport,
16
+ RegradeReportEntry,
17
+ } from './report.js';
18
+ import { buildRegradeScanSummary } from './scan-summary.js';
19
+
20
+ export type VocabularyVerdict = 'deferred' | 'modified' | 'skipped';
21
+
22
+ export interface VocabularyPreserveRule {
23
+ readonly pattern: string;
24
+ readonly reason?: string;
25
+ readonly paths?: readonly string[];
26
+ }
27
+
28
+ export interface VocabularyRegradeScope {
29
+ readonly exclude?: readonly string[];
30
+ readonly extensions?: readonly string[];
31
+ /**
32
+ * @deprecated Use `exclude` path globs for new plans. This remains as a
33
+ * compatibility bridge for pre-path-scope plans that intentionally disabled
34
+ * the collector's default directory pruning.
35
+ */
36
+ readonly ignoredDirectories?: readonly string[];
37
+ readonly include?: readonly string[];
38
+ }
39
+
40
+ export interface VocabularyRegradePlan {
41
+ readonly from: string;
42
+ readonly id?: string;
43
+ readonly intent?: string;
44
+ readonly kind: 'vocabulary';
45
+ readonly overrides?: Readonly<Record<string, string>>;
46
+ readonly preserve?: readonly VocabularyPreserveRule[];
47
+ readonly scope?: VocabularyRegradeScope;
48
+ readonly to: string;
49
+ }
50
+
51
+ export interface VocabularyOccurrence {
52
+ readonly column: number;
53
+ readonly context: string;
54
+ readonly end: number;
55
+ readonly form: string;
56
+ readonly line: number;
57
+ readonly path: string;
58
+ readonly reason: string;
59
+ readonly replacement?: string;
60
+ readonly start: number;
61
+ readonly verdict: VocabularyVerdict;
62
+ }
63
+
64
+ export interface VocabularyRunLedger {
65
+ readonly cycle: number;
66
+ readonly forms: Readonly<Record<string, VocabularyVerdict>>;
67
+ readonly occurrences: readonly VocabularyOccurrence[];
68
+ }
69
+
70
+ export interface VocabularyRunGate {
71
+ readonly remaining: number;
72
+ readonly reasons: readonly string[];
73
+ readonly status: 'green' | 'open';
74
+ }
75
+
76
+ export interface VocabularyRunReport {
77
+ readonly applied: number;
78
+ readonly deferred: number;
79
+ readonly filesChanged: number;
80
+ readonly gate: VocabularyRunGate;
81
+ readonly modified: number;
82
+ readonly open: number;
83
+ readonly skipped: number;
84
+ }
85
+
86
+ export interface VocabularyRegradeRun {
87
+ readonly ledger: VocabularyRunLedger;
88
+ readonly plan: VocabularyRegradePlan;
89
+ readonly report: VocabularyRunReport;
90
+ }
91
+
92
+ interface SourceFile {
93
+ readonly absolutePath: string;
94
+ readonly path: string;
95
+ readonly source: string;
96
+ }
97
+
98
+ interface SourceOccurrence extends VocabularyOccurrence {
99
+ readonly absolutePath: string;
100
+ }
101
+
102
+ interface VocabularyEvaluation {
103
+ readonly entries: readonly RegradeReportEntry[];
104
+ readonly occurrences: readonly SourceOccurrence[];
105
+ readonly scanned: number;
106
+ readonly skipped: readonly SkippedSource[];
107
+ readonly run: VocabularyRegradeRun;
108
+ }
109
+
110
+ const VOCABULARY_SOURCE_EXTENSIONS = Object.freeze([
111
+ '.js',
112
+ '.jsx',
113
+ '.json',
114
+ '.jsonc',
115
+ '.md',
116
+ '.mdx',
117
+ '.mjs',
118
+ '.ts',
119
+ '.tsx',
120
+ '.txt',
121
+ '.yaml',
122
+ '.yml',
123
+ ]);
124
+
125
+ const uniqueSorted = (values: readonly string[]): readonly string[] =>
126
+ [...new Set(values)].toSorted((a, b) => a.localeCompare(b));
127
+
128
+ const isVocabularyTokenCharacter = (value: string): boolean =>
129
+ /[A-Za-z0-9_$-]/.test(value);
130
+
131
+ const hasWordBoundary = (
132
+ source: string,
133
+ start: number,
134
+ end: number
135
+ ): boolean => {
136
+ const before = start === 0 ? '' : (source.at(start - 1) ?? '');
137
+ const after = end >= source.length ? '' : (source.at(end) ?? '');
138
+ return (
139
+ !isVocabularyTokenCharacter(before) && !isVocabularyTokenCharacter(after)
140
+ );
141
+ };
142
+
143
+ const expandVocabularyNeighborSpan = (
144
+ source: string,
145
+ start: number,
146
+ end: number
147
+ ): { readonly end: number; readonly start: number } => {
148
+ let expandedStart = start;
149
+ while (
150
+ expandedStart > 0 &&
151
+ isVocabularyTokenCharacter(source.at(expandedStart - 1) ?? '')
152
+ ) {
153
+ expandedStart -= 1;
154
+ }
155
+
156
+ let expandedEnd = end;
157
+ while (
158
+ expandedEnd < source.length &&
159
+ isVocabularyTokenCharacter(source.at(expandedEnd) ?? '')
160
+ ) {
161
+ expandedEnd += 1;
162
+ }
163
+
164
+ return { end: expandedEnd, start: expandedStart };
165
+ };
166
+
167
+ const lineColumnForOffset = (
168
+ source: string,
169
+ offset: number
170
+ ): { readonly column: number; readonly line: number } => {
171
+ let line = 1;
172
+ let column = 1;
173
+ for (let index = 0; index < offset; index += 1) {
174
+ if (source.codePointAt(index) === 10) {
175
+ line += 1;
176
+ column = 1;
177
+ } else {
178
+ column += 1;
179
+ }
180
+ }
181
+ return { column, line };
182
+ };
183
+
184
+ const contextForOffset = (
185
+ source: string,
186
+ start: number,
187
+ end: number
188
+ ): string => {
189
+ const lineStart = source.lastIndexOf('\n', start - 1) + 1;
190
+ const nextLine = source.indexOf('\n', end);
191
+ const lineEnd = nextLine === -1 ? source.length : nextLine;
192
+ return source.slice(lineStart, lineEnd).trim();
193
+ };
194
+
195
+ const preserveCase = (sourceForm: string, replacement: string): string => {
196
+ if (sourceForm.toUpperCase() === sourceForm) {
197
+ return replacement.toUpperCase();
198
+ }
199
+ const first = sourceForm.at(0);
200
+ if (first !== undefined && first.toUpperCase() === first) {
201
+ return replacement.at(0)?.toUpperCase() + replacement.slice(1);
202
+ }
203
+ return replacement;
204
+ };
205
+
206
+ const pluralize = (value: string): string =>
207
+ value.endsWith('s') || value.endsWith('x') || value.endsWith('ch')
208
+ ? `${value}es`
209
+ : `${value}s`;
210
+
211
+ const defaultVocabularyForms = (from: string, to: string) =>
212
+ new Map<string, string>([
213
+ [from, to],
214
+ [pluralize(from), pluralize(to)],
215
+ ]);
216
+
217
+ const normalizedOverrideEntries = (
218
+ overrides: Readonly<Record<string, string>> | undefined
219
+ ): readonly [string, string][] =>
220
+ Object.entries(overrides ?? {}).toSorted(([left], [right]) =>
221
+ left.localeCompare(right)
222
+ );
223
+
224
+ const targetFormsForPlan = (
225
+ plan: VocabularyRegradePlan
226
+ ): Map<string, string> => {
227
+ const forms = defaultVocabularyForms(plan.from, plan.to);
228
+ for (const [form, replacement] of normalizedOverrideEntries(plan.overrides)) {
229
+ forms.set(form, replacement);
230
+ }
231
+ return forms;
232
+ };
233
+
234
+ const validateVocabularyPlan = (
235
+ plan: VocabularyRegradePlan
236
+ ): Result<void, ValidationError> => {
237
+ if (plan.from.trim().length === 0) {
238
+ return Result.err(
239
+ new ValidationError('Vocabulary Regrade plan `from` cannot be empty.')
240
+ );
241
+ }
242
+ if (plan.to.trim().length === 0) {
243
+ return Result.err(
244
+ new ValidationError('Vocabulary Regrade plan `to` cannot be empty.')
245
+ );
246
+ }
247
+ for (const [form, replacement] of normalizedOverrideEntries(plan.overrides)) {
248
+ if (form.trim().length === 0) {
249
+ return Result.err(
250
+ new ValidationError(
251
+ 'Vocabulary Regrade plan override keys cannot be empty.'
252
+ )
253
+ );
254
+ }
255
+ if (replacement.trim().length === 0) {
256
+ return Result.err(
257
+ new ValidationError(
258
+ `Vocabulary Regrade plan override "${form}" cannot map to an empty replacement.`
259
+ )
260
+ );
261
+ }
262
+ }
263
+ for (const rule of plan.preserve ?? []) {
264
+ if (rule.pattern.trim().length === 0) {
265
+ return Result.err(
266
+ new ValidationError(
267
+ 'Vocabulary Regrade plan preserve patterns cannot be empty.'
268
+ )
269
+ );
270
+ }
271
+ }
272
+ return Result.ok();
273
+ };
274
+
275
+ const includedByScope = (
276
+ path: string,
277
+ scope: VocabularyRegradeScope | undefined
278
+ ): boolean =>
279
+ (scope?.include === undefined ||
280
+ scope.include.length === 0 ||
281
+ matchesAnyPathGlob(path, scope.include)) &&
282
+ !matchesAnyPathGlob(path, scope?.exclude);
283
+
284
+ const compilePreservePattern = (pattern: string): RegExp => {
285
+ try {
286
+ return new RegExp(pattern);
287
+ } catch {
288
+ return new RegExp(escapeRegExp(pattern));
289
+ }
290
+ };
291
+
292
+ const preserveRuleForOccurrence = (
293
+ occurrence: Omit<SourceOccurrence, 'reason' | 'verdict'>,
294
+ plan: VocabularyRegradePlan
295
+ ): VocabularyPreserveRule | undefined =>
296
+ plan.preserve?.find((rule) => {
297
+ if (
298
+ rule.paths !== undefined &&
299
+ !matchesAnyPathGlob(occurrence.path, rule.paths)
300
+ ) {
301
+ return false;
302
+ }
303
+ return (
304
+ compilePreservePattern(rule.pattern).test(occurrence.context) ||
305
+ compilePreservePattern(rule.pattern).test(occurrence.form)
306
+ );
307
+ });
308
+
309
+ const occurrencesForFile = (
310
+ file: SourceFile,
311
+ plan: VocabularyRegradePlan,
312
+ targetForms: Map<string, string>
313
+ ): readonly SourceOccurrence[] => {
314
+ const occurrences: SourceOccurrence[] = [];
315
+ const candidates: SourceOccurrence[] = [];
316
+ const forms = [...targetForms.entries()].toSorted(
317
+ ([left], [right]) => right.length - left.length || left.localeCompare(right)
318
+ );
319
+
320
+ for (const [form, replacement] of forms) {
321
+ const pattern = new RegExp(escapeRegExp(form), 'gi');
322
+ for (const match of file.source.matchAll(pattern)) {
323
+ const start = match.index ?? 0;
324
+ const end = start + match[0].length;
325
+ if (!hasWordBoundary(file.source, start, end)) {
326
+ continue;
327
+ }
328
+ const { column, line } = lineColumnForOffset(file.source, start);
329
+ const baseOccurrence = {
330
+ absolutePath: file.absolutePath,
331
+ column,
332
+ context: contextForOffset(file.source, start, end),
333
+ end,
334
+ form: match[0],
335
+ line,
336
+ path: file.path,
337
+ start,
338
+ };
339
+ const preserveRule = preserveRuleForOccurrence(baseOccurrence, plan);
340
+ candidates.push({
341
+ ...baseOccurrence,
342
+ reason:
343
+ preserveRule?.reason ??
344
+ (preserveRule === undefined ? 'captured-form' : 'preserved-by-plan'),
345
+ ...(preserveRule === undefined
346
+ ? { replacement: preserveCase(match[0], replacement) }
347
+ : {}),
348
+ verdict: preserveRule === undefined ? 'modified' : 'skipped',
349
+ });
350
+ }
351
+ }
352
+
353
+ for (const candidate of candidates.toSorted(
354
+ (left, right) =>
355
+ right.end - right.start - (left.end - left.start) ||
356
+ left.start - right.start
357
+ )) {
358
+ const overlaps = occurrences.some(
359
+ (occurrence) =>
360
+ candidate.start < occurrence.end && occurrence.start < candidate.end
361
+ );
362
+ if (!overlaps) {
363
+ occurrences.push(candidate);
364
+ }
365
+ }
366
+
367
+ return occurrences.toSorted((left, right) =>
368
+ left.path === right.path
369
+ ? left.start - right.start
370
+ : left.path.localeCompare(right.path)
371
+ );
372
+ };
373
+
374
+ const deferredOccurrencesForFile = (
375
+ file: SourceFile,
376
+ plan: VocabularyRegradePlan,
377
+ targetForms: Map<string, string>
378
+ ): readonly SourceOccurrence[] => {
379
+ const knownForms = new Set(
380
+ [...targetForms.keys()].flatMap((form) => [form, form.toLowerCase()])
381
+ );
382
+ const lowerFrom = plan.from.toLowerCase();
383
+ const tokenPattern = /[A-Za-z_$][A-Za-z0-9_$-]*/g;
384
+ const occurrences: SourceOccurrence[] = [];
385
+ const pushOccurrence = (
386
+ baseOccurrence: Omit<SourceOccurrence, 'reason' | 'verdict'>
387
+ ): void => {
388
+ const preserveRule = preserveRuleForOccurrence(baseOccurrence, plan);
389
+ occurrences.push({
390
+ ...baseOccurrence,
391
+ reason:
392
+ preserveRule?.reason ??
393
+ (preserveRule === undefined
394
+ ? 'unclassified-neighbor'
395
+ : 'preserved-by-plan'),
396
+ verdict: preserveRule === undefined ? 'deferred' : 'skipped',
397
+ });
398
+ };
399
+
400
+ for (const form of targetForms.keys()) {
401
+ const pattern = new RegExp(escapeRegExp(form), 'gi');
402
+ for (const match of file.source.matchAll(pattern)) {
403
+ const matchStart = match.index ?? 0;
404
+ const matchEnd = matchStart + match[0].length;
405
+ if (hasWordBoundary(file.source, matchStart, matchEnd)) {
406
+ continue;
407
+ }
408
+ const { end, start } = expandVocabularyNeighborSpan(
409
+ file.source,
410
+ matchStart,
411
+ matchEnd
412
+ );
413
+ const matchedForm = file.source.slice(start, end);
414
+ const lowerMatchedForm = matchedForm.toLowerCase();
415
+ const overlaps = occurrences.some(
416
+ (occurrence) => start < occurrence.end && occurrence.start < end
417
+ );
418
+ if (
419
+ overlaps ||
420
+ knownForms.has(matchedForm) ||
421
+ knownForms.has(lowerMatchedForm) ||
422
+ !lowerMatchedForm.includes(lowerFrom)
423
+ ) {
424
+ continue;
425
+ }
426
+ const { column, line } = lineColumnForOffset(file.source, start);
427
+ pushOccurrence({
428
+ absolutePath: file.absolutePath,
429
+ column,
430
+ context: contextForOffset(file.source, start, end),
431
+ end,
432
+ form: matchedForm,
433
+ line,
434
+ path: file.path,
435
+ start,
436
+ });
437
+ }
438
+ }
439
+
440
+ for (const match of file.source.matchAll(tokenPattern)) {
441
+ const [form] = match;
442
+ const lower = form.toLowerCase();
443
+ if (knownForms.has(form) || knownForms.has(lower)) {
444
+ continue;
445
+ }
446
+ if (!lower.includes(lowerFrom)) {
447
+ continue;
448
+ }
449
+ const start = match.index ?? 0;
450
+ const end = start + form.length;
451
+ const overlaps = occurrences.some(
452
+ (occurrence) => start < occurrence.end && occurrence.start < end
453
+ );
454
+ if (overlaps) {
455
+ continue;
456
+ }
457
+ const { column, line } = lineColumnForOffset(file.source, start);
458
+ pushOccurrence({
459
+ absolutePath: file.absolutePath,
460
+ column,
461
+ context: contextForOffset(file.source, start, end),
462
+ end,
463
+ form,
464
+ line,
465
+ path: file.path,
466
+ start,
467
+ });
468
+ }
469
+ return occurrences.toSorted((left, right) =>
470
+ left.path === right.path
471
+ ? left.start - right.start
472
+ : left.path.localeCompare(right.path)
473
+ );
474
+ };
475
+
476
+ const entryForOccurrences = (
477
+ path: string,
478
+ occurrences: readonly SourceOccurrence[]
479
+ ): RegradeReportEntry | null => {
480
+ if (occurrences.length === 0) {
481
+ return null;
482
+ }
483
+ const hasDeferred = occurrences.some(
484
+ (occurrence) => occurrence.verdict === 'deferred'
485
+ );
486
+ const hasModified = occurrences.some(
487
+ (occurrence) => occurrence.verdict === 'modified'
488
+ );
489
+ if (hasDeferred) {
490
+ return {
491
+ notes: [
492
+ `Found ${occurrences.length} vocabulary occurrence(s); judgment deferred.`,
493
+ ],
494
+ outcome: 'needs-review',
495
+ path,
496
+ reason: 'vocabulary-judgment-deferred',
497
+ reviewDetails: occurrences
498
+ .filter((occurrence) => occurrence.verdict === 'deferred')
499
+ .map((occurrence) => ({
500
+ expectedTarget:
501
+ 'Add an override or preserve rule to the regrade plan.',
502
+ reason: occurrence.reason,
503
+ span: {
504
+ column: occurrence.column,
505
+ end: occurrence.end,
506
+ line: occurrence.line,
507
+ start: occurrence.start,
508
+ },
509
+ symbol: occurrence.form,
510
+ })),
511
+ };
512
+ }
513
+ if (hasModified) {
514
+ return {
515
+ notes: [
516
+ `Found ${occurrences.length} vocabulary occurrence(s); safe modifications available.`,
517
+ ],
518
+ outcome: 'rewrite',
519
+ path,
520
+ };
521
+ }
522
+ return {
523
+ notes: [`Skipped ${occurrences.length} vocabulary occurrence(s).`],
524
+ outcome: 'no-op',
525
+ path,
526
+ };
527
+ };
528
+
529
+ const applyOccurrenceRewrites = (
530
+ file: SourceFile,
531
+ occurrences: readonly SourceOccurrence[]
532
+ ): string => {
533
+ let nextSource = file.source;
534
+ for (const occurrence of occurrences.toReversed()) {
535
+ if (
536
+ occurrence.verdict !== 'modified' ||
537
+ occurrence.replacement === undefined
538
+ ) {
539
+ continue;
540
+ }
541
+ nextSource =
542
+ nextSource.slice(0, occurrence.start) +
543
+ occurrence.replacement +
544
+ nextSource.slice(occurrence.end);
545
+ }
546
+ return nextSource;
547
+ };
548
+
549
+ const buildVocabularyEvaluation = (params: {
550
+ readonly apply?: boolean;
551
+ readonly files: readonly SourceFile[];
552
+ readonly plan: VocabularyRegradePlan;
553
+ readonly root: string;
554
+ readonly skipped: readonly SkippedSource[];
555
+ }): VocabularyEvaluation => {
556
+ const targetForms = targetFormsForPlan(params.plan);
557
+ const scopedFiles = params.files.filter((file) =>
558
+ includedByScope(file.path, params.plan.scope)
559
+ );
560
+ const scopeSkipped: SkippedSource[] = params.files
561
+ .filter((file) => !includedByScope(file.path, params.plan.scope))
562
+ .map((file) => ({ path: file.path, reason: 'excluded-by-regrade-scope' }));
563
+ const occurrences = scopedFiles.flatMap((file) => [
564
+ ...occurrencesForFile(file, params.plan, targetForms),
565
+ ...deferredOccurrencesForFile(file, params.plan, targetForms),
566
+ ]);
567
+ const occurrencesByPath = new Map<string, SourceOccurrence[]>();
568
+ for (const occurrence of occurrences) {
569
+ const existing = occurrencesByPath.get(occurrence.path) ?? [];
570
+ occurrencesByPath.set(occurrence.path, [...existing, occurrence]);
571
+ }
572
+ const entries = [...occurrencesByPath.entries()]
573
+ .flatMap(([path, pathOccurrences]) => {
574
+ const entry = entryForOccurrences(path, pathOccurrences);
575
+ return entry === null ? [] : [entry];
576
+ })
577
+ .toSorted((left, right) => left.path.localeCompare(right.path));
578
+ const rewrittenPaths = new Set(
579
+ occurrences
580
+ .filter((occurrence) => occurrence.verdict === 'modified')
581
+ .map((occurrence) => occurrence.path)
582
+ );
583
+ const deferredForms = uniqueSorted(
584
+ occurrences
585
+ .filter((occurrence) => occurrence.verdict === 'deferred')
586
+ .map((occurrence) => occurrence.form)
587
+ );
588
+ const modifiedOccurrences = occurrences.filter(
589
+ (occurrence) => occurrence.verdict === 'modified'
590
+ );
591
+ const skippedOccurrences = occurrences.filter(
592
+ (occurrence) => occurrence.verdict === 'skipped'
593
+ );
594
+ const deferredOccurrences = occurrences.filter(
595
+ (occurrence) => occurrence.verdict === 'deferred'
596
+ );
597
+ const forms: Record<string, VocabularyVerdict> = {};
598
+ for (const occurrence of occurrences) {
599
+ const current = forms[occurrence.form];
600
+ if (occurrence.verdict === 'deferred') {
601
+ forms[occurrence.form] = 'deferred';
602
+ continue;
603
+ }
604
+ if (occurrence.verdict === 'modified' && current !== 'deferred') {
605
+ forms[occurrence.form] = 'modified';
606
+ continue;
607
+ }
608
+ if (current === undefined) {
609
+ forms[occurrence.form] = 'skipped';
610
+ }
611
+ }
612
+ const gateReasons: string[] = [];
613
+ if (modifiedOccurrences.length > 0) {
614
+ gateReasons.push(
615
+ params.apply === true
616
+ ? 'source-forms-remain-after-apply'
617
+ : 'safe-modifications-not-yet-applied'
618
+ );
619
+ }
620
+ if (deferredForms.length > 0) {
621
+ gateReasons.push('deferred-forms-or-occurrences');
622
+ }
623
+ const open = modifiedOccurrences.length + deferredOccurrences.length;
624
+
625
+ return {
626
+ entries: [
627
+ ...entries,
628
+ ...params.skipped.map((entry) => ({
629
+ outcome: 'skip' as const,
630
+ path: entry.path,
631
+ reason: entry.reason,
632
+ })),
633
+ ...scopeSkipped.map((entry) => ({
634
+ outcome: 'skip' as const,
635
+ path: entry.path,
636
+ reason: entry.reason,
637
+ })),
638
+ ].toSorted((left, right) => left.path.localeCompare(right.path)),
639
+ occurrences,
640
+ run: {
641
+ ledger: {
642
+ cycle: 1,
643
+ forms,
644
+ occurrences: occurrences.map(
645
+ ({ absolutePath: _absolutePath, ...occurrence }) => occurrence
646
+ ),
647
+ },
648
+ plan: params.plan,
649
+ report: {
650
+ applied: params.apply === true ? modifiedOccurrences.length : 0,
651
+ deferred: deferredOccurrences.length,
652
+ filesChanged: params.apply === true ? rewrittenPaths.size : 0,
653
+ gate: {
654
+ reasons: gateReasons,
655
+ remaining: open,
656
+ status: gateReasons.length === 0 ? 'green' : 'open',
657
+ },
658
+ modified: modifiedOccurrences.length,
659
+ open,
660
+ skipped: skippedOccurrences.length,
661
+ },
662
+ },
663
+ scanned: scopedFiles.length,
664
+ skipped: [...params.skipped, ...scopeSkipped],
665
+ };
666
+ };
667
+
668
+ const skippedByReason = (
669
+ skipped: readonly SkippedSource[]
670
+ ): Readonly<Record<string, number>> => {
671
+ const counts = new Map<string, number>();
672
+ for (const entry of skipped) {
673
+ counts.set(entry.reason, (counts.get(entry.reason) ?? 0) + 1);
674
+ }
675
+ return Object.fromEntries(
676
+ [...counts.entries()].toSorted(([left], [right]) =>
677
+ left.localeCompare(right)
678
+ )
679
+ );
680
+ };
681
+
682
+ const withApplySummary = (
683
+ report: RegradeReport,
684
+ apply: RegradeApplySummary
685
+ ): RegradeReport => ({
686
+ ...report,
687
+ apply,
688
+ });
689
+
690
+ const applyVocabularyEvaluation = (
691
+ files: readonly SourceFile[],
692
+ evaluation: VocabularyEvaluation
693
+ ): Result<RegradeApplySummary, InternalError> => {
694
+ const changedFiles = new Set<string>();
695
+ let applied = 0;
696
+ for (const file of files) {
697
+ const fileOccurrences = evaluation.occurrences.filter(
698
+ (occurrence) =>
699
+ occurrence.path === file.path && occurrence.verdict === 'modified'
700
+ );
701
+ if (fileOccurrences.length === 0) {
702
+ continue;
703
+ }
704
+ const nextSource = applyOccurrenceRewrites(file, fileOccurrences);
705
+ if (nextSource === file.source) {
706
+ continue;
707
+ }
708
+ try {
709
+ writeFileSync(file.absolutePath, nextSource, 'utf8');
710
+ } catch (error: unknown) {
711
+ return Result.err(
712
+ new InternalError(
713
+ `Failed to apply vocabulary regrade rewrite for "${file.path}".`,
714
+ {
715
+ cause: error instanceof Error ? error : new Error(String(error)),
716
+ context: {
717
+ applied,
718
+ filesChanged: changedFiles.size,
719
+ path: file.path,
720
+ },
721
+ }
722
+ )
723
+ );
724
+ }
725
+ applied += fileOccurrences.length;
726
+ changedFiles.add(file.path);
727
+ }
728
+
729
+ const reviewFiles = new Set(
730
+ evaluation.occurrences
731
+ .filter((occurrence) => occurrence.verdict === 'deferred')
732
+ .map((occurrence) => occurrence.path)
733
+ );
734
+ const skippedOccurrences = evaluation.occurrences.filter(
735
+ (occurrence) => occurrence.verdict === 'skipped'
736
+ );
737
+
738
+ return Result.ok({
739
+ applied,
740
+ filesChanged: changedFiles.size,
741
+ review: reviewFiles.size,
742
+ skipped: skippedOccurrences.length + evaluation.skipped.length,
743
+ unknown: 0,
744
+ });
745
+ };
746
+
747
+ export const runVocabularyRegrade = (params: {
748
+ readonly apply?: boolean;
749
+ readonly includeEntries?: 'actionable' | 'all';
750
+ readonly plan: VocabularyRegradePlan;
751
+ readonly root: string;
752
+ }): Result<RegradeReport | null, InternalError | ValidationError> => {
753
+ const planValidation = validateVocabularyPlan(params.plan);
754
+ if (planValidation.isErr()) {
755
+ return planValidation;
756
+ }
757
+
758
+ const collected = collectDownstreamSources(params.root, {
759
+ extensions: params.plan.scope?.extensions ?? VOCABULARY_SOURCE_EXTENSIONS,
760
+ ...(params.plan.scope?.exclude === undefined
761
+ ? {}
762
+ : { exclude: params.plan.scope.exclude }),
763
+ ...(params.plan.scope?.ignoredDirectories === undefined
764
+ ? {}
765
+ : { ignoredDirectories: params.plan.scope.ignoredDirectories }),
766
+ } satisfies DownstreamCollectionOptions);
767
+ if (collected === null) {
768
+ return Result.ok(null);
769
+ }
770
+
771
+ const files: SourceFile[] = [];
772
+ const skipped: SkippedSource[] = [...collected.skipped];
773
+ for (const file of collected.files) {
774
+ try {
775
+ files.push({
776
+ absolutePath: file.absolutePath,
777
+ path: file.path,
778
+ source: readFileSync(file.absolutePath, 'utf8'),
779
+ });
780
+ } catch {
781
+ skipped.push({ path: file.path, reason: 'unreadable-file' });
782
+ }
783
+ }
784
+
785
+ const dryRunEvaluation = buildVocabularyEvaluation({
786
+ apply: false,
787
+ files,
788
+ plan: params.plan,
789
+ root: params.root,
790
+ skipped,
791
+ });
792
+ let reportEvaluation = dryRunEvaluation;
793
+ let applySummary: RegradeApplySummary | undefined;
794
+
795
+ if (params.apply === true) {
796
+ const applyResult = applyVocabularyEvaluation(files, dryRunEvaluation);
797
+ if (applyResult.isErr()) {
798
+ return applyResult;
799
+ }
800
+ applySummary = applyResult.value;
801
+ const appliedFiles = files.map((file) => ({
802
+ ...file,
803
+ source: readFileSync(file.absolutePath, 'utf8'),
804
+ }));
805
+ reportEvaluation = buildVocabularyEvaluation({
806
+ apply: true,
807
+ files: appliedFiles,
808
+ plan: params.plan,
809
+ root: params.root,
810
+ skipped,
811
+ });
812
+ }
813
+
814
+ const entrySelection = params.includeEntries ?? 'actionable';
815
+ const reportEntries = reportEvaluation.entries;
816
+ const actionableEntries = reportEntries.filter(
817
+ (entry) => entry.outcome === 'rewrite' || entry.outcome === 'needs-review'
818
+ );
819
+ const reportSkippedByReason = skippedByReason(reportEvaluation.skipped);
820
+ const report: RegradeReport = {
821
+ entries: entrySelection === 'all' ? reportEntries : actionableEntries,
822
+ matched: actionableEntries.length,
823
+ review: reportEntries.filter((entry) => entry.outcome === 'needs-review')
824
+ .length,
825
+ rewritten: reportEntries.filter((entry) => entry.outcome === 'rewrite')
826
+ .length,
827
+ root: collected.root,
828
+ run:
829
+ applySummary === undefined
830
+ ? reportEvaluation.run
831
+ : {
832
+ ...reportEvaluation.run,
833
+ report: {
834
+ ...reportEvaluation.run.report,
835
+ applied: applySummary.applied,
836
+ filesChanged: applySummary.filesChanged,
837
+ },
838
+ },
839
+ scan: buildRegradeScanSummary({
840
+ matchedPaths: actionableEntries.map((entry) => entry.path),
841
+ occurrencePaths: reportEvaluation.occurrences.map(
842
+ (occurrence) => occurrence.path
843
+ ),
844
+ scanned: reportEvaluation.scanned,
845
+ skipped: reportEvaluation.skipped.length,
846
+ skippedByReason: reportSkippedByReason,
847
+ }),
848
+ scanned: reportEvaluation.scanned,
849
+ selectedClassIds: [
850
+ params.plan.id ?? `vocabulary:${params.plan.from}->${params.plan.to}`,
851
+ ],
852
+ skipped: reportEvaluation.skipped.length,
853
+ skipsByReason: reportSkippedByReason,
854
+ unknownClassIds: [],
855
+ };
856
+
857
+ return Result.ok(
858
+ applySummary === undefined ? report : withApplySummary(report, applySummary)
859
+ );
860
+ };
861
+
862
+ const vocabularyPreserveRuleSchema = z.object({
863
+ paths: z
864
+ .array(z.string())
865
+ .optional()
866
+ .describe('Root-relative path patterns where the preserve rule applies'),
867
+ pattern: z.string().describe('Regex or literal pattern to preserve'),
868
+ reason: z.string().optional().describe('Why this form is preserved'),
869
+ });
870
+
871
+ const vocabularyRegradeScopeSchema = z.object({
872
+ exclude: z
873
+ .array(z.string())
874
+ .optional()
875
+ .describe('Root-relative path patterns to exclude from this regrade'),
876
+ extensions: z
877
+ .array(z.string())
878
+ .optional()
879
+ .describe('Source file extensions to scan for this regrade'),
880
+ ignoredDirectories: z
881
+ .array(z.string())
882
+ .optional()
883
+ .describe(
884
+ 'Deprecated compatibility override for legacy plans. Use exclude globs for new plans.'
885
+ ),
886
+ include: z
887
+ .array(z.string())
888
+ .optional()
889
+ .describe('Root-relative path patterns to include in this regrade'),
890
+ });
891
+
892
+ export const vocabularyRegradePlanSchema = z.object({
893
+ from: z.string().min(1).describe('Source vocabulary term or phrase'),
894
+ id: z.string().optional().describe('Stable authored regrade plan id'),
895
+ intent: z.string().optional().describe('Human-authored migration intent'),
896
+ kind: z.literal('vocabulary').describe('Regrade plan kind'),
897
+ overrides: z
898
+ .record(z.string().min(1), z.string().min(1))
899
+ .optional()
900
+ .describe('Explicit source-form to target-form mappings'),
901
+ preserve: z
902
+ .array(vocabularyPreserveRuleSchema)
903
+ .optional()
904
+ .describe('Forms or contexts that are intentionally preserved'),
905
+ scope: vocabularyRegradeScopeSchema
906
+ .optional()
907
+ .describe('Source scope for this regrade plan'),
908
+ to: z.string().min(1).describe('Target vocabulary term or phrase'),
909
+ });
910
+
911
+ export const vocabularyRegradeRunOutput = z.object({
912
+ ledger: z
913
+ .object({
914
+ cycle: z.number().describe('Observed regrade run cycle'),
915
+ forms: z
916
+ .record(z.string(), z.enum(['deferred', 'modified', 'skipped']))
917
+ .describe('Observed per-form triage verdicts for this run'),
918
+ occurrences: z
919
+ .array(
920
+ z.object({
921
+ column: z.number().describe('One-based source column'),
922
+ context: z.string().describe('Source-line context'),
923
+ end: z.number().describe('Source end offset'),
924
+ form: z.string().describe('Matched vocabulary form'),
925
+ line: z.number().describe('One-based source line'),
926
+ path: z.string().describe('Root-relative POSIX path'),
927
+ reason: z.string().describe('Why the occurrence got this verdict'),
928
+ replacement: z
929
+ .string()
930
+ .optional()
931
+ .describe('Replacement text for modified verdicts'),
932
+ start: z.number().describe('Source start offset'),
933
+ verdict: z
934
+ .enum(['deferred', 'modified', 'skipped'])
935
+ .describe('Occurrence-level verdict'),
936
+ })
937
+ )
938
+ .describe('Observed occurrence-level ledger for this run'),
939
+ })
940
+ .describe('Observed run ledger'),
941
+ plan: vocabularyRegradePlanSchema.describe('Authored regrade plan'),
942
+ report: z
943
+ .object({
944
+ applied: z.number().describe('Modified occurrences applied to disk'),
945
+ deferred: z.number().describe('Deferred occurrence count'),
946
+ filesChanged: z.number().describe('Distinct files changed on disk'),
947
+ gate: z
948
+ .object({
949
+ reasons: z.array(z.string()).describe('Open-gate reasons'),
950
+ remaining: z.number().describe('Unresolved occurrence count'),
951
+ status: z
952
+ .enum(['green', 'open'])
953
+ .describe('Whether the run is complete'),
954
+ })
955
+ .describe('Completion gate derived from the run ledger'),
956
+ modified: z.number().describe('Modified occurrence count'),
957
+ open: z
958
+ .number()
959
+ .describe(
960
+ 'Deferred or unapplied modified occurrences holding the gate open'
961
+ ),
962
+ skipped: z.number().describe('Skipped occurrence count'),
963
+ })
964
+ .describe('Projected run report'),
965
+ });