@ontrails/regrade 1.0.0-beta.30 → 1.0.0-beta.39
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 +99 -0
- package/package.json +5 -5
- package/src/downstream/ast-rewrite.ts +200 -5
- package/src/downstream/collect.ts +45 -4
- package/src/downstream/export-restructure.ts +1588 -0
- package/src/downstream/report.ts +557 -79
- package/src/downstream/scan-summary.ts +193 -0
- package/src/downstream/vocabulary-registry.ts +98 -0
- package/src/downstream/vocabulary.ts +1933 -0
- package/src/index.ts +51 -0
|
@@ -0,0 +1,1933 @@
|
|
|
1
|
+
import {
|
|
2
|
+
InternalError,
|
|
3
|
+
Result,
|
|
4
|
+
ValidationError,
|
|
5
|
+
escapeRegExp,
|
|
6
|
+
matchesAnyPathGlob,
|
|
7
|
+
} from '@ontrails/core';
|
|
8
|
+
import { createHash } from 'node:crypto';
|
|
9
|
+
import { dirname, isAbsolute, join, normalize, relative } from 'node:path';
|
|
10
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
11
|
+
import { z } from 'zod';
|
|
12
|
+
|
|
13
|
+
import { collectDownstreamSources } from './collect.js';
|
|
14
|
+
import type { DownstreamCollectionOptions, SkippedSource } from './collect.js';
|
|
15
|
+
import type {
|
|
16
|
+
RegradeApplySummary,
|
|
17
|
+
RegradeReport,
|
|
18
|
+
RegradeReportEntry,
|
|
19
|
+
} from './report.js';
|
|
20
|
+
import { buildRegradeScanSummary } from './scan-summary.js';
|
|
21
|
+
|
|
22
|
+
export type VocabularyVerdict = 'deferred' | 'modified' | 'skipped';
|
|
23
|
+
|
|
24
|
+
export const vocabularyDispositionValues = [
|
|
25
|
+
'code-context-out-of-engine',
|
|
26
|
+
'docs-only',
|
|
27
|
+
'explicit-preserve',
|
|
28
|
+
'forward-pointer',
|
|
29
|
+
'ignored-by-scope',
|
|
30
|
+
'in-family-modified',
|
|
31
|
+
'in-family-unresolved',
|
|
32
|
+
'out-of-family',
|
|
33
|
+
'preserve-current-live-api',
|
|
34
|
+
] as const;
|
|
35
|
+
|
|
36
|
+
export type VocabularyDisposition =
|
|
37
|
+
(typeof vocabularyDispositionValues)[number];
|
|
38
|
+
|
|
39
|
+
const vocabularyDispositions = new Set<string>(vocabularyDispositionValues);
|
|
40
|
+
|
|
41
|
+
export interface VocabularyPreserveRule {
|
|
42
|
+
readonly disposition?: VocabularyDisposition;
|
|
43
|
+
readonly forms?: readonly string[];
|
|
44
|
+
readonly pattern: string;
|
|
45
|
+
readonly reason?: string;
|
|
46
|
+
readonly paths?: readonly string[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface VocabularyPreserveInventoryEntry extends VocabularyPreserveRule {
|
|
50
|
+
readonly evidence: readonly string[];
|
|
51
|
+
readonly source: 'derived-live-api';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface VocabularyRegradeScope {
|
|
55
|
+
readonly exclude?: readonly string[];
|
|
56
|
+
readonly extensions?: readonly string[];
|
|
57
|
+
/**
|
|
58
|
+
* @deprecated Use `exclude` path globs for new plans. This remains as a
|
|
59
|
+
* compatibility bridge for pre-path-scope plans that intentionally disabled
|
|
60
|
+
* the collector's default directory pruning.
|
|
61
|
+
*/
|
|
62
|
+
readonly ignoredDirectories?: readonly string[];
|
|
63
|
+
readonly include?: readonly string[];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface VocabularyRegradePlan {
|
|
67
|
+
readonly caseSensitive?: boolean;
|
|
68
|
+
readonly deferForms?: readonly string[];
|
|
69
|
+
readonly from: string;
|
|
70
|
+
readonly id?: string;
|
|
71
|
+
readonly intent?: string;
|
|
72
|
+
readonly kind: 'vocabulary';
|
|
73
|
+
readonly overrides?: Readonly<Record<string, string>>;
|
|
74
|
+
readonly preserve?: readonly VocabularyPreserveRule[];
|
|
75
|
+
readonly scope?: VocabularyRegradeScope;
|
|
76
|
+
readonly to: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface VocabularyOccurrence {
|
|
80
|
+
readonly column: number;
|
|
81
|
+
readonly context: string;
|
|
82
|
+
readonly disposition: VocabularyDisposition;
|
|
83
|
+
readonly end: number;
|
|
84
|
+
readonly form: string;
|
|
85
|
+
readonly line: number;
|
|
86
|
+
readonly path: string;
|
|
87
|
+
readonly reason: string;
|
|
88
|
+
readonly replacement?: string;
|
|
89
|
+
readonly start: number;
|
|
90
|
+
readonly verdict: VocabularyVerdict;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface VocabularyRunLedger {
|
|
94
|
+
readonly cycle: number;
|
|
95
|
+
readonly forms: Readonly<Record<string, VocabularyVerdict>>;
|
|
96
|
+
readonly occurrences: readonly VocabularyOccurrence[];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface VocabularyRunGate {
|
|
100
|
+
readonly remaining: number;
|
|
101
|
+
readonly remainingByDisposition: Partial<
|
|
102
|
+
Readonly<Record<VocabularyDisposition, number>>
|
|
103
|
+
>;
|
|
104
|
+
readonly reasons: readonly string[];
|
|
105
|
+
readonly status: 'green' | 'open';
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface VocabularyRunReport {
|
|
109
|
+
readonly applied: number;
|
|
110
|
+
readonly deferred: number;
|
|
111
|
+
readonly dispositions: Partial<
|
|
112
|
+
Readonly<Record<VocabularyDisposition, number>>
|
|
113
|
+
>;
|
|
114
|
+
readonly filesChanged: number;
|
|
115
|
+
readonly gate: VocabularyRunGate;
|
|
116
|
+
readonly modified: number;
|
|
117
|
+
readonly open: number;
|
|
118
|
+
readonly skipped: number;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export interface VocabularyRegradeRun {
|
|
122
|
+
readonly ledger: VocabularyRunLedger;
|
|
123
|
+
readonly plan: VocabularyRegradePlan;
|
|
124
|
+
readonly preserveInventory?: readonly VocabularyPreserveInventoryEntry[];
|
|
125
|
+
readonly report: VocabularyRunReport;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export const VOCABULARY_TRANSITION_RECORD_SCHEMA_VERSION = 1;
|
|
129
|
+
|
|
130
|
+
export interface VocabularyTransitionRecordEnvironment {
|
|
131
|
+
readonly commitSha?: string;
|
|
132
|
+
readonly engineVersion?: string;
|
|
133
|
+
readonly graphHash?: string;
|
|
134
|
+
readonly root: string;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export interface VocabularyTransitionRecord {
|
|
138
|
+
readonly environment: VocabularyTransitionRecordEnvironment;
|
|
139
|
+
readonly kind: 'vocabulary-transition-record';
|
|
140
|
+
readonly recordPath: string;
|
|
141
|
+
readonly report: Omit<RegradeReport, 'record'>;
|
|
142
|
+
readonly schemaVersion: typeof VOCABULARY_TRANSITION_RECORD_SCHEMA_VERSION;
|
|
143
|
+
readonly transition: {
|
|
144
|
+
readonly from: string;
|
|
145
|
+
readonly id: string;
|
|
146
|
+
readonly to: string;
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export interface VocabularyTransitionRecordSummary {
|
|
151
|
+
readonly path: string;
|
|
152
|
+
readonly schemaVersion: typeof VOCABULARY_TRANSITION_RECORD_SCHEMA_VERSION;
|
|
153
|
+
readonly status: 'candidate' | 'applied' | 'checked';
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
interface SourceFile {
|
|
157
|
+
readonly absolutePath: string;
|
|
158
|
+
readonly path: string;
|
|
159
|
+
readonly source: string;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
interface SourceOccurrence extends VocabularyOccurrence {
|
|
163
|
+
readonly absolutePath: string;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
interface SourceOccurrenceDraft extends Omit<
|
|
167
|
+
SourceOccurrence,
|
|
168
|
+
'disposition' | 'reason' | 'verdict'
|
|
169
|
+
> {
|
|
170
|
+
readonly contextColumn: number;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
interface VocabularyEvaluation {
|
|
174
|
+
readonly entries: readonly RegradeReportEntry[];
|
|
175
|
+
readonly occurrences: readonly SourceOccurrence[];
|
|
176
|
+
readonly scanned: number;
|
|
177
|
+
readonly skipped: readonly SkippedSource[];
|
|
178
|
+
readonly run: VocabularyRegradeRun;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const VOCABULARY_SOURCE_EXTENSIONS = Object.freeze([
|
|
182
|
+
'.js',
|
|
183
|
+
'.jsx',
|
|
184
|
+
'.json',
|
|
185
|
+
'.jsonc',
|
|
186
|
+
'.md',
|
|
187
|
+
'.mdx',
|
|
188
|
+
'.mjs',
|
|
189
|
+
'.ts',
|
|
190
|
+
'.tsx',
|
|
191
|
+
'.txt',
|
|
192
|
+
'.yaml',
|
|
193
|
+
'.yml',
|
|
194
|
+
]);
|
|
195
|
+
|
|
196
|
+
const uniqueSorted = (values: readonly string[]): readonly string[] =>
|
|
197
|
+
[...new Set(values)].toSorted((a, b) => a.localeCompare(b));
|
|
198
|
+
|
|
199
|
+
const vocabularyDispositionCounts = (
|
|
200
|
+
occurrences: readonly VocabularyOccurrence[]
|
|
201
|
+
): Partial<Readonly<Record<VocabularyDisposition, number>>> => {
|
|
202
|
+
const counts = new Map<VocabularyDisposition, number>();
|
|
203
|
+
for (const occurrence of occurrences) {
|
|
204
|
+
counts.set(
|
|
205
|
+
occurrence.disposition,
|
|
206
|
+
(counts.get(occurrence.disposition) ?? 0) + 1
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
return Object.fromEntries(
|
|
210
|
+
[...counts.entries()].toSorted(([left], [right]) =>
|
|
211
|
+
left.localeCompare(right)
|
|
212
|
+
)
|
|
213
|
+
);
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
const isVocabularyTokenCharacter = (value: string): boolean =>
|
|
217
|
+
/[A-Za-z0-9_$-]/.test(value);
|
|
218
|
+
|
|
219
|
+
const hasWordBoundary = (
|
|
220
|
+
source: string,
|
|
221
|
+
start: number,
|
|
222
|
+
end: number
|
|
223
|
+
): boolean => {
|
|
224
|
+
const before = start === 0 ? '' : (source.at(start - 1) ?? '');
|
|
225
|
+
const after = end >= source.length ? '' : (source.at(end) ?? '');
|
|
226
|
+
return (
|
|
227
|
+
!isVocabularyTokenCharacter(before) && !isVocabularyTokenCharacter(after)
|
|
228
|
+
);
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
const expandVocabularyNeighborSpan = (
|
|
232
|
+
source: string,
|
|
233
|
+
start: number,
|
|
234
|
+
end: number
|
|
235
|
+
): { readonly end: number; readonly start: number } => {
|
|
236
|
+
let expandedStart = start;
|
|
237
|
+
while (
|
|
238
|
+
expandedStart > 0 &&
|
|
239
|
+
isVocabularyTokenCharacter(source.at(expandedStart - 1) ?? '')
|
|
240
|
+
) {
|
|
241
|
+
expandedStart -= 1;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
let expandedEnd = end;
|
|
245
|
+
while (
|
|
246
|
+
expandedEnd < source.length &&
|
|
247
|
+
isVocabularyTokenCharacter(source.at(expandedEnd) ?? '')
|
|
248
|
+
) {
|
|
249
|
+
expandedEnd += 1;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return { end: expandedEnd, start: expandedStart };
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
const lineColumnForOffset = (
|
|
256
|
+
source: string,
|
|
257
|
+
offset: number
|
|
258
|
+
): { readonly column: number; readonly line: number } => {
|
|
259
|
+
let line = 1;
|
|
260
|
+
let column = 1;
|
|
261
|
+
for (let index = 0; index < offset; index += 1) {
|
|
262
|
+
if (source.codePointAt(index) === 10) {
|
|
263
|
+
line += 1;
|
|
264
|
+
column = 1;
|
|
265
|
+
} else {
|
|
266
|
+
column += 1;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return { column, line };
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
const contextDetailsForOffset = (
|
|
273
|
+
source: string,
|
|
274
|
+
start: number,
|
|
275
|
+
end: number
|
|
276
|
+
): { readonly context: string; readonly contextColumn: number } => {
|
|
277
|
+
const lineStart = source.lastIndexOf('\n', start - 1) + 1;
|
|
278
|
+
const nextLine = source.indexOf('\n', end);
|
|
279
|
+
const lineEnd = nextLine === -1 ? source.length : nextLine;
|
|
280
|
+
const rawLine = source.slice(lineStart, lineEnd);
|
|
281
|
+
const leadingTrimmed = rawLine.length - rawLine.trimStart().length;
|
|
282
|
+
return {
|
|
283
|
+
context: rawLine.trim(),
|
|
284
|
+
contextColumn: start - lineStart - leadingTrimmed + 1,
|
|
285
|
+
};
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
const isMarkdownPath = (path: string): boolean =>
|
|
289
|
+
path.endsWith('.md') || path.endsWith('.mdx');
|
|
290
|
+
|
|
291
|
+
const sourceLineBoundsForOffset = (
|
|
292
|
+
source: string,
|
|
293
|
+
start: number,
|
|
294
|
+
end: number
|
|
295
|
+
): { readonly lineEnd: number; readonly lineStart: number } => {
|
|
296
|
+
const lineStart = source.lastIndexOf('\n', start - 1) + 1;
|
|
297
|
+
const nextLine = source.indexOf('\n', end);
|
|
298
|
+
return { lineEnd: nextLine === -1 ? source.length : nextLine, lineStart };
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
const markdownBacktickRuns = (value: string): readonly RegExpMatchArray[] => [
|
|
302
|
+
...value.matchAll(/(?<!\\)`+/g),
|
|
303
|
+
];
|
|
304
|
+
|
|
305
|
+
const isMarkdownInlineCodeContext = (
|
|
306
|
+
source: string,
|
|
307
|
+
start: number,
|
|
308
|
+
end: number
|
|
309
|
+
): boolean => {
|
|
310
|
+
const { lineEnd, lineStart } = sourceLineBoundsForOffset(source, start, end);
|
|
311
|
+
const line = source.slice(lineStart, lineEnd);
|
|
312
|
+
const relativeStart = start - lineStart;
|
|
313
|
+
const relativeEnd = end - lineStart;
|
|
314
|
+
let openRun: { readonly length: number; readonly start: number } | undefined;
|
|
315
|
+
|
|
316
|
+
for (const run of markdownBacktickRuns(line)) {
|
|
317
|
+
const runStart = run.index ?? 0;
|
|
318
|
+
const [value] = run;
|
|
319
|
+
const runLength = value.length;
|
|
320
|
+
if (openRun === undefined) {
|
|
321
|
+
openRun = { length: runLength, start: runStart };
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
if (runLength !== openRun.length) {
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
if (
|
|
328
|
+
openRun.start + openRun.length <= relativeStart &&
|
|
329
|
+
relativeEnd <= runStart
|
|
330
|
+
) {
|
|
331
|
+
return true;
|
|
332
|
+
}
|
|
333
|
+
openRun = undefined;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
return false;
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
const markdownFenceLinePattern = /^\s*(?:>\s*){0,8}(```|~~~)/;
|
|
340
|
+
|
|
341
|
+
const isMarkdownFenceContext = (source: string, start: number): boolean => {
|
|
342
|
+
const before = source.slice(0, start);
|
|
343
|
+
let fenced = false;
|
|
344
|
+
for (const line of before.split('\n')) {
|
|
345
|
+
if (markdownFenceLinePattern.test(line)) {
|
|
346
|
+
fenced = !fenced;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
return fenced;
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
const isMarkdownCodeContext = (
|
|
353
|
+
file: SourceFile,
|
|
354
|
+
start: number,
|
|
355
|
+
end: number
|
|
356
|
+
): boolean =>
|
|
357
|
+
isMarkdownPath(file.path) &&
|
|
358
|
+
(isMarkdownInlineCodeContext(file.source, start, end) ||
|
|
359
|
+
isMarkdownFenceContext(file.source, start));
|
|
360
|
+
|
|
361
|
+
const vocabularyOccurrenceReason = (
|
|
362
|
+
preserveRule: VocabularyPreserveRule | undefined,
|
|
363
|
+
markdownCodeContext: boolean,
|
|
364
|
+
defaultReason: string
|
|
365
|
+
): string => {
|
|
366
|
+
if (preserveRule !== undefined) {
|
|
367
|
+
return preserveRule.reason ?? 'preserved-by-plan';
|
|
368
|
+
}
|
|
369
|
+
if (markdownCodeContext) {
|
|
370
|
+
return 'markdown-code-context';
|
|
371
|
+
}
|
|
372
|
+
return defaultReason;
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
const capturedVocabularyVerdict = (
|
|
376
|
+
preserveRule: VocabularyPreserveRule | undefined,
|
|
377
|
+
markdownCodeContext: boolean
|
|
378
|
+
): VocabularyVerdict => {
|
|
379
|
+
if (preserveRule !== undefined) {
|
|
380
|
+
return 'skipped';
|
|
381
|
+
}
|
|
382
|
+
if (markdownCodeContext) {
|
|
383
|
+
return 'deferred';
|
|
384
|
+
}
|
|
385
|
+
return 'modified';
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
const vocabularyOccurrenceDisposition = (
|
|
389
|
+
verdict: VocabularyVerdict,
|
|
390
|
+
preserveRule: VocabularyPreserveRule | undefined,
|
|
391
|
+
markdownCodeContext: boolean
|
|
392
|
+
): VocabularyDisposition => {
|
|
393
|
+
if (preserveRule !== undefined) {
|
|
394
|
+
return preserveRule.disposition ?? 'explicit-preserve';
|
|
395
|
+
}
|
|
396
|
+
if (markdownCodeContext) {
|
|
397
|
+
return 'code-context-out-of-engine';
|
|
398
|
+
}
|
|
399
|
+
if (verdict === 'modified') {
|
|
400
|
+
return 'in-family-modified';
|
|
401
|
+
}
|
|
402
|
+
return 'in-family-unresolved';
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
const preserveCase = (sourceForm: string, replacement: string): string => {
|
|
406
|
+
if (sourceForm.toUpperCase() === sourceForm) {
|
|
407
|
+
return replacement.toUpperCase();
|
|
408
|
+
}
|
|
409
|
+
const first = sourceForm.at(0);
|
|
410
|
+
if (first !== undefined && first.toUpperCase() === first) {
|
|
411
|
+
return replacement.at(0)?.toUpperCase() + replacement.slice(1);
|
|
412
|
+
}
|
|
413
|
+
return replacement;
|
|
414
|
+
};
|
|
415
|
+
|
|
416
|
+
const isSimpleVocabularyWord = (value: string): boolean =>
|
|
417
|
+
/^[A-Za-z]+$/.test(value);
|
|
418
|
+
|
|
419
|
+
const endsWithConsonantY = (value: string): boolean => {
|
|
420
|
+
const penultimate = value.at(-2);
|
|
421
|
+
return (
|
|
422
|
+
value.endsWith('y') &&
|
|
423
|
+
penultimate !== undefined &&
|
|
424
|
+
!/[aeiou]/.test(penultimate)
|
|
425
|
+
);
|
|
426
|
+
};
|
|
427
|
+
|
|
428
|
+
const pluralize = (value: string): string => {
|
|
429
|
+
const lower = value.toLowerCase();
|
|
430
|
+
let lowerForm: string;
|
|
431
|
+
if (endsWithConsonantY(lower)) {
|
|
432
|
+
lowerForm = `${lower.slice(0, -1)}ies`;
|
|
433
|
+
} else if (
|
|
434
|
+
lower.endsWith('s') ||
|
|
435
|
+
lower.endsWith('x') ||
|
|
436
|
+
lower.endsWith('ch')
|
|
437
|
+
) {
|
|
438
|
+
lowerForm = `${lower}es`;
|
|
439
|
+
} else {
|
|
440
|
+
lowerForm = `${lower}s`;
|
|
441
|
+
}
|
|
442
|
+
return preserveCase(value, lowerForm);
|
|
443
|
+
};
|
|
444
|
+
|
|
445
|
+
const pastTenseForm = (value: string): string => {
|
|
446
|
+
const lower = value.toLowerCase();
|
|
447
|
+
let lowerForm: string;
|
|
448
|
+
if (endsWithConsonantY(lower)) {
|
|
449
|
+
lowerForm = `${lower.slice(0, -1)}ied`;
|
|
450
|
+
} else if (lower.endsWith('e')) {
|
|
451
|
+
lowerForm = `${lower}d`;
|
|
452
|
+
} else {
|
|
453
|
+
lowerForm = `${lower}ed`;
|
|
454
|
+
}
|
|
455
|
+
return preserveCase(value, lowerForm);
|
|
456
|
+
};
|
|
457
|
+
|
|
458
|
+
const presentParticipleForm = (value: string): string => {
|
|
459
|
+
const lower = value.toLowerCase();
|
|
460
|
+
let lowerForm: string;
|
|
461
|
+
if (lower.endsWith('ie')) {
|
|
462
|
+
lowerForm = `${lower.slice(0, -2)}ying`;
|
|
463
|
+
} else if (lower.endsWith('e') && !lower.endsWith('ee')) {
|
|
464
|
+
lowerForm = `${lower.slice(0, -1)}ing`;
|
|
465
|
+
} else {
|
|
466
|
+
lowerForm = `${lower}ing`;
|
|
467
|
+
}
|
|
468
|
+
return preserveCase(value, lowerForm);
|
|
469
|
+
};
|
|
470
|
+
|
|
471
|
+
const defaultDeferredVocabularyForms = (from: string): readonly string[] => {
|
|
472
|
+
if (!isSimpleVocabularyWord(from)) {
|
|
473
|
+
return [];
|
|
474
|
+
}
|
|
475
|
+
return uniqueSorted([
|
|
476
|
+
pastTenseForm(from),
|
|
477
|
+
presentParticipleForm(from),
|
|
478
|
+
]).filter((form) => form !== from && form !== pluralize(from));
|
|
479
|
+
};
|
|
480
|
+
|
|
481
|
+
const defaultVocabularyForms = (from: string, to: string) =>
|
|
482
|
+
new Map<string, string>([
|
|
483
|
+
[from, to],
|
|
484
|
+
[pluralize(from), pluralize(to)],
|
|
485
|
+
]);
|
|
486
|
+
|
|
487
|
+
const normalizedOverrideEntries = (
|
|
488
|
+
overrides: Readonly<Record<string, string>> | undefined
|
|
489
|
+
): readonly [string, string][] =>
|
|
490
|
+
Object.entries(overrides ?? {}).toSorted(([left], [right]) =>
|
|
491
|
+
left.localeCompare(right)
|
|
492
|
+
);
|
|
493
|
+
|
|
494
|
+
const formIdentityForPlan = (
|
|
495
|
+
plan: VocabularyRegradePlan,
|
|
496
|
+
form: string
|
|
497
|
+
): string => (plan.caseSensitive === true ? form : form.toLowerCase());
|
|
498
|
+
|
|
499
|
+
const targetFormsForPlan = (
|
|
500
|
+
plan: VocabularyRegradePlan
|
|
501
|
+
): Map<string, string> => {
|
|
502
|
+
const forms = defaultVocabularyForms(plan.from, plan.to);
|
|
503
|
+
for (const [form, replacement] of normalizedOverrideEntries(plan.overrides)) {
|
|
504
|
+
forms.set(form, replacement);
|
|
505
|
+
}
|
|
506
|
+
return forms;
|
|
507
|
+
};
|
|
508
|
+
|
|
509
|
+
const deferFormsForPlan = (plan: VocabularyRegradePlan): readonly string[] => {
|
|
510
|
+
const overrideForms = new Set(
|
|
511
|
+
normalizedOverrideEntries(plan.overrides).map(([form]) =>
|
|
512
|
+
formIdentityForPlan(plan, form)
|
|
513
|
+
)
|
|
514
|
+
);
|
|
515
|
+
return uniqueSorted([
|
|
516
|
+
...defaultDeferredVocabularyForms(plan.from).filter(
|
|
517
|
+
(form) => !overrideForms.has(formIdentityForPlan(plan, form))
|
|
518
|
+
),
|
|
519
|
+
...(plan.deferForms ?? []),
|
|
520
|
+
]);
|
|
521
|
+
};
|
|
522
|
+
|
|
523
|
+
const validateVocabularyPlan = (
|
|
524
|
+
plan: VocabularyRegradePlan
|
|
525
|
+
): Result<void, ValidationError> => {
|
|
526
|
+
if (plan.from.trim().length === 0) {
|
|
527
|
+
return Result.err(
|
|
528
|
+
new ValidationError('Vocabulary Regrade plan `from` cannot be empty.')
|
|
529
|
+
);
|
|
530
|
+
}
|
|
531
|
+
if (plan.to.trim().length === 0) {
|
|
532
|
+
return Result.err(
|
|
533
|
+
new ValidationError('Vocabulary Regrade plan `to` cannot be empty.')
|
|
534
|
+
);
|
|
535
|
+
}
|
|
536
|
+
for (const [form, replacement] of normalizedOverrideEntries(plan.overrides)) {
|
|
537
|
+
if (form.trim().length === 0) {
|
|
538
|
+
return Result.err(
|
|
539
|
+
new ValidationError(
|
|
540
|
+
'Vocabulary Regrade plan override keys cannot be empty.'
|
|
541
|
+
)
|
|
542
|
+
);
|
|
543
|
+
}
|
|
544
|
+
if (replacement.trim().length === 0) {
|
|
545
|
+
return Result.err(
|
|
546
|
+
new ValidationError(
|
|
547
|
+
`Vocabulary Regrade plan override "${form}" cannot map to an empty replacement.`
|
|
548
|
+
)
|
|
549
|
+
);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
for (const form of deferFormsForPlan(plan)) {
|
|
553
|
+
if (form.trim().length === 0) {
|
|
554
|
+
return Result.err(
|
|
555
|
+
new ValidationError(
|
|
556
|
+
'Vocabulary Regrade plan deferForms entries cannot be empty.'
|
|
557
|
+
)
|
|
558
|
+
);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
for (const rule of plan.preserve ?? []) {
|
|
562
|
+
if (rule.pattern.trim().length === 0) {
|
|
563
|
+
return Result.err(
|
|
564
|
+
new ValidationError(
|
|
565
|
+
'Vocabulary Regrade plan preserve patterns cannot be empty.'
|
|
566
|
+
)
|
|
567
|
+
);
|
|
568
|
+
}
|
|
569
|
+
if (
|
|
570
|
+
rule.disposition !== undefined &&
|
|
571
|
+
!vocabularyDispositions.has(rule.disposition)
|
|
572
|
+
) {
|
|
573
|
+
return Result.err(
|
|
574
|
+
new ValidationError(
|
|
575
|
+
`Vocabulary Regrade plan preserve disposition "${rule.disposition}" is not supported.`
|
|
576
|
+
)
|
|
577
|
+
);
|
|
578
|
+
}
|
|
579
|
+
if (rule.forms?.some((form) => form.trim().length === 0) === true) {
|
|
580
|
+
return Result.err(
|
|
581
|
+
new ValidationError(
|
|
582
|
+
'Vocabulary Regrade plan preserve forms cannot be empty.'
|
|
583
|
+
)
|
|
584
|
+
);
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
return Result.ok();
|
|
588
|
+
};
|
|
589
|
+
|
|
590
|
+
const validatePreserveInventory = (
|
|
591
|
+
inventory: readonly VocabularyPreserveInventoryEntry[] | undefined
|
|
592
|
+
): Result<void, ValidationError> => {
|
|
593
|
+
for (const entry of inventory ?? []) {
|
|
594
|
+
if (entry.pattern.trim().length === 0) {
|
|
595
|
+
return Result.err(
|
|
596
|
+
new ValidationError(
|
|
597
|
+
'Vocabulary Regrade preserve inventory patterns cannot be empty.'
|
|
598
|
+
)
|
|
599
|
+
);
|
|
600
|
+
}
|
|
601
|
+
if (entry.forms?.some((form) => form.trim().length === 0) === true) {
|
|
602
|
+
return Result.err(
|
|
603
|
+
new ValidationError(
|
|
604
|
+
'Vocabulary Regrade preserve inventory forms cannot be empty.'
|
|
605
|
+
)
|
|
606
|
+
);
|
|
607
|
+
}
|
|
608
|
+
if (
|
|
609
|
+
entry.disposition !== undefined &&
|
|
610
|
+
!vocabularyDispositions.has(entry.disposition)
|
|
611
|
+
) {
|
|
612
|
+
return Result.err(
|
|
613
|
+
new ValidationError(
|
|
614
|
+
`Vocabulary Regrade preserve inventory disposition "${entry.disposition}" is not supported.`
|
|
615
|
+
)
|
|
616
|
+
);
|
|
617
|
+
}
|
|
618
|
+
if (entry.evidence.length === 0) {
|
|
619
|
+
return Result.err(
|
|
620
|
+
new ValidationError(
|
|
621
|
+
'Vocabulary Regrade preserve inventory entries need evidence.'
|
|
622
|
+
)
|
|
623
|
+
);
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
return Result.ok();
|
|
627
|
+
};
|
|
628
|
+
|
|
629
|
+
const effectivePlanForRun = (
|
|
630
|
+
plan: VocabularyRegradePlan,
|
|
631
|
+
preserveInventory: readonly VocabularyPreserveInventoryEntry[] | undefined
|
|
632
|
+
): VocabularyRegradePlan => {
|
|
633
|
+
if (preserveInventory === undefined || preserveInventory.length === 0) {
|
|
634
|
+
return plan;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
return {
|
|
638
|
+
...plan,
|
|
639
|
+
preserve: [...(plan.preserve ?? []), ...preserveInventory],
|
|
640
|
+
};
|
|
641
|
+
};
|
|
642
|
+
|
|
643
|
+
const vocabularyScanFlags = (plan: VocabularyRegradePlan): string =>
|
|
644
|
+
plan.caseSensitive === true ? 'g' : 'gi';
|
|
645
|
+
|
|
646
|
+
const includedByScope = (
|
|
647
|
+
path: string,
|
|
648
|
+
scope: VocabularyRegradeScope | undefined
|
|
649
|
+
): boolean =>
|
|
650
|
+
(scope?.include === undefined ||
|
|
651
|
+
scope.include.length === 0 ||
|
|
652
|
+
matchesAnyPathGlob(path, scope.include)) &&
|
|
653
|
+
!matchesAnyPathGlob(path, scope?.exclude);
|
|
654
|
+
|
|
655
|
+
const compilePreservePattern = (pattern: string): RegExp => {
|
|
656
|
+
try {
|
|
657
|
+
return new RegExp(pattern);
|
|
658
|
+
} catch {
|
|
659
|
+
return new RegExp(escapeRegExp(pattern));
|
|
660
|
+
}
|
|
661
|
+
};
|
|
662
|
+
|
|
663
|
+
const globalPreservePattern = (pattern: RegExp): RegExp => {
|
|
664
|
+
const flags = pattern.flags.includes('g')
|
|
665
|
+
? pattern.flags
|
|
666
|
+
: `${pattern.flags}g`;
|
|
667
|
+
return new RegExp(pattern.source, flags);
|
|
668
|
+
};
|
|
669
|
+
|
|
670
|
+
const patternOverlapsOccurrence = (
|
|
671
|
+
pattern: RegExp,
|
|
672
|
+
occurrence: SourceOccurrenceDraft
|
|
673
|
+
): boolean => {
|
|
674
|
+
const occurrenceStart = occurrence.contextColumn - 1;
|
|
675
|
+
const occurrenceEnd = occurrenceStart + occurrence.form.length;
|
|
676
|
+
|
|
677
|
+
for (const match of occurrence.context.matchAll(
|
|
678
|
+
globalPreservePattern(pattern)
|
|
679
|
+
)) {
|
|
680
|
+
const matchStart = match.index ?? 0;
|
|
681
|
+
const matchEnd = matchStart + match[0].length;
|
|
682
|
+
if (
|
|
683
|
+
matchStart !== matchEnd &&
|
|
684
|
+
occurrenceStart < matchEnd &&
|
|
685
|
+
matchStart < occurrenceEnd
|
|
686
|
+
) {
|
|
687
|
+
return true;
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
return false;
|
|
692
|
+
};
|
|
693
|
+
|
|
694
|
+
const preserveRuleForOccurrence = (
|
|
695
|
+
occurrence: SourceOccurrenceDraft,
|
|
696
|
+
plan: VocabularyRegradePlan
|
|
697
|
+
): VocabularyPreserveRule | undefined =>
|
|
698
|
+
plan.preserve?.find((rule) => {
|
|
699
|
+
if (rule.forms !== undefined && !rule.forms.includes(occurrence.form)) {
|
|
700
|
+
return false;
|
|
701
|
+
}
|
|
702
|
+
if (
|
|
703
|
+
rule.paths !== undefined &&
|
|
704
|
+
!matchesAnyPathGlob(occurrence.path, rule.paths)
|
|
705
|
+
) {
|
|
706
|
+
return false;
|
|
707
|
+
}
|
|
708
|
+
const pattern = compilePreservePattern(rule.pattern);
|
|
709
|
+
if (
|
|
710
|
+
pattern.test(occurrence.form) ||
|
|
711
|
+
patternOverlapsOccurrence(pattern, occurrence)
|
|
712
|
+
) {
|
|
713
|
+
return true;
|
|
714
|
+
}
|
|
715
|
+
return rule.forms === undefined && pattern.test(occurrence.context);
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
const occurrenceOverlaps = (
|
|
719
|
+
occurrences: readonly {
|
|
720
|
+
readonly end: number;
|
|
721
|
+
readonly start: number;
|
|
722
|
+
}[],
|
|
723
|
+
start: number,
|
|
724
|
+
end: number
|
|
725
|
+
): boolean =>
|
|
726
|
+
occurrences.some(
|
|
727
|
+
(occurrence) => start < occurrence.end && occurrence.start < end
|
|
728
|
+
);
|
|
729
|
+
|
|
730
|
+
const occurrenceDraftForSpan = (
|
|
731
|
+
file: SourceFile,
|
|
732
|
+
start: number,
|
|
733
|
+
end: number,
|
|
734
|
+
form = file.source.slice(start, end)
|
|
735
|
+
): SourceOccurrenceDraft => {
|
|
736
|
+
const { column, line } = lineColumnForOffset(file.source, start);
|
|
737
|
+
const context = contextDetailsForOffset(file.source, start, end);
|
|
738
|
+
return {
|
|
739
|
+
absolutePath: file.absolutePath,
|
|
740
|
+
column,
|
|
741
|
+
context: context.context,
|
|
742
|
+
contextColumn: context.contextColumn,
|
|
743
|
+
end,
|
|
744
|
+
form,
|
|
745
|
+
line,
|
|
746
|
+
path: file.path,
|
|
747
|
+
start,
|
|
748
|
+
};
|
|
749
|
+
};
|
|
750
|
+
|
|
751
|
+
const deferredOccurrenceFromDraft = (
|
|
752
|
+
file: SourceFile,
|
|
753
|
+
plan: VocabularyRegradePlan,
|
|
754
|
+
baseOccurrence: SourceOccurrenceDraft,
|
|
755
|
+
reason = 'unclassified-neighbor'
|
|
756
|
+
): SourceOccurrence => {
|
|
757
|
+
const preserveRule = preserveRuleForOccurrence(baseOccurrence, plan);
|
|
758
|
+
const markdownCodeContext = isMarkdownCodeContext(
|
|
759
|
+
file,
|
|
760
|
+
baseOccurrence.start,
|
|
761
|
+
baseOccurrence.end
|
|
762
|
+
);
|
|
763
|
+
const verdict = preserveRule === undefined ? 'deferred' : 'skipped';
|
|
764
|
+
return {
|
|
765
|
+
absolutePath: baseOccurrence.absolutePath,
|
|
766
|
+
column: baseOccurrence.column,
|
|
767
|
+
context: baseOccurrence.context,
|
|
768
|
+
disposition: vocabularyOccurrenceDisposition(
|
|
769
|
+
verdict,
|
|
770
|
+
preserveRule,
|
|
771
|
+
markdownCodeContext
|
|
772
|
+
),
|
|
773
|
+
end: baseOccurrence.end,
|
|
774
|
+
form: baseOccurrence.form,
|
|
775
|
+
line: baseOccurrence.line,
|
|
776
|
+
path: baseOccurrence.path,
|
|
777
|
+
reason: vocabularyOccurrenceReason(
|
|
778
|
+
preserveRule,
|
|
779
|
+
markdownCodeContext,
|
|
780
|
+
reason
|
|
781
|
+
),
|
|
782
|
+
start: baseOccurrence.start,
|
|
783
|
+
verdict,
|
|
784
|
+
};
|
|
785
|
+
};
|
|
786
|
+
|
|
787
|
+
const exactDeferredFormOccurrencesForFile = (
|
|
788
|
+
file: SourceFile,
|
|
789
|
+
plan: VocabularyRegradePlan,
|
|
790
|
+
deferForms: readonly string[],
|
|
791
|
+
targetFormSpans: readonly {
|
|
792
|
+
readonly end: number;
|
|
793
|
+
readonly start: number;
|
|
794
|
+
}[]
|
|
795
|
+
): readonly SourceOccurrence[] => {
|
|
796
|
+
const occurrences: SourceOccurrence[] = [];
|
|
797
|
+
const authoredDeferForms = new Set(
|
|
798
|
+
(plan.deferForms ?? []).map((form) => formIdentityForPlan(plan, form))
|
|
799
|
+
);
|
|
800
|
+
for (const form of deferForms) {
|
|
801
|
+
const pattern = new RegExp(escapeRegExp(form), vocabularyScanFlags(plan));
|
|
802
|
+
for (const match of file.source.matchAll(pattern)) {
|
|
803
|
+
const start = match.index ?? 0;
|
|
804
|
+
const end = start + match[0].length;
|
|
805
|
+
const isAuthoredDefer = authoredDeferForms.has(
|
|
806
|
+
formIdentityForPlan(plan, form)
|
|
807
|
+
);
|
|
808
|
+
if (
|
|
809
|
+
!hasWordBoundary(file.source, start, end) ||
|
|
810
|
+
occurrenceOverlaps(occurrences, start, end) ||
|
|
811
|
+
(!isAuthoredDefer && occurrenceOverlaps(targetFormSpans, start, end))
|
|
812
|
+
) {
|
|
813
|
+
continue;
|
|
814
|
+
}
|
|
815
|
+
occurrences.push(
|
|
816
|
+
deferredOccurrenceFromDraft(
|
|
817
|
+
file,
|
|
818
|
+
plan,
|
|
819
|
+
occurrenceDraftForSpan(file, start, end, match[0]),
|
|
820
|
+
'deferred-form'
|
|
821
|
+
)
|
|
822
|
+
);
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
return occurrences;
|
|
826
|
+
};
|
|
827
|
+
|
|
828
|
+
const targetFormSpansForFile = (
|
|
829
|
+
file: SourceFile,
|
|
830
|
+
plan: VocabularyRegradePlan,
|
|
831
|
+
targetForms: Map<string, string>
|
|
832
|
+
): readonly {
|
|
833
|
+
readonly end: number;
|
|
834
|
+
readonly start: number;
|
|
835
|
+
}[] => {
|
|
836
|
+
const spans: { end: number; start: number }[] = [];
|
|
837
|
+
for (const form of targetForms.keys()) {
|
|
838
|
+
const pattern = new RegExp(escapeRegExp(form), vocabularyScanFlags(plan));
|
|
839
|
+
for (const match of file.source.matchAll(pattern)) {
|
|
840
|
+
const start = match.index ?? 0;
|
|
841
|
+
const end = start + match[0].length;
|
|
842
|
+
if (
|
|
843
|
+
!hasWordBoundary(file.source, start, end) ||
|
|
844
|
+
occurrenceOverlaps(spans, start, end)
|
|
845
|
+
) {
|
|
846
|
+
continue;
|
|
847
|
+
}
|
|
848
|
+
spans.push({ end, start });
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
return spans;
|
|
852
|
+
};
|
|
853
|
+
|
|
854
|
+
const occurrencesForFile = (
|
|
855
|
+
file: SourceFile,
|
|
856
|
+
plan: VocabularyRegradePlan,
|
|
857
|
+
targetForms: Map<string, string>,
|
|
858
|
+
deferredOccurrences: readonly SourceOccurrence[]
|
|
859
|
+
): readonly SourceOccurrence[] => {
|
|
860
|
+
const occurrences: SourceOccurrence[] = [];
|
|
861
|
+
const candidates: SourceOccurrence[] = [];
|
|
862
|
+
const forms = [...targetForms.entries()].toSorted(
|
|
863
|
+
([left], [right]) => right.length - left.length || left.localeCompare(right)
|
|
864
|
+
);
|
|
865
|
+
|
|
866
|
+
for (const [form, replacement] of forms) {
|
|
867
|
+
const pattern = new RegExp(escapeRegExp(form), vocabularyScanFlags(plan));
|
|
868
|
+
for (const match of file.source.matchAll(pattern)) {
|
|
869
|
+
const start = match.index ?? 0;
|
|
870
|
+
const end = start + match[0].length;
|
|
871
|
+
if (
|
|
872
|
+
!hasWordBoundary(file.source, start, end) ||
|
|
873
|
+
occurrenceOverlaps(deferredOccurrences, start, end)
|
|
874
|
+
) {
|
|
875
|
+
continue;
|
|
876
|
+
}
|
|
877
|
+
const { column, line } = lineColumnForOffset(file.source, start);
|
|
878
|
+
const context = contextDetailsForOffset(file.source, start, end);
|
|
879
|
+
const baseOccurrence = {
|
|
880
|
+
absolutePath: file.absolutePath,
|
|
881
|
+
column,
|
|
882
|
+
context: context.context,
|
|
883
|
+
contextColumn: context.contextColumn,
|
|
884
|
+
end,
|
|
885
|
+
form: match[0],
|
|
886
|
+
line,
|
|
887
|
+
path: file.path,
|
|
888
|
+
start,
|
|
889
|
+
};
|
|
890
|
+
const preserveRule = preserveRuleForOccurrence(baseOccurrence, plan);
|
|
891
|
+
const markdownCodeContext = isMarkdownCodeContext(file, start, end);
|
|
892
|
+
const verdict = capturedVocabularyVerdict(
|
|
893
|
+
preserveRule,
|
|
894
|
+
markdownCodeContext
|
|
895
|
+
);
|
|
896
|
+
candidates.push({
|
|
897
|
+
absolutePath: baseOccurrence.absolutePath,
|
|
898
|
+
column: baseOccurrence.column,
|
|
899
|
+
context: baseOccurrence.context,
|
|
900
|
+
disposition: vocabularyOccurrenceDisposition(
|
|
901
|
+
verdict,
|
|
902
|
+
preserveRule,
|
|
903
|
+
markdownCodeContext
|
|
904
|
+
),
|
|
905
|
+
end: baseOccurrence.end,
|
|
906
|
+
form: baseOccurrence.form,
|
|
907
|
+
line: baseOccurrence.line,
|
|
908
|
+
path: baseOccurrence.path,
|
|
909
|
+
reason: vocabularyOccurrenceReason(
|
|
910
|
+
preserveRule,
|
|
911
|
+
markdownCodeContext,
|
|
912
|
+
'captured-form'
|
|
913
|
+
),
|
|
914
|
+
...(preserveRule === undefined && !markdownCodeContext
|
|
915
|
+
? { replacement: preserveCase(match[0], replacement) }
|
|
916
|
+
: {}),
|
|
917
|
+
start: baseOccurrence.start,
|
|
918
|
+
verdict,
|
|
919
|
+
});
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
for (const candidate of candidates.toSorted(
|
|
924
|
+
(left, right) =>
|
|
925
|
+
right.end - right.start - (left.end - left.start) ||
|
|
926
|
+
left.start - right.start
|
|
927
|
+
)) {
|
|
928
|
+
const overlaps = occurrences.some(
|
|
929
|
+
(occurrence) =>
|
|
930
|
+
candidate.start < occurrence.end && occurrence.start < candidate.end
|
|
931
|
+
);
|
|
932
|
+
if (!overlaps) {
|
|
933
|
+
occurrences.push(candidate);
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
return occurrences.toSorted((left, right) =>
|
|
938
|
+
left.path === right.path
|
|
939
|
+
? left.start - right.start
|
|
940
|
+
: left.path.localeCompare(right.path)
|
|
941
|
+
);
|
|
942
|
+
};
|
|
943
|
+
|
|
944
|
+
const deferredOccurrencesForFile = (
|
|
945
|
+
file: SourceFile,
|
|
946
|
+
plan: VocabularyRegradePlan,
|
|
947
|
+
targetForms: Map<string, string>
|
|
948
|
+
): readonly SourceOccurrence[] => {
|
|
949
|
+
const deferForms = deferFormsForPlan(plan);
|
|
950
|
+
const targetFormSpans = targetFormSpansForFile(file, plan, targetForms);
|
|
951
|
+
const knownForms = new Set(
|
|
952
|
+
plan.caseSensitive === true
|
|
953
|
+
? [...targetForms.keys(), ...deferForms]
|
|
954
|
+
: [...targetForms.keys(), ...deferForms].flatMap((form) => [
|
|
955
|
+
form,
|
|
956
|
+
form.toLowerCase(),
|
|
957
|
+
])
|
|
958
|
+
);
|
|
959
|
+
const lowerFrom = plan.from.toLowerCase();
|
|
960
|
+
const tokenPattern = /[A-Za-z_$][A-Za-z0-9_$-]*/g;
|
|
961
|
+
const occurrences = [
|
|
962
|
+
...exactDeferredFormOccurrencesForFile(
|
|
963
|
+
file,
|
|
964
|
+
plan,
|
|
965
|
+
deferForms,
|
|
966
|
+
targetFormSpans
|
|
967
|
+
),
|
|
968
|
+
];
|
|
969
|
+
|
|
970
|
+
for (const form of targetForms.keys()) {
|
|
971
|
+
const pattern = new RegExp(escapeRegExp(form), vocabularyScanFlags(plan));
|
|
972
|
+
for (const match of file.source.matchAll(pattern)) {
|
|
973
|
+
const matchStart = match.index ?? 0;
|
|
974
|
+
const matchEnd = matchStart + match[0].length;
|
|
975
|
+
if (hasWordBoundary(file.source, matchStart, matchEnd)) {
|
|
976
|
+
continue;
|
|
977
|
+
}
|
|
978
|
+
const { end, start } = expandVocabularyNeighborSpan(
|
|
979
|
+
file.source,
|
|
980
|
+
matchStart,
|
|
981
|
+
matchEnd
|
|
982
|
+
);
|
|
983
|
+
const matchedForm = file.source.slice(start, end);
|
|
984
|
+
const lowerMatchedForm = matchedForm.toLowerCase();
|
|
985
|
+
if (
|
|
986
|
+
occurrenceOverlaps(occurrences, start, end) ||
|
|
987
|
+
knownForms.has(matchedForm) ||
|
|
988
|
+
(plan.caseSensitive !== true && knownForms.has(lowerMatchedForm)) ||
|
|
989
|
+
!lowerMatchedForm.includes(lowerFrom)
|
|
990
|
+
) {
|
|
991
|
+
continue;
|
|
992
|
+
}
|
|
993
|
+
occurrences.push(
|
|
994
|
+
deferredOccurrenceFromDraft(
|
|
995
|
+
file,
|
|
996
|
+
plan,
|
|
997
|
+
occurrenceDraftForSpan(file, start, end, matchedForm)
|
|
998
|
+
)
|
|
999
|
+
);
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
for (const match of file.source.matchAll(tokenPattern)) {
|
|
1004
|
+
const [form] = match;
|
|
1005
|
+
const lower = form.toLowerCase();
|
|
1006
|
+
if (
|
|
1007
|
+
knownForms.has(form) ||
|
|
1008
|
+
(plan.caseSensitive !== true && knownForms.has(lower))
|
|
1009
|
+
) {
|
|
1010
|
+
continue;
|
|
1011
|
+
}
|
|
1012
|
+
if (!lower.includes(lowerFrom)) {
|
|
1013
|
+
continue;
|
|
1014
|
+
}
|
|
1015
|
+
const start = match.index ?? 0;
|
|
1016
|
+
const end = start + form.length;
|
|
1017
|
+
if (occurrenceOverlaps(occurrences, start, end)) {
|
|
1018
|
+
continue;
|
|
1019
|
+
}
|
|
1020
|
+
occurrences.push(
|
|
1021
|
+
deferredOccurrenceFromDraft(
|
|
1022
|
+
file,
|
|
1023
|
+
plan,
|
|
1024
|
+
occurrenceDraftForSpan(file, start, end, form)
|
|
1025
|
+
)
|
|
1026
|
+
);
|
|
1027
|
+
}
|
|
1028
|
+
return occurrences.toSorted((left, right) =>
|
|
1029
|
+
left.path === right.path
|
|
1030
|
+
? left.start - right.start
|
|
1031
|
+
: left.path.localeCompare(right.path)
|
|
1032
|
+
);
|
|
1033
|
+
};
|
|
1034
|
+
|
|
1035
|
+
const entryForOccurrences = (
|
|
1036
|
+
path: string,
|
|
1037
|
+
occurrences: readonly SourceOccurrence[]
|
|
1038
|
+
): RegradeReportEntry | null => {
|
|
1039
|
+
if (occurrences.length === 0) {
|
|
1040
|
+
return null;
|
|
1041
|
+
}
|
|
1042
|
+
const hasDeferred = occurrences.some(
|
|
1043
|
+
(occurrence) => occurrence.verdict === 'deferred'
|
|
1044
|
+
);
|
|
1045
|
+
const hasModified = occurrences.some(
|
|
1046
|
+
(occurrence) => occurrence.verdict === 'modified'
|
|
1047
|
+
);
|
|
1048
|
+
if (hasDeferred) {
|
|
1049
|
+
return {
|
|
1050
|
+
notes: [
|
|
1051
|
+
`Found ${occurrences.length} vocabulary occurrence(s); judgment deferred.`,
|
|
1052
|
+
],
|
|
1053
|
+
outcome: 'needs-review',
|
|
1054
|
+
path,
|
|
1055
|
+
reason: 'vocabulary-judgment-deferred',
|
|
1056
|
+
reviewDetails: occurrences
|
|
1057
|
+
.filter((occurrence) => occurrence.verdict === 'deferred')
|
|
1058
|
+
.map((occurrence) => ({
|
|
1059
|
+
expectedTarget:
|
|
1060
|
+
'Add an override or preserve rule to the regrade plan.',
|
|
1061
|
+
reason: occurrence.reason,
|
|
1062
|
+
span: {
|
|
1063
|
+
column: occurrence.column,
|
|
1064
|
+
end: occurrence.end,
|
|
1065
|
+
line: occurrence.line,
|
|
1066
|
+
start: occurrence.start,
|
|
1067
|
+
},
|
|
1068
|
+
symbol: occurrence.form,
|
|
1069
|
+
})),
|
|
1070
|
+
};
|
|
1071
|
+
}
|
|
1072
|
+
if (hasModified) {
|
|
1073
|
+
return {
|
|
1074
|
+
notes: [
|
|
1075
|
+
`Found ${occurrences.length} vocabulary occurrence(s); safe modifications available.`,
|
|
1076
|
+
],
|
|
1077
|
+
outcome: 'rewrite',
|
|
1078
|
+
path,
|
|
1079
|
+
};
|
|
1080
|
+
}
|
|
1081
|
+
return {
|
|
1082
|
+
notes: [`Skipped ${occurrences.length} vocabulary occurrence(s).`],
|
|
1083
|
+
outcome: 'no-op',
|
|
1084
|
+
path,
|
|
1085
|
+
};
|
|
1086
|
+
};
|
|
1087
|
+
|
|
1088
|
+
const applyOccurrenceRewrites = (
|
|
1089
|
+
file: SourceFile,
|
|
1090
|
+
occurrences: readonly SourceOccurrence[]
|
|
1091
|
+
): string => {
|
|
1092
|
+
let nextSource = file.source;
|
|
1093
|
+
for (const occurrence of occurrences.toReversed()) {
|
|
1094
|
+
if (
|
|
1095
|
+
occurrence.verdict !== 'modified' ||
|
|
1096
|
+
occurrence.replacement === undefined
|
|
1097
|
+
) {
|
|
1098
|
+
continue;
|
|
1099
|
+
}
|
|
1100
|
+
nextSource =
|
|
1101
|
+
nextSource.slice(0, occurrence.start) +
|
|
1102
|
+
occurrence.replacement +
|
|
1103
|
+
nextSource.slice(occurrence.end);
|
|
1104
|
+
}
|
|
1105
|
+
return nextSource;
|
|
1106
|
+
};
|
|
1107
|
+
|
|
1108
|
+
const buildVocabularyEvaluation = (params: {
|
|
1109
|
+
readonly apply?: boolean;
|
|
1110
|
+
readonly effectivePlan?: VocabularyRegradePlan | undefined;
|
|
1111
|
+
readonly files: readonly SourceFile[];
|
|
1112
|
+
readonly plan: VocabularyRegradePlan;
|
|
1113
|
+
readonly preserveInventory?: readonly VocabularyPreserveInventoryEntry[];
|
|
1114
|
+
readonly root: string;
|
|
1115
|
+
readonly skipped: readonly SkippedSource[];
|
|
1116
|
+
}): VocabularyEvaluation => {
|
|
1117
|
+
const effectivePlan = params.effectivePlan ?? params.plan;
|
|
1118
|
+
const targetForms = targetFormsForPlan(effectivePlan);
|
|
1119
|
+
const scopedFiles = params.files.filter((file) =>
|
|
1120
|
+
includedByScope(file.path, effectivePlan.scope)
|
|
1121
|
+
);
|
|
1122
|
+
const scopeSkipped: SkippedSource[] = params.files
|
|
1123
|
+
.filter((file) => !includedByScope(file.path, effectivePlan.scope))
|
|
1124
|
+
.map((file) => ({ path: file.path, reason: 'excluded-by-regrade-scope' }));
|
|
1125
|
+
const occurrences = scopedFiles.flatMap((file) => {
|
|
1126
|
+
const deferredOccurrences = deferredOccurrencesForFile(
|
|
1127
|
+
file,
|
|
1128
|
+
effectivePlan,
|
|
1129
|
+
targetForms
|
|
1130
|
+
);
|
|
1131
|
+
return [
|
|
1132
|
+
...occurrencesForFile(
|
|
1133
|
+
file,
|
|
1134
|
+
effectivePlan,
|
|
1135
|
+
targetForms,
|
|
1136
|
+
deferredOccurrences
|
|
1137
|
+
),
|
|
1138
|
+
...deferredOccurrences,
|
|
1139
|
+
];
|
|
1140
|
+
});
|
|
1141
|
+
const occurrencesByPath = new Map<string, SourceOccurrence[]>();
|
|
1142
|
+
for (const occurrence of occurrences) {
|
|
1143
|
+
const existing = occurrencesByPath.get(occurrence.path) ?? [];
|
|
1144
|
+
occurrencesByPath.set(occurrence.path, [...existing, occurrence]);
|
|
1145
|
+
}
|
|
1146
|
+
const entries = [...occurrencesByPath.entries()]
|
|
1147
|
+
.flatMap(([path, pathOccurrences]) => {
|
|
1148
|
+
const entry = entryForOccurrences(path, pathOccurrences);
|
|
1149
|
+
return entry === null ? [] : [entry];
|
|
1150
|
+
})
|
|
1151
|
+
.toSorted((left, right) => left.path.localeCompare(right.path));
|
|
1152
|
+
const rewrittenPaths = new Set(
|
|
1153
|
+
occurrences
|
|
1154
|
+
.filter((occurrence) => occurrence.verdict === 'modified')
|
|
1155
|
+
.map((occurrence) => occurrence.path)
|
|
1156
|
+
);
|
|
1157
|
+
const deferredForms = uniqueSorted(
|
|
1158
|
+
occurrences
|
|
1159
|
+
.filter((occurrence) => occurrence.verdict === 'deferred')
|
|
1160
|
+
.map((occurrence) => occurrence.form)
|
|
1161
|
+
);
|
|
1162
|
+
const modifiedOccurrences = occurrences.filter(
|
|
1163
|
+
(occurrence) => occurrence.verdict === 'modified'
|
|
1164
|
+
);
|
|
1165
|
+
const skippedOccurrences = occurrences.filter(
|
|
1166
|
+
(occurrence) => occurrence.verdict === 'skipped'
|
|
1167
|
+
);
|
|
1168
|
+
const deferredOccurrences = occurrences.filter(
|
|
1169
|
+
(occurrence) => occurrence.verdict === 'deferred'
|
|
1170
|
+
);
|
|
1171
|
+
const unresolvedOccurrences = occurrences.filter(
|
|
1172
|
+
(occurrence) =>
|
|
1173
|
+
occurrence.verdict === 'modified' || occurrence.verdict === 'deferred'
|
|
1174
|
+
);
|
|
1175
|
+
const forms: Record<string, VocabularyVerdict> = {};
|
|
1176
|
+
for (const occurrence of occurrences) {
|
|
1177
|
+
const current = forms[occurrence.form];
|
|
1178
|
+
if (occurrence.verdict === 'deferred') {
|
|
1179
|
+
forms[occurrence.form] = 'deferred';
|
|
1180
|
+
continue;
|
|
1181
|
+
}
|
|
1182
|
+
if (occurrence.verdict === 'modified' && current !== 'deferred') {
|
|
1183
|
+
forms[occurrence.form] = 'modified';
|
|
1184
|
+
continue;
|
|
1185
|
+
}
|
|
1186
|
+
if (current === undefined) {
|
|
1187
|
+
forms[occurrence.form] = 'skipped';
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
const gateReasons: string[] = [];
|
|
1191
|
+
if (modifiedOccurrences.length > 0) {
|
|
1192
|
+
gateReasons.push(
|
|
1193
|
+
params.apply === true
|
|
1194
|
+
? 'source-forms-remain-after-apply'
|
|
1195
|
+
: 'safe-modifications-not-yet-applied'
|
|
1196
|
+
);
|
|
1197
|
+
}
|
|
1198
|
+
if (deferredForms.length > 0) {
|
|
1199
|
+
gateReasons.push('deferred-forms-or-occurrences');
|
|
1200
|
+
}
|
|
1201
|
+
const open = unresolvedOccurrences.length;
|
|
1202
|
+
|
|
1203
|
+
return {
|
|
1204
|
+
entries: [
|
|
1205
|
+
...entries,
|
|
1206
|
+
...params.skipped.map((entry) => ({
|
|
1207
|
+
outcome: 'skip' as const,
|
|
1208
|
+
path: entry.path,
|
|
1209
|
+
reason: entry.reason,
|
|
1210
|
+
})),
|
|
1211
|
+
...scopeSkipped.map((entry) => ({
|
|
1212
|
+
outcome: 'skip' as const,
|
|
1213
|
+
path: entry.path,
|
|
1214
|
+
reason: entry.reason,
|
|
1215
|
+
})),
|
|
1216
|
+
].toSorted((left, right) => left.path.localeCompare(right.path)),
|
|
1217
|
+
occurrences,
|
|
1218
|
+
run: {
|
|
1219
|
+
ledger: {
|
|
1220
|
+
cycle: 1,
|
|
1221
|
+
forms,
|
|
1222
|
+
occurrences: occurrences.map(
|
|
1223
|
+
({ absolutePath: _absolutePath, ...occurrence }) => occurrence
|
|
1224
|
+
),
|
|
1225
|
+
},
|
|
1226
|
+
plan: params.plan,
|
|
1227
|
+
...(params.preserveInventory === undefined ||
|
|
1228
|
+
params.preserveInventory.length === 0
|
|
1229
|
+
? {}
|
|
1230
|
+
: { preserveInventory: params.preserveInventory }),
|
|
1231
|
+
report: {
|
|
1232
|
+
applied: params.apply === true ? modifiedOccurrences.length : 0,
|
|
1233
|
+
deferred: deferredOccurrences.length,
|
|
1234
|
+
dispositions: vocabularyDispositionCounts(occurrences),
|
|
1235
|
+
filesChanged: params.apply === true ? rewrittenPaths.size : 0,
|
|
1236
|
+
gate: {
|
|
1237
|
+
reasons: gateReasons,
|
|
1238
|
+
remaining: open,
|
|
1239
|
+
remainingByDisposition: vocabularyDispositionCounts(
|
|
1240
|
+
unresolvedOccurrences
|
|
1241
|
+
),
|
|
1242
|
+
status: gateReasons.length === 0 ? 'green' : 'open',
|
|
1243
|
+
},
|
|
1244
|
+
modified: modifiedOccurrences.length,
|
|
1245
|
+
open,
|
|
1246
|
+
skipped: skippedOccurrences.length,
|
|
1247
|
+
},
|
|
1248
|
+
},
|
|
1249
|
+
scanned: scopedFiles.length,
|
|
1250
|
+
skipped: [...params.skipped, ...scopeSkipped],
|
|
1251
|
+
};
|
|
1252
|
+
};
|
|
1253
|
+
|
|
1254
|
+
const skippedByReason = (
|
|
1255
|
+
skipped: readonly SkippedSource[]
|
|
1256
|
+
): Readonly<Record<string, number>> => {
|
|
1257
|
+
const counts = new Map<string, number>();
|
|
1258
|
+
for (const entry of skipped) {
|
|
1259
|
+
counts.set(entry.reason, (counts.get(entry.reason) ?? 0) + 1);
|
|
1260
|
+
}
|
|
1261
|
+
return Object.fromEntries(
|
|
1262
|
+
[...counts.entries()].toSorted(([left], [right]) =>
|
|
1263
|
+
left.localeCompare(right)
|
|
1264
|
+
)
|
|
1265
|
+
);
|
|
1266
|
+
};
|
|
1267
|
+
|
|
1268
|
+
const withApplySummary = (
|
|
1269
|
+
report: RegradeReport,
|
|
1270
|
+
apply: RegradeApplySummary
|
|
1271
|
+
): RegradeReport => ({
|
|
1272
|
+
...report,
|
|
1273
|
+
apply,
|
|
1274
|
+
});
|
|
1275
|
+
|
|
1276
|
+
const applyVocabularyEvaluation = (
|
|
1277
|
+
files: readonly SourceFile[],
|
|
1278
|
+
evaluation: VocabularyEvaluation
|
|
1279
|
+
): Result<RegradeApplySummary, InternalError> => {
|
|
1280
|
+
const changedFiles = new Set<string>();
|
|
1281
|
+
let applied = 0;
|
|
1282
|
+
for (const file of files) {
|
|
1283
|
+
const fileOccurrences = evaluation.occurrences.filter(
|
|
1284
|
+
(occurrence) =>
|
|
1285
|
+
occurrence.path === file.path && occurrence.verdict === 'modified'
|
|
1286
|
+
);
|
|
1287
|
+
if (fileOccurrences.length === 0) {
|
|
1288
|
+
continue;
|
|
1289
|
+
}
|
|
1290
|
+
const nextSource = applyOccurrenceRewrites(file, fileOccurrences);
|
|
1291
|
+
if (nextSource === file.source) {
|
|
1292
|
+
continue;
|
|
1293
|
+
}
|
|
1294
|
+
try {
|
|
1295
|
+
writeFileSync(file.absolutePath, nextSource, 'utf8');
|
|
1296
|
+
} catch (error: unknown) {
|
|
1297
|
+
return Result.err(
|
|
1298
|
+
new InternalError(
|
|
1299
|
+
`Failed to apply vocabulary regrade rewrite for "${file.path}".`,
|
|
1300
|
+
{
|
|
1301
|
+
cause: error instanceof Error ? error : new Error(String(error)),
|
|
1302
|
+
context: {
|
|
1303
|
+
applied,
|
|
1304
|
+
filesChanged: changedFiles.size,
|
|
1305
|
+
path: file.path,
|
|
1306
|
+
},
|
|
1307
|
+
}
|
|
1308
|
+
)
|
|
1309
|
+
);
|
|
1310
|
+
}
|
|
1311
|
+
applied += fileOccurrences.length;
|
|
1312
|
+
changedFiles.add(file.path);
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
const reviewFiles = new Set(
|
|
1316
|
+
evaluation.occurrences
|
|
1317
|
+
.filter((occurrence) => occurrence.verdict === 'deferred')
|
|
1318
|
+
.map((occurrence) => occurrence.path)
|
|
1319
|
+
);
|
|
1320
|
+
const skippedOccurrences = evaluation.occurrences.filter(
|
|
1321
|
+
(occurrence) => occurrence.verdict === 'skipped'
|
|
1322
|
+
);
|
|
1323
|
+
|
|
1324
|
+
return Result.ok({
|
|
1325
|
+
applied,
|
|
1326
|
+
filesChanged: changedFiles.size,
|
|
1327
|
+
review: reviewFiles.size,
|
|
1328
|
+
skipped: skippedOccurrences.length + evaluation.skipped.length,
|
|
1329
|
+
unknown: 0,
|
|
1330
|
+
});
|
|
1331
|
+
};
|
|
1332
|
+
|
|
1333
|
+
const readVocabularySourceFiles = (
|
|
1334
|
+
collected: NonNullable<ReturnType<typeof collectDownstreamSources>>
|
|
1335
|
+
): {
|
|
1336
|
+
readonly files: readonly SourceFile[];
|
|
1337
|
+
readonly skipped: readonly SkippedSource[];
|
|
1338
|
+
} => {
|
|
1339
|
+
const files: SourceFile[] = [];
|
|
1340
|
+
const skipped: SkippedSource[] = [...collected.skipped];
|
|
1341
|
+
for (const file of collected.files) {
|
|
1342
|
+
try {
|
|
1343
|
+
files.push({
|
|
1344
|
+
absolutePath: file.absolutePath,
|
|
1345
|
+
path: file.path,
|
|
1346
|
+
source: readFileSync(file.absolutePath, 'utf8'),
|
|
1347
|
+
});
|
|
1348
|
+
} catch {
|
|
1349
|
+
skipped.push({ path: file.path, reason: 'unreadable-file' });
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
return { files, skipped };
|
|
1353
|
+
};
|
|
1354
|
+
|
|
1355
|
+
const buildRunVocabularyEvaluation = (params: {
|
|
1356
|
+
readonly apply: boolean;
|
|
1357
|
+
readonly effectivePlan: VocabularyRegradePlan;
|
|
1358
|
+
readonly files: readonly SourceFile[];
|
|
1359
|
+
readonly plan: VocabularyRegradePlan;
|
|
1360
|
+
readonly preserveInventory:
|
|
1361
|
+
| readonly VocabularyPreserveInventoryEntry[]
|
|
1362
|
+
| undefined;
|
|
1363
|
+
readonly root: string;
|
|
1364
|
+
readonly skipped: readonly SkippedSource[];
|
|
1365
|
+
}): VocabularyEvaluation =>
|
|
1366
|
+
buildVocabularyEvaluation({
|
|
1367
|
+
apply: params.apply,
|
|
1368
|
+
effectivePlan: params.effectivePlan,
|
|
1369
|
+
files: params.files,
|
|
1370
|
+
plan: params.plan,
|
|
1371
|
+
...(params.preserveInventory === undefined
|
|
1372
|
+
? {}
|
|
1373
|
+
: { preserveInventory: params.preserveInventory }),
|
|
1374
|
+
root: params.root,
|
|
1375
|
+
skipped: params.skipped,
|
|
1376
|
+
});
|
|
1377
|
+
|
|
1378
|
+
export const runVocabularyRegrade = (params: {
|
|
1379
|
+
readonly apply?: boolean;
|
|
1380
|
+
readonly includeEntries?: 'actionable' | 'all';
|
|
1381
|
+
readonly plan: VocabularyRegradePlan;
|
|
1382
|
+
readonly preserveInventory?: readonly VocabularyPreserveInventoryEntry[];
|
|
1383
|
+
readonly root: string;
|
|
1384
|
+
}): Result<RegradeReport | null, InternalError | ValidationError> => {
|
|
1385
|
+
const planValidation = validateVocabularyPlan(params.plan);
|
|
1386
|
+
if (planValidation.isErr()) {
|
|
1387
|
+
return planValidation;
|
|
1388
|
+
}
|
|
1389
|
+
const inventoryValidation = validatePreserveInventory(
|
|
1390
|
+
params.preserveInventory
|
|
1391
|
+
);
|
|
1392
|
+
if (inventoryValidation.isErr()) {
|
|
1393
|
+
return inventoryValidation;
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
const effectivePlan = effectivePlanForRun(
|
|
1397
|
+
params.plan,
|
|
1398
|
+
params.preserveInventory
|
|
1399
|
+
);
|
|
1400
|
+
|
|
1401
|
+
const collected = collectDownstreamSources(params.root, {
|
|
1402
|
+
extensions: effectivePlan.scope?.extensions ?? VOCABULARY_SOURCE_EXTENSIONS,
|
|
1403
|
+
...(effectivePlan.scope?.exclude === undefined
|
|
1404
|
+
? {}
|
|
1405
|
+
: { exclude: effectivePlan.scope.exclude }),
|
|
1406
|
+
...(effectivePlan.scope?.include === undefined
|
|
1407
|
+
? {}
|
|
1408
|
+
: { include: effectivePlan.scope.include }),
|
|
1409
|
+
...(effectivePlan.scope?.ignoredDirectories === undefined
|
|
1410
|
+
? {}
|
|
1411
|
+
: { ignoredDirectories: effectivePlan.scope.ignoredDirectories }),
|
|
1412
|
+
} satisfies DownstreamCollectionOptions);
|
|
1413
|
+
if (collected === null) {
|
|
1414
|
+
return Result.ok(null);
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
const { files, skipped } = readVocabularySourceFiles(collected);
|
|
1418
|
+
|
|
1419
|
+
const dryRunEffectiveEvaluation = buildRunVocabularyEvaluation({
|
|
1420
|
+
apply: false,
|
|
1421
|
+
effectivePlan,
|
|
1422
|
+
files,
|
|
1423
|
+
plan: params.plan,
|
|
1424
|
+
preserveInventory: params.preserveInventory,
|
|
1425
|
+
root: params.root,
|
|
1426
|
+
skipped,
|
|
1427
|
+
});
|
|
1428
|
+
let reportEvaluation = dryRunEffectiveEvaluation;
|
|
1429
|
+
let applySummary: RegradeApplySummary | undefined;
|
|
1430
|
+
|
|
1431
|
+
if (params.apply === true) {
|
|
1432
|
+
const applyResult = applyVocabularyEvaluation(
|
|
1433
|
+
files,
|
|
1434
|
+
dryRunEffectiveEvaluation
|
|
1435
|
+
);
|
|
1436
|
+
if (applyResult.isErr()) {
|
|
1437
|
+
return applyResult;
|
|
1438
|
+
}
|
|
1439
|
+
applySummary = applyResult.value;
|
|
1440
|
+
const appliedFiles = files.map((file) => ({
|
|
1441
|
+
...file,
|
|
1442
|
+
source: readFileSync(file.absolutePath, 'utf8'),
|
|
1443
|
+
}));
|
|
1444
|
+
reportEvaluation = buildRunVocabularyEvaluation({
|
|
1445
|
+
apply: true,
|
|
1446
|
+
effectivePlan,
|
|
1447
|
+
files: appliedFiles,
|
|
1448
|
+
plan: params.plan,
|
|
1449
|
+
preserveInventory: params.preserveInventory,
|
|
1450
|
+
root: params.root,
|
|
1451
|
+
skipped,
|
|
1452
|
+
});
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
const entrySelection = params.includeEntries ?? 'actionable';
|
|
1456
|
+
const reportEntries = reportEvaluation.entries;
|
|
1457
|
+
const actionableEntries = reportEntries.filter(
|
|
1458
|
+
(entry) => entry.outcome === 'rewrite' || entry.outcome === 'needs-review'
|
|
1459
|
+
);
|
|
1460
|
+
const reportSkippedByReason = skippedByReason(reportEvaluation.skipped);
|
|
1461
|
+
const report: RegradeReport = {
|
|
1462
|
+
entries: entrySelection === 'all' ? reportEntries : actionableEntries,
|
|
1463
|
+
matched: actionableEntries.length,
|
|
1464
|
+
review: reportEntries.filter((entry) => entry.outcome === 'needs-review')
|
|
1465
|
+
.length,
|
|
1466
|
+
rewritten: reportEntries.filter((entry) => entry.outcome === 'rewrite')
|
|
1467
|
+
.length,
|
|
1468
|
+
root: collected.root,
|
|
1469
|
+
run:
|
|
1470
|
+
applySummary === undefined
|
|
1471
|
+
? reportEvaluation.run
|
|
1472
|
+
: {
|
|
1473
|
+
...reportEvaluation.run,
|
|
1474
|
+
report: {
|
|
1475
|
+
...reportEvaluation.run.report,
|
|
1476
|
+
applied: applySummary.applied,
|
|
1477
|
+
filesChanged: applySummary.filesChanged,
|
|
1478
|
+
},
|
|
1479
|
+
},
|
|
1480
|
+
scan: buildRegradeScanSummary({
|
|
1481
|
+
matchedPaths: actionableEntries.map((entry) => entry.path),
|
|
1482
|
+
occurrencePaths: reportEvaluation.occurrences.map(
|
|
1483
|
+
(occurrence) => occurrence.path
|
|
1484
|
+
),
|
|
1485
|
+
scanned: reportEvaluation.scanned,
|
|
1486
|
+
skipped: reportEvaluation.skipped.length,
|
|
1487
|
+
skippedByReason: reportSkippedByReason,
|
|
1488
|
+
}),
|
|
1489
|
+
scanned: reportEvaluation.scanned,
|
|
1490
|
+
selectedClassIds: [
|
|
1491
|
+
params.plan.id ?? `vocabulary:${params.plan.from}->${params.plan.to}`,
|
|
1492
|
+
],
|
|
1493
|
+
skipped: reportEvaluation.skipped.length,
|
|
1494
|
+
skipsByReason: reportSkippedByReason,
|
|
1495
|
+
unknownClassIds: [],
|
|
1496
|
+
};
|
|
1497
|
+
|
|
1498
|
+
return Result.ok(
|
|
1499
|
+
applySummary === undefined ? report : withApplySummary(report, applySummary)
|
|
1500
|
+
);
|
|
1501
|
+
};
|
|
1502
|
+
|
|
1503
|
+
const vocabularyPreserveRuleSchema = z.object({
|
|
1504
|
+
disposition: z
|
|
1505
|
+
.enum(vocabularyDispositionValues)
|
|
1506
|
+
.optional()
|
|
1507
|
+
.describe('Classification for occurrences preserved by this rule'),
|
|
1508
|
+
forms: z
|
|
1509
|
+
.array(z.string().min(1))
|
|
1510
|
+
.optional()
|
|
1511
|
+
.describe('Matched forms this preserve rule applies to'),
|
|
1512
|
+
paths: z
|
|
1513
|
+
.array(z.string())
|
|
1514
|
+
.optional()
|
|
1515
|
+
.describe('Root-relative path patterns where the preserve rule applies'),
|
|
1516
|
+
pattern: z.string().describe('Regex or literal pattern to preserve'),
|
|
1517
|
+
reason: z.string().optional().describe('Why this form is preserved'),
|
|
1518
|
+
});
|
|
1519
|
+
|
|
1520
|
+
const vocabularyPreserveInventoryEntrySchema =
|
|
1521
|
+
vocabularyPreserveRuleSchema.extend({
|
|
1522
|
+
evidence: z
|
|
1523
|
+
.array(z.string().min(1))
|
|
1524
|
+
.describe('Graph or surface facts that justify this derived preserve'),
|
|
1525
|
+
source: z.literal('derived-live-api').describe('Derived inventory source'),
|
|
1526
|
+
});
|
|
1527
|
+
|
|
1528
|
+
const vocabularyDispositionCountSchema = z.object(
|
|
1529
|
+
Object.fromEntries(
|
|
1530
|
+
vocabularyDispositionValues.map((disposition) => [
|
|
1531
|
+
disposition,
|
|
1532
|
+
z.number().optional(),
|
|
1533
|
+
])
|
|
1534
|
+
) as Record<VocabularyDisposition, z.ZodOptional<z.ZodNumber>>
|
|
1535
|
+
);
|
|
1536
|
+
|
|
1537
|
+
const vocabularyRegradeScopeSchema = z.object({
|
|
1538
|
+
exclude: z
|
|
1539
|
+
.array(z.string())
|
|
1540
|
+
.optional()
|
|
1541
|
+
.describe('Root-relative path patterns to exclude from this regrade'),
|
|
1542
|
+
extensions: z
|
|
1543
|
+
.array(z.string())
|
|
1544
|
+
.optional()
|
|
1545
|
+
.describe('Source file extensions to scan for this regrade'),
|
|
1546
|
+
ignoredDirectories: z
|
|
1547
|
+
.array(z.string())
|
|
1548
|
+
.optional()
|
|
1549
|
+
.describe(
|
|
1550
|
+
'Deprecated compatibility override for legacy plans. Use exclude globs for new plans.'
|
|
1551
|
+
),
|
|
1552
|
+
include: z
|
|
1553
|
+
.array(z.string())
|
|
1554
|
+
.optional()
|
|
1555
|
+
.describe('Root-relative path patterns to include in this regrade'),
|
|
1556
|
+
});
|
|
1557
|
+
|
|
1558
|
+
export const vocabularyRegradePlanSchema = z.object({
|
|
1559
|
+
caseSensitive: z
|
|
1560
|
+
.boolean()
|
|
1561
|
+
.optional()
|
|
1562
|
+
.describe('Whether source form scanning preserves case exactly'),
|
|
1563
|
+
deferForms: z
|
|
1564
|
+
.array(z.string().min(1))
|
|
1565
|
+
.optional()
|
|
1566
|
+
.describe('Known forms that must be inventoried for review, not rewritten'),
|
|
1567
|
+
from: z.string().min(1).describe('Source vocabulary term or phrase'),
|
|
1568
|
+
id: z.string().optional().describe('Stable authored regrade plan id'),
|
|
1569
|
+
intent: z.string().optional().describe('Human-authored migration intent'),
|
|
1570
|
+
kind: z.literal('vocabulary').describe('Regrade plan kind'),
|
|
1571
|
+
overrides: z
|
|
1572
|
+
.record(z.string().min(1), z.string().min(1))
|
|
1573
|
+
.optional()
|
|
1574
|
+
.describe('Explicit source-form to target-form mappings'),
|
|
1575
|
+
preserve: z
|
|
1576
|
+
.array(vocabularyPreserveRuleSchema)
|
|
1577
|
+
.optional()
|
|
1578
|
+
.describe('Forms or contexts that are intentionally preserved'),
|
|
1579
|
+
scope: vocabularyRegradeScopeSchema
|
|
1580
|
+
.optional()
|
|
1581
|
+
.describe('Source scope for this regrade plan'),
|
|
1582
|
+
to: z.string().min(1).describe('Target vocabulary term or phrase'),
|
|
1583
|
+
});
|
|
1584
|
+
|
|
1585
|
+
export const vocabularyRegradeRunOutput = z.object({
|
|
1586
|
+
ledger: z
|
|
1587
|
+
.object({
|
|
1588
|
+
cycle: z.number().describe('Observed regrade run cycle'),
|
|
1589
|
+
forms: z
|
|
1590
|
+
.record(z.string(), z.enum(['deferred', 'modified', 'skipped']))
|
|
1591
|
+
.describe('Observed per-form triage verdicts for this run'),
|
|
1592
|
+
occurrences: z
|
|
1593
|
+
.array(
|
|
1594
|
+
z.object({
|
|
1595
|
+
column: z.number().describe('One-based source column'),
|
|
1596
|
+
context: z.string().describe('Source-line context'),
|
|
1597
|
+
disposition: z
|
|
1598
|
+
.enum(vocabularyDispositionValues)
|
|
1599
|
+
.describe('Occurrence-level classification beside the verdict'),
|
|
1600
|
+
end: z.number().describe('Source end offset'),
|
|
1601
|
+
form: z.string().describe('Matched vocabulary form'),
|
|
1602
|
+
line: z.number().describe('One-based source line'),
|
|
1603
|
+
path: z.string().describe('Root-relative POSIX path'),
|
|
1604
|
+
reason: z.string().describe('Why the occurrence got this verdict'),
|
|
1605
|
+
replacement: z
|
|
1606
|
+
.string()
|
|
1607
|
+
.optional()
|
|
1608
|
+
.describe('Replacement text for modified verdicts'),
|
|
1609
|
+
start: z.number().describe('Source start offset'),
|
|
1610
|
+
verdict: z
|
|
1611
|
+
.enum(['deferred', 'modified', 'skipped'])
|
|
1612
|
+
.describe('Occurrence-level verdict'),
|
|
1613
|
+
})
|
|
1614
|
+
)
|
|
1615
|
+
.describe('Observed occurrence-level ledger for this run'),
|
|
1616
|
+
})
|
|
1617
|
+
.describe('Observed run ledger'),
|
|
1618
|
+
plan: vocabularyRegradePlanSchema.describe('Authored regrade plan'),
|
|
1619
|
+
preserveInventory: z
|
|
1620
|
+
.array(vocabularyPreserveInventoryEntrySchema)
|
|
1621
|
+
.optional()
|
|
1622
|
+
.describe(
|
|
1623
|
+
'Derived live-API preserve inventory applied at run time without changing the authored plan'
|
|
1624
|
+
),
|
|
1625
|
+
report: z
|
|
1626
|
+
.object({
|
|
1627
|
+
applied: z.number().describe('Modified occurrences applied to disk'),
|
|
1628
|
+
deferred: z.number().describe('Deferred occurrence count'),
|
|
1629
|
+
dispositions: z
|
|
1630
|
+
.object(vocabularyDispositionCountSchema.shape)
|
|
1631
|
+
.describe('Occurrence counts grouped by disposition'),
|
|
1632
|
+
filesChanged: z.number().describe('Distinct files changed on disk'),
|
|
1633
|
+
gate: z
|
|
1634
|
+
.object({
|
|
1635
|
+
reasons: z.array(z.string()).describe('Open-gate reasons'),
|
|
1636
|
+
remaining: z.number().describe('Unresolved occurrence count'),
|
|
1637
|
+
remainingByDisposition: z
|
|
1638
|
+
.object(vocabularyDispositionCountSchema.shape)
|
|
1639
|
+
.describe('Unresolved occurrence counts grouped by disposition'),
|
|
1640
|
+
status: z
|
|
1641
|
+
.enum(['green', 'open'])
|
|
1642
|
+
.describe('Whether the run is complete'),
|
|
1643
|
+
})
|
|
1644
|
+
.describe('Completion gate derived from the run ledger'),
|
|
1645
|
+
modified: z.number().describe('Modified occurrence count'),
|
|
1646
|
+
open: z
|
|
1647
|
+
.number()
|
|
1648
|
+
.describe(
|
|
1649
|
+
'Deferred or unapplied modified occurrences holding the gate open'
|
|
1650
|
+
),
|
|
1651
|
+
skipped: z.number().describe('Skipped occurrence count'),
|
|
1652
|
+
})
|
|
1653
|
+
.describe('Projected run report'),
|
|
1654
|
+
});
|
|
1655
|
+
|
|
1656
|
+
const vocabularyTransitionRecordEnvironmentSchema = z
|
|
1657
|
+
.object({
|
|
1658
|
+
commitSha: z.string().optional(),
|
|
1659
|
+
engineVersion: z.string().optional(),
|
|
1660
|
+
graphHash: z.string().optional(),
|
|
1661
|
+
root: z.string(),
|
|
1662
|
+
})
|
|
1663
|
+
.strict();
|
|
1664
|
+
|
|
1665
|
+
const vocabularyTransitionRecordReportSchema = z
|
|
1666
|
+
.object({
|
|
1667
|
+
apply: z.unknown().optional(),
|
|
1668
|
+
entries: z.array(z.unknown()),
|
|
1669
|
+
matched: z.number(),
|
|
1670
|
+
review: z.number(),
|
|
1671
|
+
rewritten: z.number(),
|
|
1672
|
+
root: z.string(),
|
|
1673
|
+
run: vocabularyRegradeRunOutput,
|
|
1674
|
+
scan: z.unknown(),
|
|
1675
|
+
scanned: z.number(),
|
|
1676
|
+
selectedClassIds: z.array(z.string()),
|
|
1677
|
+
skipped: z.number(),
|
|
1678
|
+
skipsByReason: z.record(z.string(), z.number()),
|
|
1679
|
+
unknownClassIds: z.array(z.string()),
|
|
1680
|
+
})
|
|
1681
|
+
.strict();
|
|
1682
|
+
|
|
1683
|
+
const normalizeTransitionRecordPath = (path: string): string =>
|
|
1684
|
+
normalize(path).replaceAll('\\', '/');
|
|
1685
|
+
|
|
1686
|
+
const isSafeRootRelativeRecordPath = (path: string): boolean => {
|
|
1687
|
+
const normalized = normalizeTransitionRecordPath(path);
|
|
1688
|
+
return (
|
|
1689
|
+
normalized.length > 0 &&
|
|
1690
|
+
!isAbsolute(normalized) &&
|
|
1691
|
+
normalized !== '..' &&
|
|
1692
|
+
!normalized.startsWith('../')
|
|
1693
|
+
);
|
|
1694
|
+
};
|
|
1695
|
+
|
|
1696
|
+
export const vocabularyTransitionRecordSchema = z
|
|
1697
|
+
.object({
|
|
1698
|
+
environment: vocabularyTransitionRecordEnvironmentSchema,
|
|
1699
|
+
kind: z.literal('vocabulary-transition-record'),
|
|
1700
|
+
recordPath: z.string().refine(isSafeRootRelativeRecordPath),
|
|
1701
|
+
report: vocabularyTransitionRecordReportSchema,
|
|
1702
|
+
schemaVersion: z.literal(VOCABULARY_TRANSITION_RECORD_SCHEMA_VERSION),
|
|
1703
|
+
transition: z
|
|
1704
|
+
.object({
|
|
1705
|
+
from: z.string(),
|
|
1706
|
+
id: z.string(),
|
|
1707
|
+
to: z.string(),
|
|
1708
|
+
})
|
|
1709
|
+
.strict(),
|
|
1710
|
+
})
|
|
1711
|
+
.strict();
|
|
1712
|
+
|
|
1713
|
+
const transitionRecordSlug = (run: VocabularyRegradeRun): string =>
|
|
1714
|
+
`${run.plan.from}-to-${run.plan.to}`
|
|
1715
|
+
.toLowerCase()
|
|
1716
|
+
.replaceAll(/[^a-z0-9]+/g, '-')
|
|
1717
|
+
.replaceAll(/^-|-$/g, '');
|
|
1718
|
+
|
|
1719
|
+
const stableJson = (value: unknown): string =>
|
|
1720
|
+
JSON.stringify(value, (_key, nested) => {
|
|
1721
|
+
if (
|
|
1722
|
+
nested === null ||
|
|
1723
|
+
typeof nested !== 'object' ||
|
|
1724
|
+
Array.isArray(nested)
|
|
1725
|
+
) {
|
|
1726
|
+
return nested as unknown;
|
|
1727
|
+
}
|
|
1728
|
+
return Object.fromEntries(
|
|
1729
|
+
Object.entries(nested as Record<string, unknown>).toSorted(
|
|
1730
|
+
([left], [right]) => left.localeCompare(right)
|
|
1731
|
+
)
|
|
1732
|
+
);
|
|
1733
|
+
});
|
|
1734
|
+
|
|
1735
|
+
const shortHashForRun = (
|
|
1736
|
+
run: VocabularyRegradeRun,
|
|
1737
|
+
environment?: Partial<VocabularyTransitionRecordEnvironment>
|
|
1738
|
+
): string => {
|
|
1739
|
+
const explicitHash = environment?.graphHash ?? environment?.commitSha;
|
|
1740
|
+
if (explicitHash !== undefined && explicitHash.length > 0) {
|
|
1741
|
+
return explicitHash.slice(0, 7);
|
|
1742
|
+
}
|
|
1743
|
+
return createHash('sha256')
|
|
1744
|
+
.update(stableJson({ ledger: run.ledger, plan: run.plan }))
|
|
1745
|
+
.digest('hex')
|
|
1746
|
+
.slice(0, 7);
|
|
1747
|
+
};
|
|
1748
|
+
|
|
1749
|
+
export const vocabularyTransitionRecordPath = (params: {
|
|
1750
|
+
readonly environment?: Partial<VocabularyTransitionRecordEnvironment>;
|
|
1751
|
+
readonly root: string;
|
|
1752
|
+
readonly run: VocabularyRegradeRun;
|
|
1753
|
+
}): string =>
|
|
1754
|
+
join(
|
|
1755
|
+
'.trails',
|
|
1756
|
+
'regrade',
|
|
1757
|
+
'history',
|
|
1758
|
+
`${transitionRecordSlug(params.run)}-${shortHashForRun(params.run, params.environment)}.json`
|
|
1759
|
+
);
|
|
1760
|
+
|
|
1761
|
+
const reportWithoutRecord = (
|
|
1762
|
+
report: RegradeReport
|
|
1763
|
+
): Omit<RegradeReport, 'record'> => {
|
|
1764
|
+
const { record: _record, ...rest } = report;
|
|
1765
|
+
return rest;
|
|
1766
|
+
};
|
|
1767
|
+
|
|
1768
|
+
const transitionRecordPathForWrite = (params: {
|
|
1769
|
+
readonly environment: VocabularyTransitionRecordEnvironment;
|
|
1770
|
+
readonly recordPath?: string;
|
|
1771
|
+
readonly report: RegradeReport;
|
|
1772
|
+
readonly root: string;
|
|
1773
|
+
}): Result<string, ValidationError> => {
|
|
1774
|
+
const recordPath =
|
|
1775
|
+
params.recordPath ??
|
|
1776
|
+
vocabularyTransitionRecordPath({
|
|
1777
|
+
environment: params.environment,
|
|
1778
|
+
root: params.root,
|
|
1779
|
+
run: params.report.run as VocabularyRegradeRun,
|
|
1780
|
+
});
|
|
1781
|
+
const normalized = normalizeTransitionRecordPath(
|
|
1782
|
+
isAbsolute(recordPath) ? relative(params.root, recordPath) : recordPath
|
|
1783
|
+
);
|
|
1784
|
+
if (!isSafeRootRelativeRecordPath(normalized)) {
|
|
1785
|
+
return Result.err(
|
|
1786
|
+
new ValidationError(
|
|
1787
|
+
'Vocabulary transition record path must stay inside the regrade root.',
|
|
1788
|
+
{ context: { recordPath } }
|
|
1789
|
+
)
|
|
1790
|
+
);
|
|
1791
|
+
}
|
|
1792
|
+
return Result.ok(normalized);
|
|
1793
|
+
};
|
|
1794
|
+
|
|
1795
|
+
export const buildVocabularyTransitionRecord = (params: {
|
|
1796
|
+
readonly environment?: Partial<VocabularyTransitionRecordEnvironment>;
|
|
1797
|
+
readonly recordPath?: string;
|
|
1798
|
+
readonly report: RegradeReport;
|
|
1799
|
+
readonly root: string;
|
|
1800
|
+
}): Result<VocabularyTransitionRecord, ValidationError> => {
|
|
1801
|
+
if (params.report.run === undefined) {
|
|
1802
|
+
return Result.err(
|
|
1803
|
+
new ValidationError(
|
|
1804
|
+
'Vocabulary transition records require a vocabulary Regrade report.'
|
|
1805
|
+
)
|
|
1806
|
+
);
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1809
|
+
const environment: VocabularyTransitionRecordEnvironment = {
|
|
1810
|
+
...(params.environment?.commitSha === undefined
|
|
1811
|
+
? {}
|
|
1812
|
+
: { commitSha: params.environment.commitSha }),
|
|
1813
|
+
...(params.environment?.engineVersion === undefined
|
|
1814
|
+
? {}
|
|
1815
|
+
: { engineVersion: params.environment.engineVersion }),
|
|
1816
|
+
...(params.environment?.graphHash === undefined
|
|
1817
|
+
? {}
|
|
1818
|
+
: { graphHash: params.environment.graphHash }),
|
|
1819
|
+
root: params.root,
|
|
1820
|
+
};
|
|
1821
|
+
const recordPathResult = transitionRecordPathForWrite({
|
|
1822
|
+
environment,
|
|
1823
|
+
report: params.report,
|
|
1824
|
+
root: params.root,
|
|
1825
|
+
...(params.recordPath === undefined
|
|
1826
|
+
? {}
|
|
1827
|
+
: { recordPath: params.recordPath }),
|
|
1828
|
+
});
|
|
1829
|
+
if (recordPathResult.isErr()) {
|
|
1830
|
+
return recordPathResult;
|
|
1831
|
+
}
|
|
1832
|
+
const recordPath = recordPathResult.value;
|
|
1833
|
+
const record: VocabularyTransitionRecord = {
|
|
1834
|
+
environment,
|
|
1835
|
+
kind: 'vocabulary-transition-record',
|
|
1836
|
+
recordPath,
|
|
1837
|
+
report: reportWithoutRecord(params.report),
|
|
1838
|
+
schemaVersion: VOCABULARY_TRANSITION_RECORD_SCHEMA_VERSION,
|
|
1839
|
+
transition: {
|
|
1840
|
+
from: params.report.run.plan.from,
|
|
1841
|
+
id:
|
|
1842
|
+
params.report.run.plan.id ??
|
|
1843
|
+
`vocabulary:${params.report.run.plan.from}->${params.report.run.plan.to}`,
|
|
1844
|
+
to: params.report.run.plan.to,
|
|
1845
|
+
},
|
|
1846
|
+
};
|
|
1847
|
+
const parsed = vocabularyTransitionRecordSchema.safeParse(record);
|
|
1848
|
+
if (!parsed.success) {
|
|
1849
|
+
return Result.err(
|
|
1850
|
+
new ValidationError('Invalid vocabulary transition record.', {
|
|
1851
|
+
context: { issues: parsed.error.issues },
|
|
1852
|
+
})
|
|
1853
|
+
);
|
|
1854
|
+
}
|
|
1855
|
+
return Result.ok(parsed.data as VocabularyTransitionRecord);
|
|
1856
|
+
};
|
|
1857
|
+
|
|
1858
|
+
export const writeVocabularyTransitionRecord = (params: {
|
|
1859
|
+
readonly environment?: Partial<VocabularyTransitionRecordEnvironment>;
|
|
1860
|
+
readonly recordPath?: string;
|
|
1861
|
+
readonly report: RegradeReport;
|
|
1862
|
+
readonly root: string;
|
|
1863
|
+
readonly status: VocabularyTransitionRecordSummary['status'];
|
|
1864
|
+
}): Result<
|
|
1865
|
+
{
|
|
1866
|
+
readonly record: VocabularyTransitionRecord;
|
|
1867
|
+
readonly summary: VocabularyTransitionRecordSummary;
|
|
1868
|
+
},
|
|
1869
|
+
InternalError | ValidationError
|
|
1870
|
+
> => {
|
|
1871
|
+
const recordResult = buildVocabularyTransitionRecord(params);
|
|
1872
|
+
if (recordResult.isErr()) {
|
|
1873
|
+
return recordResult;
|
|
1874
|
+
}
|
|
1875
|
+
const record = recordResult.value;
|
|
1876
|
+
const absolutePath = isAbsolute(record.recordPath)
|
|
1877
|
+
? record.recordPath
|
|
1878
|
+
: join(params.root, record.recordPath);
|
|
1879
|
+
try {
|
|
1880
|
+
mkdirSync(dirname(absolutePath), { recursive: true });
|
|
1881
|
+
writeFileSync(absolutePath, `${JSON.stringify(record, null, 2)}\n`);
|
|
1882
|
+
} catch (error) {
|
|
1883
|
+
return Result.err(
|
|
1884
|
+
new InternalError('Failed to write vocabulary transition record.', {
|
|
1885
|
+
...(error instanceof Error ? { cause: error } : {}),
|
|
1886
|
+
context: { path: record.recordPath },
|
|
1887
|
+
})
|
|
1888
|
+
);
|
|
1889
|
+
}
|
|
1890
|
+
return Result.ok({
|
|
1891
|
+
record,
|
|
1892
|
+
summary: {
|
|
1893
|
+
path: record.recordPath,
|
|
1894
|
+
schemaVersion: record.schemaVersion,
|
|
1895
|
+
status: params.status,
|
|
1896
|
+
},
|
|
1897
|
+
});
|
|
1898
|
+
};
|
|
1899
|
+
|
|
1900
|
+
export const readVocabularyTransitionRecord = (
|
|
1901
|
+
path: string
|
|
1902
|
+
): Result<VocabularyTransitionRecord, InternalError | ValidationError> => {
|
|
1903
|
+
if (!existsSync(path)) {
|
|
1904
|
+
return Result.err(
|
|
1905
|
+
new ValidationError(`Vocabulary transition record "${path}" not found.`)
|
|
1906
|
+
);
|
|
1907
|
+
}
|
|
1908
|
+
let parsedJson: unknown;
|
|
1909
|
+
try {
|
|
1910
|
+
parsedJson = JSON.parse(readFileSync(path, 'utf8'));
|
|
1911
|
+
} catch (error) {
|
|
1912
|
+
return Result.err(
|
|
1913
|
+
new InternalError('Failed to read vocabulary transition record.', {
|
|
1914
|
+
...(error instanceof Error ? { cause: error } : {}),
|
|
1915
|
+
context: { path },
|
|
1916
|
+
})
|
|
1917
|
+
);
|
|
1918
|
+
}
|
|
1919
|
+
const parsed = vocabularyTransitionRecordSchema.safeParse(parsedJson);
|
|
1920
|
+
if (!parsed.success) {
|
|
1921
|
+
return Result.err(
|
|
1922
|
+
new ValidationError('Invalid vocabulary transition record.', {
|
|
1923
|
+
context: { issues: parsed.error.issues, path },
|
|
1924
|
+
})
|
|
1925
|
+
);
|
|
1926
|
+
}
|
|
1927
|
+
return Result.ok(parsed.data as VocabularyTransitionRecord);
|
|
1928
|
+
};
|
|
1929
|
+
|
|
1930
|
+
export const transitionRecordReportWithSummary = (
|
|
1931
|
+
report: RegradeReport,
|
|
1932
|
+
summary: VocabularyTransitionRecordSummary
|
|
1933
|
+
): RegradeReport => ({ ...report, record: summary });
|