@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.
- package/CHANGELOG.md +606 -0
- package/package.json +40 -0
- package/src/downstream/ast-rewrite.ts +1031 -0
- package/src/downstream/collect.ts +240 -0
- package/src/downstream/export-restructure.ts +1588 -0
- package/src/downstream/file-renames.ts +1677 -0
- package/src/downstream/package-source-artifact.ts +375 -0
- package/src/downstream/package-source-files.ts +195 -0
- package/src/downstream/package-source-manifest.ts +369 -0
- package/src/downstream/package-source.ts +237 -0
- package/src/downstream/report.ts +2085 -0
- package/src/downstream/scan-summary.ts +193 -0
- package/src/downstream/vocabulary-registry.ts +195 -0
- package/src/downstream/vocabulary.ts +3094 -0
- package/src/history-receipt.ts +847 -0
- package/src/index.ts +170 -0
- package/src/literal-transform.ts +124 -0
|
@@ -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
|
+
};
|