@ontrails/regrade 1.0.0-beta.41 → 1.0.0-beta.43

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