@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,1677 @@
1
+ import {
2
+ InternalError,
3
+ Result,
4
+ ValidationError,
5
+ deriveSafePath,
6
+ escapeRegExp,
7
+ matchesAnyPathGlob,
8
+ } from '@ontrails/core';
9
+ import type { Result as TrailsResult } from '@ontrails/core';
10
+ import { createHash } from 'node:crypto';
11
+ import {
12
+ existsSync,
13
+ mkdirSync,
14
+ readFileSync,
15
+ renameSync,
16
+ statSync,
17
+ writeFileSync,
18
+ } from 'node:fs';
19
+ import { dirname, extname, posix } from 'node:path';
20
+
21
+ import { createAstStringLiteralRenameClass } from './ast-rewrite.js';
22
+ import {
23
+ DEFAULT_IGNORED_DIRECTORIES,
24
+ collectDownstreamSources,
25
+ } from './collect.js';
26
+ import type {
27
+ RegradeClassResult,
28
+ RegradeReport,
29
+ RegradeReportEntry,
30
+ } from './report.js';
31
+ import { buildRegradeScanSummary } from './scan-summary.js';
32
+ import type { DownstreamSourceCollection } from './collect.js';
33
+ import {
34
+ deriveVocabularyFormProposals,
35
+ vocabularyRewriteFormsForPlan,
36
+ } from './vocabulary.js';
37
+ import type {
38
+ VocabularyFileRename,
39
+ VocabularyFileRenameEvidence,
40
+ VocabularyRegradePlan,
41
+ VocabularyRegradeScope,
42
+ } from './vocabulary.js';
43
+
44
+ /**
45
+ * A review-only file move proposed from a minimal vocabulary seed.
46
+ *
47
+ * @example
48
+ * ```ts
49
+ * const candidate: FileRenameCandidate = {
50
+ * evidence: ['docs/legacy-guide.md'],
51
+ * from: 'docs/legacy-guide.md',
52
+ * to: 'docs/current-guide.md',
53
+ * };
54
+ * ```
55
+ */
56
+ export interface FileRenameCandidate extends VocabularyFileRename {
57
+ readonly evidence: readonly string[];
58
+ }
59
+
60
+ const fileRenameSourceExtensions = Object.freeze([
61
+ '.cjs',
62
+ '.cts',
63
+ '.js',
64
+ '.jsx',
65
+ '.json',
66
+ '.jsonc',
67
+ '.md',
68
+ '.mdx',
69
+ '.mjs',
70
+ '.mts',
71
+ '.ts',
72
+ '.tsx',
73
+ '.txt',
74
+ '.yaml',
75
+ '.yml',
76
+ ]);
77
+
78
+ const astSourceExtensions = new Set([
79
+ '.cjs',
80
+ '.cts',
81
+ '.js',
82
+ '.jsx',
83
+ '.mjs',
84
+ '.mts',
85
+ '.ts',
86
+ '.tsx',
87
+ ]);
88
+
89
+ const emittedModuleExtensions = new Map([
90
+ ['.cts', '.cjs'],
91
+ ['.mts', '.mjs'],
92
+ ['.ts', '.js'],
93
+ ['.tsx', '.js'],
94
+ ]);
95
+
96
+ interface ReferenceMapping {
97
+ readonly from: string;
98
+ readonly moduleSpecifierOnly?: boolean;
99
+ readonly renameIndex: number;
100
+ readonly to: string;
101
+ }
102
+
103
+ interface AmbiguousReferenceMapping {
104
+ readonly from: string;
105
+ readonly renameIndexes: readonly number[];
106
+ }
107
+
108
+ interface MutableEvidence {
109
+ deferred: number;
110
+ historical: number;
111
+ preserved: number;
112
+ rewritten: number;
113
+ skipped: number;
114
+ }
115
+
116
+ const emptyEvidence = (): MutableEvidence => ({
117
+ deferred: 0,
118
+ historical: 0,
119
+ preserved: 0,
120
+ rewritten: 0,
121
+ skipped: 0,
122
+ });
123
+
124
+ const updateEvidence = (
125
+ evidence: readonly MutableEvidence[],
126
+ index: number,
127
+ update: (item: MutableEvidence) => void
128
+ ): void => {
129
+ const item = evidence[index];
130
+ if (item !== undefined) {
131
+ update(item);
132
+ }
133
+ };
134
+
135
+ /**
136
+ * Report and per-move evidence produced by a governed file rename pass.
137
+ *
138
+ * @example
139
+ * ```ts
140
+ * const run: FileRenameRegradeRun = result.value;
141
+ * console.log(run.evidence[0]?.rewritten);
142
+ * ```
143
+ */
144
+ export interface FileRenameRegradeRun {
145
+ readonly changedPaths: readonly string[];
146
+ readonly evidence: readonly VocabularyFileRenameEvidence[];
147
+ readonly occurrencePaths: readonly string[];
148
+ readonly policyOccurrencePaths: readonly string[];
149
+ readonly report: RegradeReport;
150
+ readonly sourceStateHash: string;
151
+ }
152
+
153
+ const asError = (error: unknown): Error =>
154
+ error instanceof Error ? error : new Error(String(error));
155
+
156
+ const normalizeRenamePath = (path: string): string =>
157
+ posix.normalize(path.replaceAll('\\', '/'));
158
+
159
+ const compareFileRenameCodeUnits = (left: string, right: string): number => {
160
+ if (left < right) {
161
+ return -1;
162
+ }
163
+ if (left > right) {
164
+ return 1;
165
+ }
166
+ return 0;
167
+ };
168
+
169
+ const fileRenameSourceStateHash = (params: {
170
+ readonly apply: boolean;
171
+ readonly collected: DownstreamSourceCollection;
172
+ readonly renames: readonly VocabularyFileRename[];
173
+ readonly resolved: readonly ResolvedFileRename[];
174
+ }): TrailsResult<string, Error> => {
175
+ const unreadable = params.collected.skipped
176
+ .filter(
177
+ (entry) =>
178
+ entry.reason === 'unreadable-file' ||
179
+ entry.reason === 'unreadable-directory'
180
+ )
181
+ .map((entry) => entry.path);
182
+ if (unreadable.length > 0) {
183
+ return Result.err(
184
+ new ValidationError('File rename Regrade sources must all be readable.', {
185
+ context: { paths: unreadable },
186
+ })
187
+ );
188
+ }
189
+ const sourceFiles = new Map(
190
+ params.collected.files.map((file) => [file.path, file])
191
+ );
192
+ for (const [index, rename] of params.renames.entries()) {
193
+ const resolved = params.resolved[index];
194
+ if (resolved === undefined) {
195
+ return Result.err(
196
+ new InternalError('File rename Regrade endpoint was not resolved.')
197
+ );
198
+ }
199
+ const useTarget = params.apply || resolved.alreadyApplied;
200
+ const path = normalizeRenamePath(useTarget ? rename.to : rename.from);
201
+ sourceFiles.set(path, {
202
+ absolutePath: useTarget ? resolved.to : resolved.from,
203
+ path,
204
+ });
205
+ }
206
+ const sources: { readonly bytes: string; readonly path: string }[] = [];
207
+ for (const file of [...sourceFiles.values()].toSorted((left, right) =>
208
+ compareFileRenameCodeUnits(left.path, right.path)
209
+ )) {
210
+ try {
211
+ sources.push({
212
+ bytes: readFileSync(file.absolutePath).toString('base64'),
213
+ path: file.path,
214
+ });
215
+ } catch (error) {
216
+ return Result.err(
217
+ new ValidationError(
218
+ 'File rename Regrade sources must all be readable.',
219
+ {
220
+ ...(error instanceof Error ? { cause: error } : {}),
221
+ context: { paths: [file.path] },
222
+ }
223
+ )
224
+ );
225
+ }
226
+ }
227
+ return Result.ok(
228
+ createHash('sha256').update(JSON.stringify({ sources })).digest('hex')
229
+ );
230
+ };
231
+
232
+ const openedPolicyDirectories = (
233
+ scope: VocabularyRegradeScope | undefined
234
+ ): readonly string[] =>
235
+ DEFAULT_IGNORED_DIRECTORIES.filter((directory) =>
236
+ scope?.policyClassified?.some((policy) =>
237
+ policy.paths.some((pattern) => pattern.split('/').includes(directory))
238
+ )
239
+ );
240
+
241
+ const policyForPath = (
242
+ path: string,
243
+ scope: VocabularyRegradeScope | undefined
244
+ ) =>
245
+ scope?.policyClassified?.find((policy) =>
246
+ matchesAnyPathGlob(path, policy.paths)
247
+ );
248
+
249
+ const importerRenameForPath = (params: {
250
+ readonly path: string;
251
+ readonly renames: readonly VocabularyFileRename[];
252
+ readonly resolved: readonly ResolvedFileRename[];
253
+ readonly useTargetPaths: boolean;
254
+ }): VocabularyFileRename | undefined => {
255
+ const path = normalizeRenamePath(params.path);
256
+ const incomingIndex = params.renames.findIndex(
257
+ (rename) => normalizeRenamePath(rename.to) === path
258
+ );
259
+ const outgoingIndex = params.renames.findIndex(
260
+ (rename) => normalizeRenamePath(rename.from) === path
261
+ );
262
+ if (params.useTargetPaths) {
263
+ return params.renames[incomingIndex];
264
+ }
265
+ if (
266
+ incomingIndex !== -1 &&
267
+ params.resolved[incomingIndex]?.alreadyApplied === true
268
+ ) {
269
+ return params.renames[incomingIndex];
270
+ }
271
+ if (
272
+ outgoingIndex !== -1 &&
273
+ params.resolved[outgoingIndex]?.alreadyApplied !== true
274
+ ) {
275
+ return params.renames[outgoingIndex];
276
+ }
277
+ return params.renames[incomingIndex === -1 ? outgoingIndex : incomingIndex];
278
+ };
279
+
280
+ const sourcePathForMovedTarget = (
281
+ path: string,
282
+ renames: readonly VocabularyFileRename[]
283
+ ): string =>
284
+ normalizeRenamePath(
285
+ renames.find(
286
+ (rename) => normalizeRenamePath(rename.to) === normalizeRenamePath(path)
287
+ )?.from ?? path
288
+ );
289
+
290
+ const relativeReferenceTarget = (path: string): string =>
291
+ path.startsWith('.') ? path : `./${path}`;
292
+
293
+ const hasAstSourceExtensions = (from: string, to: string): boolean =>
294
+ astSourceExtensions.has(extname(from)) &&
295
+ astSourceExtensions.has(extname(to));
296
+
297
+ const preserveVocabularyCase = (
298
+ sourceForm: string,
299
+ replacement: string
300
+ ): string => {
301
+ if (sourceForm.toUpperCase() === sourceForm) {
302
+ return replacement.toUpperCase();
303
+ }
304
+ const first = sourceForm.at(0);
305
+ return first !== undefined && first.toUpperCase() === first
306
+ ? replacement.at(0)?.toUpperCase() + replacement.slice(1)
307
+ : replacement;
308
+ };
309
+
310
+ const deriveVocabularyText = (
311
+ source: string,
312
+ plan: VocabularyRegradePlan
313
+ ): string => {
314
+ let derived = source;
315
+ const safeForms = vocabularyRewriteFormsForPlan(plan).toSorted(
316
+ ([left], [right]) => right.length - left.length
317
+ );
318
+ for (const [from, to] of safeForms) {
319
+ derived = derived.replaceAll(
320
+ new RegExp(
321
+ `(?<![A-Za-z0-9_$-])${escapeRegExp(from)}(?![A-Za-z0-9_$-])`,
322
+ plan.caseSensitive === true ? 'gu' : 'giu'
323
+ ),
324
+ (matched) =>
325
+ plan.caseSensitive === true ? to : preserveVocabularyCase(matched, to)
326
+ );
327
+ }
328
+ return derived;
329
+ };
330
+
331
+ const astReferenceMappings = (
332
+ relativeFrom: string,
333
+ relativeTo: string,
334
+ renameIndex: number
335
+ ): readonly ReferenceMapping[] => {
336
+ if (!hasAstSourceExtensions(relativeFrom, relativeTo)) {
337
+ return [];
338
+ }
339
+ const fromExtension = extname(relativeFrom);
340
+ const toExtension = extname(relativeTo);
341
+ const extensionlessFrom = relativeFrom.slice(0, -fromExtension.length);
342
+ const extensionlessTo = relativeTo.slice(0, -toExtension.length);
343
+ const mappings: ReferenceMapping[] = [
344
+ {
345
+ from: relativeReferenceTarget(extensionlessFrom),
346
+ moduleSpecifierOnly: true,
347
+ renameIndex,
348
+ to: relativeReferenceTarget(extensionlessTo),
349
+ },
350
+ ];
351
+ if (posix.basename(extensionlessFrom) === 'index') {
352
+ const indexTarget =
353
+ posix.basename(extensionlessTo) === 'index'
354
+ ? posix.dirname(extensionlessTo)
355
+ : extensionlessTo;
356
+ mappings.push({
357
+ from: relativeReferenceTarget(posix.dirname(extensionlessFrom)),
358
+ moduleSpecifierOnly: true,
359
+ renameIndex,
360
+ to: relativeReferenceTarget(indexTarget),
361
+ });
362
+ }
363
+ const emittedFromExtension = emittedModuleExtensions.get(fromExtension);
364
+ const emittedToExtension = emittedModuleExtensions.get(toExtension);
365
+ if (emittedFromExtension === undefined || emittedToExtension === undefined) {
366
+ return mappings;
367
+ }
368
+ const emittedFrom = `${extensionlessFrom}${emittedFromExtension}`;
369
+ const emittedTo = `${extensionlessTo}${emittedToExtension}`;
370
+ mappings.push({
371
+ from: relativeReferenceTarget(emittedFrom),
372
+ moduleSpecifierOnly: true,
373
+ renameIndex,
374
+ to: relativeReferenceTarget(emittedTo),
375
+ });
376
+ return mappings;
377
+ };
378
+
379
+ const referenceMappingsForSourcePath = (params: {
380
+ readonly finalDirectory: string;
381
+ readonly rename: VocabularyFileRename;
382
+ readonly renameIndex: number;
383
+ readonly sourceDirectory: string;
384
+ readonly sourcePath: string;
385
+ }): readonly ReferenceMapping[] => {
386
+ if (
387
+ normalizeRenamePath(params.sourcePath) ===
388
+ normalizeRenamePath(params.rename.to)
389
+ ) {
390
+ return [];
391
+ }
392
+ const relativeFrom = posix.relative(
393
+ params.sourceDirectory,
394
+ params.sourcePath
395
+ );
396
+ const relativeTo = posix.relative(params.finalDirectory, params.rename.to);
397
+ const mappings: ReferenceMapping[] = [
398
+ {
399
+ from: params.sourcePath,
400
+ renameIndex: params.renameIndex,
401
+ to: params.rename.to,
402
+ },
403
+ { from: relativeFrom, renameIndex: params.renameIndex, to: relativeTo },
404
+ ...astReferenceMappings(relativeFrom, relativeTo, params.renameIndex),
405
+ ];
406
+ if (!relativeFrom.startsWith('.')) {
407
+ mappings.push({
408
+ from: `./${relativeFrom}`,
409
+ renameIndex: params.renameIndex,
410
+ to: relativeReferenceTarget(relativeTo),
411
+ });
412
+ }
413
+ return mappings;
414
+ };
415
+
416
+ const referenceMappingsForPath = (
417
+ path: string,
418
+ renames: readonly VocabularyFileRename[],
419
+ excludedRenameIndexes: ReadonlySet<number> = new Set(),
420
+ resolved: readonly ResolvedFileRename[] = [],
421
+ useTargetPaths = false,
422
+ vocabularyPlan?: VocabularyRegradePlan
423
+ ): {
424
+ readonly ambiguous: readonly AmbiguousReferenceMapping[];
425
+ readonly safe: readonly ReferenceMapping[];
426
+ } => {
427
+ const importerRename = importerRenameForPath({
428
+ path,
429
+ renames,
430
+ resolved,
431
+ useTargetPaths,
432
+ });
433
+ const sourceImporterPath = normalizeRenamePath(importerRename?.from ?? path);
434
+ const finalImporterPath = normalizeRenamePath(importerRename?.to ?? path);
435
+ const sourceDirectory = posix.dirname(sourceImporterPath);
436
+ const finalDirectory = posix.dirname(finalImporterPath);
437
+ const mappings: ReferenceMapping[] = [];
438
+ for (const [renameIndex, authoredRename] of renames.entries()) {
439
+ if (excludedRenameIndexes.has(renameIndex)) {
440
+ continue;
441
+ }
442
+ const rename = {
443
+ from: normalizeRenamePath(authoredRename.from),
444
+ to: normalizeRenamePath(authoredRename.to),
445
+ };
446
+ const sourcePaths = [
447
+ rename.from,
448
+ ...(vocabularyPlan === undefined
449
+ ? []
450
+ : [deriveVocabularyText(rename.from, vocabularyPlan)]),
451
+ ].filter((value, index, values) => values.indexOf(value) === index);
452
+ for (const sourcePath of sourcePaths) {
453
+ mappings.push(
454
+ ...referenceMappingsForSourcePath({
455
+ finalDirectory,
456
+ rename,
457
+ renameIndex,
458
+ sourceDirectory,
459
+ sourcePath,
460
+ })
461
+ );
462
+ }
463
+ }
464
+
465
+ const targetsByForm = new Map<string, Set<string>>();
466
+ for (const mapping of mappings) {
467
+ const targets = targetsByForm.get(mapping.from) ?? new Set<string>();
468
+ targets.add(mapping.to);
469
+ targetsByForm.set(mapping.from, targets);
470
+ }
471
+ const ambiguous = [...targetsByForm.entries()]
472
+ .filter(([from, targets]) => from.length > 0 && targets.size > 1)
473
+ .map(([from]) => ({
474
+ from,
475
+ renameIndexes: [
476
+ ...new Set(
477
+ mappings
478
+ .filter((mapping) => mapping.from === from)
479
+ .map((mapping) => mapping.renameIndex)
480
+ ),
481
+ ],
482
+ }));
483
+ const indexesByBasename = new Map<string, number[]>();
484
+ for (const [renameIndex, rename] of renames.entries()) {
485
+ const basename = posix.basename(rename.from);
486
+ const indexes = indexesByBasename.get(basename) ?? [];
487
+ indexes.push(renameIndex);
488
+ indexesByBasename.set(basename, indexes);
489
+ }
490
+ for (const [basename, renameIndexes] of indexesByBasename) {
491
+ if (renameIndexes.length > 1 && !targetsByForm.has(basename)) {
492
+ ambiguous.push({ from: basename, renameIndexes });
493
+ }
494
+ }
495
+ const safe = mappings
496
+ .filter(
497
+ (mapping, index) =>
498
+ mapping.from.length > 0 &&
499
+ targetsByForm.get(mapping.from)?.size === 1 &&
500
+ mappings.findIndex(
501
+ (candidate) =>
502
+ candidate.from === mapping.from &&
503
+ candidate.renameIndex === mapping.renameIndex
504
+ ) === index
505
+ )
506
+ .toSorted((left, right) => right.from.length - left.from.length);
507
+ return { ambiguous, safe };
508
+ };
509
+
510
+ const countText = (source: string, text: string): number => {
511
+ if (text.length === 0) {
512
+ return 0;
513
+ }
514
+ let count = 0;
515
+ let offset = 0;
516
+ while (offset < source.length) {
517
+ const index = source.indexOf(text, offset);
518
+ if (index === -1) {
519
+ break;
520
+ }
521
+ count += 1;
522
+ offset = index + text.length;
523
+ }
524
+ return count;
525
+ };
526
+
527
+ const referencePattern = (text: string): RegExp =>
528
+ new RegExp(
529
+ `(?<![A-Za-z0-9_./-])${escapeRegExp(text)}(?![A-Za-z0-9_./-])`,
530
+ 'gu'
531
+ );
532
+
533
+ const countReference = (source: string, text: string): number =>
534
+ [...source.matchAll(referencePattern(text))].length;
535
+
536
+ const containsExactStringLiteral = (source: string, value: string): boolean =>
537
+ [`'${value}'`, `"${value}"`, `\`${value}\``].some((literal) =>
538
+ source.includes(literal)
539
+ );
540
+
541
+ const countExactStringLiterals = (source: string, value: string): number =>
542
+ [`'${value}'`, `"${value}"`, `\`${value}\``].reduce(
543
+ (count, literal) => count + countText(source, literal),
544
+ 0
545
+ );
546
+
547
+ const reviewedStringLiteralCount = (params: {
548
+ readonly mapping: ReferenceMapping;
549
+ readonly result: RegradeClassResult;
550
+ readonly source: string;
551
+ }): number => {
552
+ if (params.result.kind !== 'needs-review') {
553
+ return 0;
554
+ }
555
+ if (params.result.reviewDetails !== undefined) {
556
+ return (
557
+ params.result.reviewDetails.length +
558
+ (params.mapping.moduleSpecifierOnly === true
559
+ ? countExactStringLiterals(params.source, params.mapping.from)
560
+ : 0)
561
+ );
562
+ }
563
+ if (
564
+ params.mapping.moduleSpecifierOnly !== true ||
565
+ (params.result.reason?.startsWith('ast-rewrite-') === true &&
566
+ !containsExactStringLiteral(params.source, params.mapping.from))
567
+ ) {
568
+ return 0;
569
+ }
570
+ return countExactStringLiterals(params.source, params.mapping.from);
571
+ };
572
+
573
+ const collisionFreePlaceholders = (
574
+ source: string,
575
+ mappings: readonly ReferenceMapping[],
576
+ label: string
577
+ ): readonly string[] => {
578
+ let generation = 0;
579
+ while (true) {
580
+ const placeholders: string[] = [];
581
+ for (const [index] of mappings.entries()) {
582
+ placeholders.push(`__TRAILS_${label}_${generation}_${index}__`);
583
+ }
584
+ if (
585
+ placeholders.every(
586
+ (placeholder) =>
587
+ !source.includes(placeholder) &&
588
+ mappings.every(
589
+ (mapping) =>
590
+ !mapping.from.includes(placeholder) &&
591
+ !mapping.to.includes(placeholder)
592
+ )
593
+ )
594
+ ) {
595
+ return placeholders;
596
+ }
597
+ generation += 1;
598
+ }
599
+ };
600
+
601
+ const replaceSimultaneously = (
602
+ source: string,
603
+ mappings: readonly ReferenceMapping[],
604
+ evidence: readonly MutableEvidence[]
605
+ ): string => {
606
+ let nextSource = source;
607
+ const placeholders = collisionFreePlaceholders(
608
+ source,
609
+ mappings,
610
+ 'FILE_RENAME_REFERENCE'
611
+ );
612
+ for (const [index, mapping] of mappings.entries()) {
613
+ const count = countReference(nextSource, mapping.from);
614
+ if (count === 0) {
615
+ continue;
616
+ }
617
+ nextSource = nextSource.replaceAll(
618
+ referencePattern(mapping.from),
619
+ placeholders[index] ?? ''
620
+ );
621
+ updateEvidence(evidence, mapping.renameIndex, (item) => {
622
+ item.rewritten += count;
623
+ });
624
+ }
625
+ for (const [index, mapping] of mappings.entries()) {
626
+ nextSource = nextSource.replaceAll(placeholders[index] ?? '', mapping.to);
627
+ }
628
+ return nextSource;
629
+ };
630
+
631
+ const rewriteAstReferences = (
632
+ source: string,
633
+ path: string,
634
+ mappings: readonly ReferenceMapping[],
635
+ evidence: readonly MutableEvidence[]
636
+ ): { readonly deferred: boolean; readonly source: string } => {
637
+ let nextSource = source;
638
+ const placeholders = collisionFreePlaceholders(
639
+ source,
640
+ mappings,
641
+ 'FILE_RENAME_LITERAL'
642
+ );
643
+ const reviewedModuleSpecifierRenames = new Map<number, number>();
644
+ for (const [index, mapping] of mappings.entries()) {
645
+ const cls = createAstStringLiteralRenameClass({
646
+ allowModuleSpecifier: true,
647
+ from: mapping.from,
648
+ id: `file-reference:${mapping.from}->${mapping.to}`,
649
+ match: 'exact',
650
+ ...(mapping.moduleSpecifierOnly === true
651
+ ? { moduleSpecifierOnly: true }
652
+ : {}),
653
+ to: placeholders[index] ?? '',
654
+ });
655
+ const result = cls.apply(nextSource, { path });
656
+ if (result.kind === 'rewrite' && result.nextSource !== undefined) {
657
+ const { nextSource: rewrittenSource } = result;
658
+ const count = countText(rewrittenSource, placeholders[index] ?? '');
659
+ updateEvidence(evidence, mapping.renameIndex, (item) => {
660
+ item.rewritten += count;
661
+ });
662
+ nextSource = rewrittenSource;
663
+ } else {
664
+ const reviewed = reviewedStringLiteralCount({
665
+ mapping,
666
+ result,
667
+ source: nextSource,
668
+ });
669
+ if (reviewed === 0) {
670
+ continue;
671
+ }
672
+ reviewedModuleSpecifierRenames.set(
673
+ mapping.renameIndex,
674
+ (reviewedModuleSpecifierRenames.get(mapping.renameIndex) ?? 0) +
675
+ reviewed
676
+ );
677
+ }
678
+ }
679
+ for (const [index, mapping] of mappings.entries()) {
680
+ nextSource = nextSource.replaceAll(placeholders[index] ?? '', mapping.to);
681
+ }
682
+
683
+ let deferred = false;
684
+ for (const mapping of mappings) {
685
+ if (mapping.moduleSpecifierOnly === true) {
686
+ continue;
687
+ }
688
+ const remaining = countReference(nextSource, mapping.from);
689
+ if (remaining > 0) {
690
+ updateEvidence(evidence, mapping.renameIndex, (item) => {
691
+ item.deferred += remaining;
692
+ });
693
+ deferred = true;
694
+ }
695
+ }
696
+ for (const [renameIndex, count] of reviewedModuleSpecifierRenames) {
697
+ updateEvidence(evidence, renameIndex, (item) => {
698
+ item.deferred += count;
699
+ });
700
+ deferred = true;
701
+ }
702
+ return { deferred, source: nextSource };
703
+ };
704
+
705
+ interface ResolvedFileRename {
706
+ readonly alreadyApplied: boolean;
707
+ readonly from: string;
708
+ readonly to: string;
709
+ }
710
+
711
+ const orderRenamesForApply = <
712
+ T extends { readonly from: string; readonly to: string },
713
+ >(
714
+ renames: readonly T[]
715
+ ): TrailsResult<readonly T[], Error> => {
716
+ const successors = new Map<string, string[]>();
717
+ const indegree = new Map<string, number>();
718
+ const byFrom = new Map<string, T>();
719
+ for (const rename of renames) {
720
+ byFrom.set(rename.from, rename);
721
+ successors.set(rename.from, []);
722
+ indegree.set(rename.from, 0);
723
+ }
724
+ for (const rename of renames) {
725
+ if (!byFrom.has(rename.to)) {
726
+ continue;
727
+ }
728
+ // Vacate rename.to before writing into it.
729
+ successors.get(rename.to)?.push(rename.from);
730
+ indegree.set(rename.from, (indegree.get(rename.from) ?? 0) + 1);
731
+ }
732
+ const queue = renames
733
+ .map((rename) => rename.from)
734
+ .filter((from) => (indegree.get(from) ?? 0) === 0);
735
+ const ordered: T[] = [];
736
+ while (queue.length > 0) {
737
+ const from = queue.shift();
738
+ if (from === undefined) {
739
+ break;
740
+ }
741
+ const rename = byFrom.get(from);
742
+ if (rename !== undefined) {
743
+ ordered.push(rename);
744
+ }
745
+ for (const dependent of successors.get(from) ?? []) {
746
+ const nextDegree = (indegree.get(dependent) ?? 0) - 1;
747
+ indegree.set(dependent, nextDegree);
748
+ if (nextDegree === 0) {
749
+ queue.push(dependent);
750
+ }
751
+ }
752
+ }
753
+ if (ordered.length !== renames.length) {
754
+ return Result.err(
755
+ new ValidationError(
756
+ 'File rename map contains a cycle that cannot be applied safely.'
757
+ )
758
+ );
759
+ }
760
+ return Result.ok(ordered);
761
+ };
762
+
763
+ const isCompletedRenameChain = (
764
+ root: string,
765
+ rename: VocabularyFileRename,
766
+ renames: readonly VocabularyFileRename[]
767
+ ): boolean => {
768
+ const byFrom = new Map(
769
+ renames.map((candidate) => [normalizeRenamePath(candidate.from), candidate])
770
+ );
771
+ const byTo = new Map(
772
+ renames.map((candidate) => [normalizeRenamePath(candidate.to), candidate])
773
+ );
774
+ const visited = new Set<string>();
775
+ let first = rename;
776
+ while (byTo.has(normalizeRenamePath(first.from))) {
777
+ const from = normalizeRenamePath(first.from);
778
+ if (visited.has(from)) {
779
+ return false;
780
+ }
781
+ visited.add(from);
782
+ const predecessor = byTo.get(from);
783
+ if (predecessor === undefined) {
784
+ break;
785
+ }
786
+ first = predecessor;
787
+ }
788
+ const firstSource = deriveSafePath(root, first.from);
789
+ if (
790
+ firstSource.isErr() ||
791
+ (existsSync(firstSource.value) && statSync(firstSource.value).isFile())
792
+ ) {
793
+ return false;
794
+ }
795
+ visited.clear();
796
+ let current: VocabularyFileRename | undefined = first;
797
+ while (current !== undefined) {
798
+ const from = normalizeRenamePath(current.from);
799
+ if (visited.has(from)) {
800
+ return false;
801
+ }
802
+ visited.add(from);
803
+ const target = deriveSafePath(root, current.to);
804
+ if (
805
+ target.isErr() ||
806
+ !existsSync(target.value) ||
807
+ !statSync(target.value).isFile()
808
+ ) {
809
+ return false;
810
+ }
811
+ current = byFrom.get(normalizeRenamePath(current.to));
812
+ }
813
+ return true;
814
+ };
815
+
816
+ const validateRenames = (
817
+ root: string,
818
+ renames: readonly VocabularyFileRename[]
819
+ ): TrailsResult<readonly ResolvedFileRename[], Error> => {
820
+ const resolved: ResolvedFileRename[] = [];
821
+ const sources = new Set<string>();
822
+ const targets = new Set<string>();
823
+ const safeRenames: {
824
+ readonly from: string;
825
+ readonly rename: VocabularyFileRename;
826
+ readonly to: string;
827
+ }[] = [];
828
+ for (const rename of renames) {
829
+ const from = deriveSafePath(root, rename.from);
830
+ const to = deriveSafePath(root, rename.to);
831
+ if (from.isErr() || to.isErr()) {
832
+ return Result.err(
833
+ new ValidationError(
834
+ 'File rename paths must stay within the Regrade root.'
835
+ )
836
+ );
837
+ }
838
+ safeRenames.push({ from: from.value, rename, to: to.value });
839
+ }
840
+ const sourcePaths = new Set(safeRenames.map((rename) => rename.from));
841
+ for (const safeRename of safeRenames) {
842
+ const { rename } = safeRename;
843
+ if (safeRename.from === safeRename.to) {
844
+ return Result.err(
845
+ new ValidationError(
846
+ `File rename source and target are identical: "${rename.from}".`
847
+ )
848
+ );
849
+ }
850
+ if (sources.has(safeRename.from) || targets.has(safeRename.to)) {
851
+ return Result.err(
852
+ new ValidationError('File rename sources and targets must be unique.')
853
+ );
854
+ }
855
+ const sourceExists =
856
+ existsSync(safeRename.from) && statSync(safeRename.from).isFile();
857
+ const targetExists =
858
+ existsSync(safeRename.to) && statSync(safeRename.to).isFile();
859
+ const chainAlreadyApplied = isCompletedRenameChain(root, rename, renames);
860
+ if (!sourceExists && !targetExists) {
861
+ return Result.err(
862
+ new ValidationError(
863
+ `File rename source does not exist: "${rename.from}".`
864
+ )
865
+ );
866
+ }
867
+ // Intermediate targets may already exist when they are also a source in
868
+ // this map; those paths are vacated before the dependent move applies.
869
+ if (
870
+ sourceExists &&
871
+ targetExists &&
872
+ !sourcePaths.has(safeRename.to) &&
873
+ !chainAlreadyApplied
874
+ ) {
875
+ return Result.err(
876
+ new ValidationError(
877
+ `File rename target already exists: "${rename.to}".`
878
+ )
879
+ );
880
+ }
881
+ sources.add(safeRename.from);
882
+ targets.add(safeRename.to);
883
+ resolved.push({
884
+ alreadyApplied: chainAlreadyApplied || (!sourceExists && targetExists),
885
+ from: safeRename.from,
886
+ to: safeRename.to,
887
+ });
888
+ }
889
+
890
+ const ordered = orderRenamesForApply(safeRenames);
891
+ return ordered.isErr() ? ordered : Result.ok(resolved);
892
+ };
893
+
894
+ const rollbackResolvedRenames = (
895
+ renames: readonly ResolvedFileRename[]
896
+ ): void => {
897
+ for (const rename of renames.toReversed()) {
898
+ if (!existsSync(rename.to) || existsSync(rename.from)) {
899
+ continue;
900
+ }
901
+ mkdirSync(dirname(rename.from), { recursive: true });
902
+ renameSync(rename.to, rename.from);
903
+ }
904
+ };
905
+
906
+ const applyResolvedRenames = (
907
+ renames: readonly ResolvedFileRename[]
908
+ ): TrailsResult<readonly ResolvedFileRename[], Error> => {
909
+ const pending = renames.filter((rename) => !rename.alreadyApplied);
910
+ const ordered = orderRenamesForApply(pending);
911
+ if (ordered.isErr()) {
912
+ return ordered;
913
+ }
914
+ const applied: ResolvedFileRename[] = [];
915
+ try {
916
+ for (const rename of ordered.value) {
917
+ mkdirSync(dirname(rename.to), { recursive: true });
918
+ renameSync(rename.from, rename.to);
919
+ applied.push(rename);
920
+ }
921
+ return Result.ok(applied);
922
+ } catch (error) {
923
+ rollbackResolvedRenames(applied);
924
+ return Result.err(
925
+ new InternalError('Failed to apply governed file moves.', {
926
+ cause: asError(error),
927
+ })
928
+ );
929
+ }
930
+ };
931
+
932
+ const fileMoveNote = (
933
+ rename: VocabularyFileRename,
934
+ alreadyApplied: boolean,
935
+ apply: boolean
936
+ ): string => {
937
+ if (alreadyApplied) {
938
+ return `File already resides at governed target "${rename.to}".`;
939
+ }
940
+ return apply
941
+ ? `Moved "${rename.from}" to "${rename.to}" before rewriting references.`
942
+ : `Would move "${rename.from}" to "${rename.to}" before rewriting references.`;
943
+ };
944
+
945
+ const initialFileMoveEntries = (
946
+ renames: readonly VocabularyFileRename[],
947
+ resolved: readonly ResolvedFileRename[],
948
+ apply: boolean
949
+ ): RegradeReportEntry[] =>
950
+ renames.map((rename, index) => {
951
+ const alreadyApplied = resolved[index]?.alreadyApplied === true;
952
+ return {
953
+ classId: `file-rename:${rename.from}->${rename.to}`,
954
+ notes: [fileMoveNote(rename, alreadyApplied, apply)],
955
+ outcome: alreadyApplied || apply ? 'no-op' : 'rewrite',
956
+ path: alreadyApplied || apply ? rename.to : rename.from,
957
+ };
958
+ });
959
+
960
+ const recordPolicyReferences = (params: {
961
+ readonly evidence: readonly MutableEvidence[];
962
+ readonly mappings: ReturnType<typeof referenceMappingsForPath>;
963
+ readonly occurrencePaths: string[];
964
+ readonly path: string;
965
+ readonly preserved: boolean;
966
+ readonly source: string;
967
+ }): void => {
968
+ for (const mapping of params.mappings.safe) {
969
+ const count = countReference(params.source, mapping.from);
970
+ updateEvidence(params.evidence, mapping.renameIndex, (item) => {
971
+ item.historical += count;
972
+ if (params.preserved) {
973
+ item.preserved += count;
974
+ } else {
975
+ item.skipped += count;
976
+ }
977
+ });
978
+ params.occurrencePaths.push(
979
+ ...Array.from({ length: count }, () => params.path)
980
+ );
981
+ }
982
+ for (const mapping of params.mappings.ambiguous) {
983
+ const count = countReference(params.source, mapping.from);
984
+ for (const renameIndex of mapping.renameIndexes) {
985
+ updateEvidence(params.evidence, renameIndex, (item) => {
986
+ item.historical += count;
987
+ item.skipped += count;
988
+ });
989
+ }
990
+ params.occurrencePaths.push(
991
+ ...Array.from({ length: count }, () => params.path)
992
+ );
993
+ }
994
+ };
995
+
996
+ const recordAmbiguousReferences = (
997
+ source: string,
998
+ mappings: readonly AmbiguousReferenceMapping[],
999
+ evidence: readonly MutableEvidence[]
1000
+ ): boolean => {
1001
+ let deferred = false;
1002
+ for (const mapping of mappings) {
1003
+ const count = countReference(source, mapping.from);
1004
+ if (count === 0) {
1005
+ continue;
1006
+ }
1007
+ deferred = true;
1008
+ for (const renameIndex of mapping.renameIndexes) {
1009
+ updateEvidence(evidence, renameIndex, (item) => {
1010
+ item.deferred += count;
1011
+ });
1012
+ }
1013
+ }
1014
+ return deferred;
1015
+ };
1016
+
1017
+ const referenceEntry = (
1018
+ path: string,
1019
+ deferred: boolean
1020
+ ): RegradeReportEntry => ({
1021
+ classId: 'file-reference-closure',
1022
+ ...(deferred ? { reason: 'file-reference-context-unverified' } : {}),
1023
+ notes: [
1024
+ deferred
1025
+ ? 'A path-like occurrence was not an exact code string literal and requires review.'
1026
+ : 'Derived a safe reference rewrite from the final file rename map.',
1027
+ ],
1028
+ outcome: deferred ? 'needs-review' : 'rewrite',
1029
+ path,
1030
+ });
1031
+
1032
+ interface PlannedReferenceWrite {
1033
+ readonly absolutePath: string;
1034
+ readonly nextSource: string;
1035
+ readonly source: string;
1036
+ }
1037
+
1038
+ const applyReferenceWrites = (
1039
+ writes: readonly PlannedReferenceWrite[]
1040
+ ): TrailsResult<void, Error> => {
1041
+ const applied: PlannedReferenceWrite[] = [];
1042
+ try {
1043
+ for (const write of writes) {
1044
+ writeFileSync(write.absolutePath, write.nextSource, 'utf8');
1045
+ applied.push(write);
1046
+ }
1047
+ return Result.ok();
1048
+ } catch (error) {
1049
+ for (const write of applied.toReversed()) {
1050
+ try {
1051
+ writeFileSync(write.absolutePath, write.source, 'utf8');
1052
+ } catch {
1053
+ // Preserve the original apply failure; the caller still rolls back moves.
1054
+ }
1055
+ }
1056
+ return Result.err(
1057
+ new InternalError('Failed to apply governed file reference rewrites.', {
1058
+ cause: asError(error),
1059
+ })
1060
+ );
1061
+ }
1062
+ };
1063
+
1064
+ const rewriteReferenceFiles = (params: {
1065
+ readonly apply: boolean;
1066
+ readonly collected: DownstreamSourceCollection;
1067
+ readonly evidence: readonly MutableEvidence[];
1068
+ readonly excludedRenameIndexes?: ReadonlySet<number>;
1069
+ readonly renames: readonly VocabularyFileRename[];
1070
+ readonly resolved: readonly ResolvedFileRename[];
1071
+ readonly scope?: VocabularyRegradeScope;
1072
+ readonly vocabularyPlan?: VocabularyRegradePlan;
1073
+ }): TrailsResult<
1074
+ {
1075
+ readonly changedFiles: ReadonlySet<string>;
1076
+ readonly entries: readonly RegradeReportEntry[];
1077
+ readonly occurrencePaths: readonly string[];
1078
+ readonly policyOccurrencePaths: readonly string[];
1079
+ readonly writes: readonly PlannedReferenceWrite[];
1080
+ },
1081
+ Error
1082
+ > => {
1083
+ const changedFiles = new Set<string>();
1084
+ const entries: RegradeReportEntry[] = [];
1085
+ const occurrencePaths: string[] = [];
1086
+ const policyOccurrencePaths: string[] = [];
1087
+ const writes: PlannedReferenceWrite[] = [];
1088
+ for (const file of params.collected.files) {
1089
+ const mappings = referenceMappingsForPath(
1090
+ file.path,
1091
+ params.renames,
1092
+ params.excludedRenameIndexes,
1093
+ params.resolved,
1094
+ params.apply,
1095
+ params.vocabularyPlan
1096
+ );
1097
+ if (mappings.safe.length === 0 && mappings.ambiguous.length === 0) {
1098
+ continue;
1099
+ }
1100
+ let source: string;
1101
+ try {
1102
+ source = readFileSync(file.absolutePath, 'utf8');
1103
+ } catch (error) {
1104
+ return Result.err(
1105
+ new InternalError(`Failed to read file reference in "${file.path}".`, {
1106
+ cause: asError(error),
1107
+ })
1108
+ );
1109
+ }
1110
+ const scopePath = params.apply
1111
+ ? sourcePathForMovedTarget(file.path, params.renames)
1112
+ : file.path;
1113
+ const policy = policyForPath(scopePath, params.scope);
1114
+ if (policy !== undefined) {
1115
+ const occurrenceStart = occurrencePaths.length;
1116
+ recordPolicyReferences({
1117
+ evidence: params.evidence,
1118
+ mappings,
1119
+ occurrencePaths,
1120
+ path: file.path,
1121
+ preserved: policy.disposition === 'explicit-preserve',
1122
+ source,
1123
+ });
1124
+ policyOccurrencePaths.push(...occurrencePaths.slice(occurrenceStart));
1125
+ continue;
1126
+ }
1127
+
1128
+ const evidenceBefore = params.evidence.reduce(
1129
+ (total, item) => total + item.deferred + item.rewritten,
1130
+ 0
1131
+ );
1132
+ const ambiguous = recordAmbiguousReferences(
1133
+ source,
1134
+ mappings.ambiguous,
1135
+ params.evidence
1136
+ );
1137
+ const result = astSourceExtensions.has(extname(file.path))
1138
+ ? rewriteAstReferences(source, file.path, mappings.safe, params.evidence)
1139
+ : {
1140
+ deferred: false,
1141
+ source: replaceSimultaneously(
1142
+ source,
1143
+ mappings.safe.filter(
1144
+ (mapping) => mapping.moduleSpecifierOnly !== true
1145
+ ),
1146
+ params.evidence
1147
+ ),
1148
+ };
1149
+ if (result.source === source && !result.deferred && !ambiguous) {
1150
+ continue;
1151
+ }
1152
+ const deferred = result.deferred || ambiguous;
1153
+ entries.push(referenceEntry(file.path, deferred));
1154
+ const occurrenceCount =
1155
+ params.evidence.reduce(
1156
+ (total, item) => total + item.deferred + item.rewritten,
1157
+ 0
1158
+ ) - evidenceBefore;
1159
+ occurrencePaths.push(
1160
+ ...Array.from({ length: occurrenceCount }, () => file.path)
1161
+ );
1162
+ if (result.source === source) {
1163
+ continue;
1164
+ }
1165
+ writes.push({
1166
+ absolutePath: file.absolutePath,
1167
+ nextSource: result.source,
1168
+ source,
1169
+ });
1170
+ changedFiles.add(file.path);
1171
+ }
1172
+ return Result.ok({
1173
+ changedFiles,
1174
+ entries,
1175
+ occurrencePaths,
1176
+ policyOccurrencePaths,
1177
+ writes,
1178
+ });
1179
+ };
1180
+
1181
+ const skippedCounts = (
1182
+ collected: DownstreamSourceCollection
1183
+ ): Readonly<Record<string, number>> =>
1184
+ Object.fromEntries(
1185
+ [...new Set(collected.skipped.map((entry) => entry.reason))].map(
1186
+ (reason) => [
1187
+ reason,
1188
+ collected.skipped.filter((entry) => entry.reason === reason).length,
1189
+ ]
1190
+ )
1191
+ );
1192
+
1193
+ const isGeneratedRegradeArtifactPath = (path: string): boolean =>
1194
+ /(?:^|\/)\.trails\/regrade\/.+\.json$/u.test(path);
1195
+
1196
+ const filterOpenedPolicyDirectories = (
1197
+ collected: DownstreamSourceCollection,
1198
+ opened: readonly string[],
1199
+ derivedTargetPaths: ReadonlySet<string>,
1200
+ scope: VocabularyRegradeScope | undefined,
1201
+ renames: readonly VocabularyFileRename[],
1202
+ apply: boolean
1203
+ ): DownstreamSourceCollection => {
1204
+ const files = collected.files.filter((file) => {
1205
+ const scopePath = apply
1206
+ ? sourcePathForMovedTarget(file.path, renames)
1207
+ : file.path;
1208
+ const insideOpenedDirectory = [file.path, scopePath].some((path) =>
1209
+ normalizeRenamePath(path)
1210
+ .split('/')
1211
+ .some((segment) => opened.includes(segment))
1212
+ );
1213
+ return (
1214
+ !insideOpenedDirectory ||
1215
+ derivedTargetPaths.has(normalizeRenamePath(file.path)) ||
1216
+ policyForPath(scopePath, scope) !== undefined
1217
+ );
1218
+ });
1219
+ const selected = new Set(files.map((file) => file.path));
1220
+ return {
1221
+ ...collected,
1222
+ files,
1223
+ skipped: [
1224
+ ...collected.skipped,
1225
+ ...collected.files
1226
+ .filter((file) => !selected.has(file.path))
1227
+ .map((file) => ({ path: file.path, reason: 'ignored-directory' })),
1228
+ ].toSorted((left, right) => left.path.localeCompare(right.path)),
1229
+ };
1230
+ };
1231
+
1232
+ const collectionIncludeForFileRenames = (params: {
1233
+ readonly apply: boolean;
1234
+ readonly include: readonly string[] | undefined;
1235
+ readonly renames: readonly VocabularyFileRename[];
1236
+ }): readonly string[] | undefined => {
1237
+ if (!params.apply || params.include === undefined) {
1238
+ return params.include;
1239
+ }
1240
+ return [
1241
+ ...params.include,
1242
+ ...params.renames
1243
+ .filter((rename) =>
1244
+ matchesAnyPathGlob(
1245
+ sourcePathForMovedTarget(rename.to, params.renames),
1246
+ params.include
1247
+ )
1248
+ )
1249
+ .map((rename) => rename.to),
1250
+ ];
1251
+ };
1252
+
1253
+ const collectionExcludeForFileRenames = (params: {
1254
+ readonly apply: boolean;
1255
+ readonly exclude: readonly string[] | undefined;
1256
+ readonly renames: readonly VocabularyFileRename[];
1257
+ }): readonly string[] | undefined => {
1258
+ if (!params.apply || params.exclude === undefined) {
1259
+ return params.exclude;
1260
+ }
1261
+ return [
1262
+ ...params.exclude,
1263
+ ...params.renames
1264
+ .filter((rename) =>
1265
+ matchesAnyPathGlob(
1266
+ sourcePathForMovedTarget(rename.to, params.renames),
1267
+ params.exclude
1268
+ )
1269
+ )
1270
+ .map((rename) => rename.to),
1271
+ ];
1272
+ };
1273
+
1274
+ const normalizeExtension = (extension: string): string =>
1275
+ extension === '' || extension.startsWith('.') ? extension : `.${extension}`;
1276
+
1277
+ const deriveCollectionExtensionsForFileRenames = (params: {
1278
+ readonly apply: boolean;
1279
+ readonly extensions: readonly string[];
1280
+ readonly renames: readonly VocabularyFileRename[];
1281
+ }): {
1282
+ readonly extensions: readonly string[];
1283
+ readonly derivedTargetPaths: ReadonlySet<string>;
1284
+ } => {
1285
+ const sourceExtensions = new Set(params.extensions.map(normalizeExtension));
1286
+ if (!params.apply || sourceExtensions.size === 0) {
1287
+ return {
1288
+ derivedTargetPaths: new Set(),
1289
+ extensions: params.extensions,
1290
+ };
1291
+ }
1292
+ const derivedTargetPaths = new Set(
1293
+ params.renames
1294
+ .filter((rename) =>
1295
+ sourceExtensions.has(
1296
+ extname(sourcePathForMovedTarget(rename.to, params.renames))
1297
+ )
1298
+ )
1299
+ .map((rename) => normalizeRenamePath(rename.to))
1300
+ );
1301
+ return {
1302
+ derivedTargetPaths,
1303
+ extensions: [
1304
+ ...sourceExtensions,
1305
+ ...new Set([...derivedTargetPaths].map((path) => extname(path))),
1306
+ ],
1307
+ };
1308
+ };
1309
+
1310
+ const filterDerivedTargetExtensions = (
1311
+ collected: DownstreamSourceCollection,
1312
+ sourceExtensions: readonly string[],
1313
+ derivedTargetPaths: ReadonlySet<string>
1314
+ ): DownstreamSourceCollection => {
1315
+ const normalizedSourceExtensions = new Set(
1316
+ sourceExtensions.map(normalizeExtension)
1317
+ );
1318
+ if (normalizedSourceExtensions.size === 0) {
1319
+ return collected;
1320
+ }
1321
+ const files = collected.files.filter(
1322
+ (file) =>
1323
+ normalizedSourceExtensions.has(extname(file.path)) ||
1324
+ derivedTargetPaths.has(normalizeRenamePath(file.path))
1325
+ );
1326
+ const selected = new Set(files.map((file) => file.path));
1327
+ return {
1328
+ ...collected,
1329
+ files,
1330
+ skipped: [
1331
+ ...collected.skipped,
1332
+ ...collected.files
1333
+ .filter((file) => !selected.has(file.path))
1334
+ .map((file) => ({ path: file.path, reason: 'unsupported-extension' })),
1335
+ ].toSorted((left, right) => left.path.localeCompare(right.path)),
1336
+ };
1337
+ };
1338
+
1339
+ const withExactMovedTargets = (params: {
1340
+ readonly apply: boolean;
1341
+ readonly collected: DownstreamSourceCollection;
1342
+ readonly derivedTargetPaths: ReadonlySet<string>;
1343
+ readonly renames: readonly VocabularyFileRename[];
1344
+ readonly resolved: readonly ResolvedFileRename[];
1345
+ readonly scope: VocabularyRegradeScope | undefined;
1346
+ }): DownstreamSourceCollection => {
1347
+ if (!params.apply) {
1348
+ return params.collected;
1349
+ }
1350
+ const files = new Map(
1351
+ params.collected.files.map((file) => [normalizeRenamePath(file.path), file])
1352
+ );
1353
+ for (const [index, rename] of params.renames.entries()) {
1354
+ const path = normalizeRenamePath(rename.to);
1355
+ const sourcePath = sourcePathForMovedTarget(path, params.renames);
1356
+ if (
1357
+ !params.derivedTargetPaths.has(path) ||
1358
+ (params.scope?.include !== undefined &&
1359
+ !matchesAnyPathGlob(sourcePath, params.scope.include)) ||
1360
+ (params.scope?.exclude !== undefined &&
1361
+ matchesAnyPathGlob(sourcePath, params.scope.exclude)) ||
1362
+ files.has(path)
1363
+ ) {
1364
+ continue;
1365
+ }
1366
+ const absolutePath = params.resolved[index]?.to;
1367
+ if (
1368
+ absolutePath !== undefined &&
1369
+ existsSync(absolutePath) &&
1370
+ statSync(absolutePath).isFile()
1371
+ ) {
1372
+ files.set(path, { absolutePath, path });
1373
+ }
1374
+ }
1375
+ return {
1376
+ ...params.collected,
1377
+ files: [...files.values()].toSorted((left, right) =>
1378
+ left.path.localeCompare(right.path)
1379
+ ),
1380
+ };
1381
+ };
1382
+
1383
+ /**
1384
+ * Derive review-only filename candidates without mutating source.
1385
+ *
1386
+ * @example
1387
+ * ```ts
1388
+ * const candidates = deriveFileRenameCandidates({
1389
+ * plan: { from: 'legacy', kind: 'vocabulary', to: 'current' },
1390
+ * root: process.cwd(),
1391
+ * });
1392
+ * ```
1393
+ */
1394
+ export const deriveFileRenameCandidates = (params: {
1395
+ readonly plan: VocabularyRegradePlan;
1396
+ readonly root: string;
1397
+ }): readonly FileRenameCandidate[] => {
1398
+ const collected = collectDownstreamSources(params.root, {
1399
+ extensions: params.plan.scope?.extensions ?? fileRenameSourceExtensions,
1400
+ ...(params.plan.scope?.exclude === undefined
1401
+ ? {}
1402
+ : { exclude: params.plan.scope.exclude }),
1403
+ ...(params.plan.scope?.include === undefined
1404
+ ? {}
1405
+ : { include: params.plan.scope.include }),
1406
+ });
1407
+ if (collected === null) {
1408
+ return [];
1409
+ }
1410
+ const safeForms = deriveVocabularyFormProposals(params.plan)
1411
+ .filter(
1412
+ (proposal): proposal is typeof proposal & { readonly to: string } =>
1413
+ proposal.kind === 'safe-rewrite' && proposal.to !== undefined
1414
+ )
1415
+ .toSorted((left, right) => right.from.length - left.from.length);
1416
+ const candidates = new Map<string, FileRenameCandidate>();
1417
+ for (const file of collected.files) {
1418
+ if (policyForPath(file.path, params.plan.scope) !== undefined) {
1419
+ continue;
1420
+ }
1421
+ let to = file.path;
1422
+ for (const form of safeForms) {
1423
+ to = to.replaceAll(
1424
+ new RegExp(
1425
+ `(?<![A-Za-z0-9_$])${escapeRegExp(form.from)}(?![A-Za-z0-9_$])`,
1426
+ params.plan.caseSensitive === true ? 'gu' : 'giu'
1427
+ ),
1428
+ form.to
1429
+ );
1430
+ }
1431
+ if (to !== file.path) {
1432
+ candidates.set(file.path, {
1433
+ evidence: [file.path],
1434
+ from: file.path,
1435
+ to,
1436
+ });
1437
+ }
1438
+ }
1439
+ return [...candidates.values()].toSorted((left, right) =>
1440
+ left.from.localeCompare(right.from)
1441
+ );
1442
+ };
1443
+
1444
+ const changedFilesForRun = (params: {
1445
+ readonly apply: boolean;
1446
+ readonly referencePaths: ReadonlySet<string>;
1447
+ readonly renames: readonly VocabularyFileRename[];
1448
+ readonly resolved: readonly ResolvedFileRename[];
1449
+ }): ReadonlySet<string> => {
1450
+ const changed = new Set(params.referencePaths);
1451
+ if (!params.apply) {
1452
+ return changed;
1453
+ }
1454
+ for (const [index, rename] of params.renames.entries()) {
1455
+ if (params.resolved[index]?.alreadyApplied !== true) {
1456
+ changed.add(normalizeRenamePath(rename.to));
1457
+ }
1458
+ }
1459
+ return changed;
1460
+ };
1461
+
1462
+ /**
1463
+ * Preview or apply governed file moves and one derived reference-closure pass.
1464
+ *
1465
+ * @example
1466
+ * ```ts
1467
+ * const result = runFileRenameRegrade({
1468
+ * renames: [{ from: 'docs/old.md', to: 'docs/new.md' }],
1469
+ * root: process.cwd(),
1470
+ * });
1471
+ * ```
1472
+ */
1473
+ export const runFileRenameRegrade = (params: {
1474
+ readonly apply?: boolean;
1475
+ readonly excludeGeneratedArtifacts?: boolean;
1476
+ readonly includeEntries?: 'actionable' | 'all';
1477
+ readonly renames: readonly VocabularyFileRename[];
1478
+ readonly root: string;
1479
+ readonly scope?: VocabularyRegradeScope;
1480
+ readonly vocabularyPlan?: VocabularyRegradePlan;
1481
+ }): TrailsResult<FileRenameRegradeRun, Error> => {
1482
+ const apply = params.apply === true;
1483
+ const validated = validateRenames(params.root, params.renames);
1484
+ if (validated.isErr()) {
1485
+ return validated;
1486
+ }
1487
+
1488
+ const moved: TrailsResult<readonly ResolvedFileRename[], Error> = apply
1489
+ ? applyResolvedRenames(validated.value)
1490
+ : Result.ok([]);
1491
+ if (moved.isErr()) {
1492
+ return moved;
1493
+ }
1494
+
1495
+ const opened = openedPolicyDirectories(params.scope);
1496
+ const include = collectionIncludeForFileRenames({
1497
+ apply,
1498
+ include: params.scope?.include,
1499
+ renames: params.renames,
1500
+ });
1501
+ const exclude = collectionExcludeForFileRenames({
1502
+ apply,
1503
+ exclude: params.scope?.exclude,
1504
+ renames: params.renames,
1505
+ });
1506
+ const sourceExtensions =
1507
+ params.scope?.extensions ?? fileRenameSourceExtensions;
1508
+ const derivedExtensions = deriveCollectionExtensionsForFileRenames({
1509
+ apply,
1510
+ extensions: sourceExtensions,
1511
+ renames: params.renames,
1512
+ });
1513
+ const rawCollection = collectDownstreamSources(params.root, {
1514
+ extensions: derivedExtensions.extensions,
1515
+ ...(exclude === undefined ? {} : { exclude }),
1516
+ ...(include === undefined ? {} : { include }),
1517
+ ignoredDirectories: DEFAULT_IGNORED_DIRECTORIES.filter(
1518
+ (directory) => !opened.includes(directory)
1519
+ ),
1520
+ });
1521
+ if (rawCollection === null) {
1522
+ rollbackResolvedRenames(moved.value);
1523
+ return Result.err(
1524
+ new InternalError('Failed to collect file rename references.')
1525
+ );
1526
+ }
1527
+ const exactTargetCollection = withExactMovedTargets({
1528
+ apply,
1529
+ collected: rawCollection,
1530
+ derivedTargetPaths: derivedExtensions.derivedTargetPaths,
1531
+ renames: params.renames,
1532
+ resolved: validated.value,
1533
+ scope: params.scope,
1534
+ });
1535
+ const extensionScopedCollection = filterDerivedTargetExtensions(
1536
+ exactTargetCollection,
1537
+ sourceExtensions,
1538
+ derivedExtensions.derivedTargetPaths
1539
+ );
1540
+ const scopedCollection = filterOpenedPolicyDirectories(
1541
+ extensionScopedCollection,
1542
+ opened,
1543
+ derivedExtensions.derivedTargetPaths,
1544
+ params.scope,
1545
+ params.renames,
1546
+ apply
1547
+ );
1548
+ const collected: DownstreamSourceCollection =
1549
+ params.excludeGeneratedArtifacts === true
1550
+ ? {
1551
+ ...scopedCollection,
1552
+ files: scopedCollection.files.filter(
1553
+ (file) => !isGeneratedRegradeArtifactPath(file.path)
1554
+ ),
1555
+ }
1556
+ : scopedCollection;
1557
+
1558
+ const sourceStateHash = fileRenameSourceStateHash({
1559
+ apply,
1560
+ collected,
1561
+ renames: params.renames,
1562
+ resolved: validated.value,
1563
+ });
1564
+ if (sourceStateHash.isErr()) {
1565
+ rollbackResolvedRenames(moved.value);
1566
+ return sourceStateHash;
1567
+ }
1568
+
1569
+ const evidence = params.renames.map(() => emptyEvidence());
1570
+ const targetPaths = new Set(
1571
+ params.renames.map((rename) => normalizeRenamePath(rename.to))
1572
+ );
1573
+ const excludedRenameIndexes = new Set(
1574
+ params.renames.flatMap((rename, index) =>
1575
+ validated.value[index]?.alreadyApplied === true &&
1576
+ targetPaths.has(normalizeRenamePath(rename.from))
1577
+ ? [index]
1578
+ : []
1579
+ )
1580
+ );
1581
+ const referenceResult = rewriteReferenceFiles({
1582
+ apply,
1583
+ collected,
1584
+ evidence,
1585
+ excludedRenameIndexes,
1586
+ renames: params.renames,
1587
+ resolved: validated.value,
1588
+ ...(params.scope === undefined ? {} : { scope: params.scope }),
1589
+ ...(params.vocabularyPlan === undefined
1590
+ ? {}
1591
+ : { vocabularyPlan: params.vocabularyPlan }),
1592
+ });
1593
+ if (referenceResult.isErr()) {
1594
+ rollbackResolvedRenames(moved.value);
1595
+ return referenceResult;
1596
+ }
1597
+ if (apply) {
1598
+ const written = applyReferenceWrites(referenceResult.value.writes);
1599
+ if (written.isErr()) {
1600
+ rollbackResolvedRenames(moved.value);
1601
+ return written;
1602
+ }
1603
+ }
1604
+
1605
+ const derivedEvidence = params.renames.map((rename, index) => ({
1606
+ ...rename,
1607
+ ...(evidence[index] ?? emptyEvidence()),
1608
+ }));
1609
+ const entries = [
1610
+ ...initialFileMoveEntries(params.renames, validated.value, apply),
1611
+ ...referenceResult.value.entries,
1612
+ ];
1613
+ const actionableEntries = entries.filter(
1614
+ (entry) => entry.outcome === 'rewrite' || entry.outcome === 'needs-review'
1615
+ );
1616
+ const matchedPaths = actionableEntries.map((entry) => entry.path);
1617
+ const review = entries.filter(
1618
+ (entry) => entry.outcome === 'needs-review'
1619
+ ).length;
1620
+ const rewritten = entries.filter(
1621
+ (entry) => entry.outcome === 'rewrite'
1622
+ ).length;
1623
+ const skippedByReason = skippedCounts(collected);
1624
+ const changedFiles = changedFilesForRun({
1625
+ apply,
1626
+ referencePaths: referenceResult.value.changedFiles,
1627
+ renames: params.renames,
1628
+ resolved: validated.value,
1629
+ });
1630
+ const report: RegradeReport = {
1631
+ ...(apply
1632
+ ? {
1633
+ apply: {
1634
+ applied:
1635
+ validated.value.filter((rename) => !rename.alreadyApplied)
1636
+ .length +
1637
+ derivedEvidence.reduce((sum, item) => sum + item.rewritten, 0),
1638
+ filesChanged: changedFiles.size,
1639
+ review,
1640
+ skipped: derivedEvidence.reduce(
1641
+ (sum, item) => sum + item.skipped,
1642
+ 0
1643
+ ),
1644
+ unknown: 0,
1645
+ },
1646
+ }
1647
+ : {}),
1648
+ entries: params.includeEntries === 'all' ? entries : actionableEntries,
1649
+ matched: new Set(matchedPaths).size,
1650
+ review,
1651
+ rewritten,
1652
+ root: collected.root,
1653
+ scan: buildRegradeScanSummary({
1654
+ matchedPaths,
1655
+ occurrencePaths: referenceResult.value.occurrencePaths,
1656
+ scanned: collected.files.length,
1657
+ skipped: collected.skipped.length,
1658
+ skippedByReason,
1659
+ }),
1660
+ scanned: collected.files.length,
1661
+ selectedClassIds: params.renames.map(
1662
+ (rename) => `file-rename:${rename.from}->${rename.to}`
1663
+ ),
1664
+ skipped: collected.skipped.length,
1665
+ skipsByReason: skippedByReason,
1666
+ unknownClassIds: [],
1667
+ };
1668
+
1669
+ return Result.ok({
1670
+ changedPaths: [...changedFiles].toSorted(),
1671
+ evidence: derivedEvidence,
1672
+ occurrencePaths: referenceResult.value.occurrencePaths,
1673
+ policyOccurrencePaths: referenceResult.value.policyOccurrencePaths,
1674
+ report,
1675
+ sourceStateHash: sourceStateHash.value,
1676
+ });
1677
+ };