@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,3094 @@
|
|
|
1
|
+
import {
|
|
2
|
+
InternalError,
|
|
3
|
+
Result,
|
|
4
|
+
ValidationError,
|
|
5
|
+
escapeRegExp,
|
|
6
|
+
isPlainObject,
|
|
7
|
+
matchesAnyPathGlob,
|
|
8
|
+
} from '@ontrails/core';
|
|
9
|
+
import { parseWithDiagnostics } from '@ontrails/source';
|
|
10
|
+
import type { SourceComment } from '@ontrails/source';
|
|
11
|
+
import { createHash } from 'node:crypto';
|
|
12
|
+
import {
|
|
13
|
+
dirname,
|
|
14
|
+
extname,
|
|
15
|
+
isAbsolute,
|
|
16
|
+
join,
|
|
17
|
+
normalize,
|
|
18
|
+
relative,
|
|
19
|
+
} from 'node:path';
|
|
20
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
21
|
+
import { z } from 'zod';
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
DEFAULT_IGNORED_DIRECTORIES,
|
|
25
|
+
collectDownstreamSources,
|
|
26
|
+
} from './collect.js';
|
|
27
|
+
import type { DownstreamCollectionOptions, SkippedSource } from './collect.js';
|
|
28
|
+
import type {
|
|
29
|
+
PreparedRegradeRunIdentity,
|
|
30
|
+
RegradeApplySummary,
|
|
31
|
+
RegradeReport,
|
|
32
|
+
RegradeReportEntry,
|
|
33
|
+
} from './report.js';
|
|
34
|
+
import { buildRegradeScanSummary } from './scan-summary.js';
|
|
35
|
+
|
|
36
|
+
export type VocabularyVerdict = 'applied' | 'deferred' | 'modified' | 'skipped';
|
|
37
|
+
|
|
38
|
+
export type VocabularyOccurrenceSourceKind = 'source-comment' | 'tsdoc';
|
|
39
|
+
|
|
40
|
+
export const vocabularyDispositionValues = [
|
|
41
|
+
'code-context-out-of-engine',
|
|
42
|
+
'docs-only',
|
|
43
|
+
'explicit-preserve',
|
|
44
|
+
'forward-pointer',
|
|
45
|
+
'historical-by-policy',
|
|
46
|
+
'ignored-by-scope',
|
|
47
|
+
'in-family-modified',
|
|
48
|
+
'in-family-unresolved',
|
|
49
|
+
'out-of-family',
|
|
50
|
+
'preserve-current-live-api',
|
|
51
|
+
] as const;
|
|
52
|
+
|
|
53
|
+
export type VocabularyDisposition =
|
|
54
|
+
(typeof vocabularyDispositionValues)[number];
|
|
55
|
+
|
|
56
|
+
const vocabularyDispositions = new Set<string>(vocabularyDispositionValues);
|
|
57
|
+
|
|
58
|
+
export interface VocabularyPreserveRule {
|
|
59
|
+
readonly disposition?: VocabularyDisposition;
|
|
60
|
+
readonly forms?: readonly string[];
|
|
61
|
+
readonly pattern: string;
|
|
62
|
+
readonly reason?: string;
|
|
63
|
+
readonly paths?: readonly string[];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface VocabularyPreserveInventoryEntry extends VocabularyPreserveRule {
|
|
67
|
+
readonly evidence: readonly string[];
|
|
68
|
+
readonly source: 'derived-live-api';
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface VocabularyScopePolicy {
|
|
72
|
+
readonly disposition: VocabularyDisposition;
|
|
73
|
+
readonly expectMatches?: boolean | undefined;
|
|
74
|
+
readonly paths: readonly string[];
|
|
75
|
+
readonly reason: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export type VocabularyScopeTier = 'in-scope' | 'policy-classified';
|
|
79
|
+
|
|
80
|
+
export interface VocabularyRegradeScope {
|
|
81
|
+
readonly exclude?: readonly string[];
|
|
82
|
+
readonly extensions?: readonly string[];
|
|
83
|
+
/**
|
|
84
|
+
* @deprecated Use `exclude` path globs for new plans. This remains as a
|
|
85
|
+
* compatibility bridge for pre-path-scope plans that intentionally disabled
|
|
86
|
+
* the collector's default directory pruning.
|
|
87
|
+
*/
|
|
88
|
+
readonly ignoredDirectories?: readonly string[];
|
|
89
|
+
readonly include?: readonly string[];
|
|
90
|
+
readonly policyClassified?: readonly VocabularyScopePolicy[];
|
|
91
|
+
readonly teachingSurfaces?: readonly string[];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* One authored root-relative file move in a vocabulary plan.
|
|
96
|
+
*
|
|
97
|
+
* @example
|
|
98
|
+
* ```ts
|
|
99
|
+
* const rename: VocabularyFileRename = {
|
|
100
|
+
* from: 'docs/old.md',
|
|
101
|
+
* to: 'docs/new.md',
|
|
102
|
+
* };
|
|
103
|
+
* ```
|
|
104
|
+
*/
|
|
105
|
+
export interface VocabularyFileRename {
|
|
106
|
+
readonly from: string;
|
|
107
|
+
readonly to: string;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Derived reference-closure totals for one governed file move.
|
|
112
|
+
*
|
|
113
|
+
* @example
|
|
114
|
+
* ```ts
|
|
115
|
+
* console.log(evidence.rewritten, evidence.historical);
|
|
116
|
+
* ```
|
|
117
|
+
*/
|
|
118
|
+
export interface VocabularyFileRenameEvidence extends VocabularyFileRename {
|
|
119
|
+
readonly deferred: number;
|
|
120
|
+
readonly historical: number;
|
|
121
|
+
readonly preserved: number;
|
|
122
|
+
readonly rewritten: number;
|
|
123
|
+
readonly skipped: number;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* One deterministic form proposal synthesized from a vocabulary seed.
|
|
128
|
+
*
|
|
129
|
+
* @example
|
|
130
|
+
* ```ts
|
|
131
|
+
* const proposal: VocabularyFormProposal = {
|
|
132
|
+
* from: 'legacies',
|
|
133
|
+
* kind: 'safe-rewrite',
|
|
134
|
+
* reason: 'default-morphology',
|
|
135
|
+
* source: 'default-morphology',
|
|
136
|
+
* to: 'currents',
|
|
137
|
+
* };
|
|
138
|
+
* ```
|
|
139
|
+
*/
|
|
140
|
+
export interface VocabularyFormProposal {
|
|
141
|
+
readonly from: string;
|
|
142
|
+
readonly kind: 'review' | 'safe-rewrite';
|
|
143
|
+
readonly reason: string;
|
|
144
|
+
readonly source:
|
|
145
|
+
| 'default-morphology'
|
|
146
|
+
| 'plan-defer'
|
|
147
|
+
| 'plan-override'
|
|
148
|
+
| 'seed';
|
|
149
|
+
readonly to?: string;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export interface VocabularyRegradePlan {
|
|
153
|
+
readonly caseSensitive?: boolean;
|
|
154
|
+
readonly deferForms?: readonly string[];
|
|
155
|
+
readonly fileRenames?: readonly VocabularyFileRename[];
|
|
156
|
+
readonly from: string;
|
|
157
|
+
readonly id?: string;
|
|
158
|
+
readonly intent?: string;
|
|
159
|
+
readonly kind: 'vocabulary';
|
|
160
|
+
readonly overrides?: Readonly<Record<string, string>>;
|
|
161
|
+
readonly preserve?: readonly VocabularyPreserveRule[];
|
|
162
|
+
readonly scope?: VocabularyRegradeScope;
|
|
163
|
+
readonly to: string;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export interface VocabularyOccurrence {
|
|
167
|
+
readonly column: number;
|
|
168
|
+
readonly context: string;
|
|
169
|
+
readonly disposition: VocabularyDisposition;
|
|
170
|
+
readonly end: number;
|
|
171
|
+
readonly form: string;
|
|
172
|
+
readonly line: number;
|
|
173
|
+
readonly path: string;
|
|
174
|
+
readonly reason: string;
|
|
175
|
+
readonly replacement?: string;
|
|
176
|
+
readonly start: number;
|
|
177
|
+
readonly scopeTier: VocabularyScopeTier;
|
|
178
|
+
readonly sourceKind?: VocabularyOccurrenceSourceKind;
|
|
179
|
+
readonly verdict: VocabularyVerdict;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export interface VocabularyRunLedger {
|
|
183
|
+
readonly cycle: number;
|
|
184
|
+
readonly forms: Readonly<Record<string, VocabularyVerdict>>;
|
|
185
|
+
readonly occurrences: readonly VocabularyOccurrence[];
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export interface VocabularyRunGate {
|
|
189
|
+
readonly remaining: number;
|
|
190
|
+
readonly remainingByDisposition: Partial<
|
|
191
|
+
Readonly<Record<VocabularyDisposition, number>>
|
|
192
|
+
>;
|
|
193
|
+
readonly reasons: readonly string[];
|
|
194
|
+
readonly status: 'green' | 'open';
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export interface VocabularyRunReport {
|
|
198
|
+
readonly applied: number;
|
|
199
|
+
readonly deferred: number;
|
|
200
|
+
readonly dispositions: Partial<
|
|
201
|
+
Readonly<Record<VocabularyDisposition, number>>
|
|
202
|
+
>;
|
|
203
|
+
readonly filesChanged: number;
|
|
204
|
+
readonly fileRenames?: readonly VocabularyFileRenameEvidence[];
|
|
205
|
+
readonly gate: VocabularyRunGate;
|
|
206
|
+
readonly modified: number;
|
|
207
|
+
readonly open: number;
|
|
208
|
+
readonly skipped: number;
|
|
209
|
+
readonly scopeTiers: Readonly<Record<VocabularyScopeTier, number>>;
|
|
210
|
+
readonly teachingSurfaces: {
|
|
211
|
+
readonly expected: readonly string[];
|
|
212
|
+
readonly missing: readonly string[];
|
|
213
|
+
readonly touched: readonly string[];
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export interface VocabularyRegradeRun {
|
|
218
|
+
readonly ledger: VocabularyRunLedger;
|
|
219
|
+
readonly plan: VocabularyRegradePlan;
|
|
220
|
+
readonly preserveInventory?: readonly VocabularyPreserveInventoryEntry[];
|
|
221
|
+
readonly report: VocabularyRunReport;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export const VOCABULARY_TRANSITION_RECORD_SCHEMA_VERSION = 1;
|
|
225
|
+
|
|
226
|
+
export interface VocabularyTransitionRecordEnvironment {
|
|
227
|
+
readonly commitSha?: string;
|
|
228
|
+
readonly engineVersion?: string;
|
|
229
|
+
readonly graphHash?: string;
|
|
230
|
+
readonly root: string;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export interface VocabularyTransitionRecord {
|
|
234
|
+
readonly environment: VocabularyTransitionRecordEnvironment;
|
|
235
|
+
readonly kind: 'vocabulary-transition-record';
|
|
236
|
+
readonly recordPath: string;
|
|
237
|
+
readonly report: Omit<RegradeReport, 'record'>;
|
|
238
|
+
readonly schemaVersion: typeof VOCABULARY_TRANSITION_RECORD_SCHEMA_VERSION;
|
|
239
|
+
readonly transition: {
|
|
240
|
+
readonly from: string;
|
|
241
|
+
readonly id: string;
|
|
242
|
+
readonly to: string;
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export interface VocabularyTransitionRecordSummary {
|
|
247
|
+
readonly path: string;
|
|
248
|
+
readonly schemaVersion: typeof VOCABULARY_TRANSITION_RECORD_SCHEMA_VERSION;
|
|
249
|
+
readonly status: 'candidate' | 'applied' | 'checked';
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
interface SourceFile {
|
|
253
|
+
readonly absolutePath: string;
|
|
254
|
+
readonly path: string;
|
|
255
|
+
readonly source: string;
|
|
256
|
+
readonly sourceBytes: string;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
interface SourceOccurrence extends VocabularyOccurrence {
|
|
260
|
+
readonly absolutePath: string;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
type VocabularySourceKind = 'all' | 'comments';
|
|
264
|
+
|
|
265
|
+
const sourceCommentKind = (
|
|
266
|
+
file: SourceFile,
|
|
267
|
+
comment: SourceComment
|
|
268
|
+
): VocabularyOccurrenceSourceKind =>
|
|
269
|
+
comment.type === 'Block' && file.source.startsWith('/**', comment.start)
|
|
270
|
+
? 'tsdoc'
|
|
271
|
+
: 'source-comment';
|
|
272
|
+
|
|
273
|
+
interface SourceOccurrenceDraft extends Omit<
|
|
274
|
+
SourceOccurrence,
|
|
275
|
+
'disposition' | 'reason' | 'scopeTier' | 'verdict'
|
|
276
|
+
> {
|
|
277
|
+
readonly contextColumn: number;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
interface VocabularyEvaluation {
|
|
281
|
+
readonly entries: readonly RegradeReportEntry[];
|
|
282
|
+
readonly occurrences: readonly SourceOccurrence[];
|
|
283
|
+
readonly scanned: number;
|
|
284
|
+
readonly skipped: readonly SkippedSource[];
|
|
285
|
+
readonly run: VocabularyRegradeRun;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const VOCABULARY_SOURCE_EXTENSIONS = Object.freeze([
|
|
289
|
+
'.js',
|
|
290
|
+
'.jsx',
|
|
291
|
+
'.json',
|
|
292
|
+
'.jsonc',
|
|
293
|
+
'.md',
|
|
294
|
+
'.mdx',
|
|
295
|
+
'.mjs',
|
|
296
|
+
'.ts',
|
|
297
|
+
'.tsx',
|
|
298
|
+
'.txt',
|
|
299
|
+
'.yaml',
|
|
300
|
+
'.yml',
|
|
301
|
+
]);
|
|
302
|
+
|
|
303
|
+
const uniqueSorted = (values: readonly string[]): readonly string[] =>
|
|
304
|
+
[...new Set(values)].toSorted((a, b) => a.localeCompare(b));
|
|
305
|
+
|
|
306
|
+
const vocabularyDispositionCounts = (
|
|
307
|
+
occurrences: readonly VocabularyOccurrence[]
|
|
308
|
+
): Partial<Readonly<Record<VocabularyDisposition, number>>> => {
|
|
309
|
+
const counts = new Map<VocabularyDisposition, number>();
|
|
310
|
+
for (const occurrence of occurrences) {
|
|
311
|
+
counts.set(
|
|
312
|
+
occurrence.disposition,
|
|
313
|
+
(counts.get(occurrence.disposition) ?? 0) + 1
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
return Object.fromEntries(
|
|
317
|
+
[...counts.entries()].toSorted(([left], [right]) =>
|
|
318
|
+
left.localeCompare(right)
|
|
319
|
+
)
|
|
320
|
+
);
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
const vocabularyFormVerdicts = (
|
|
324
|
+
occurrences: readonly VocabularyOccurrence[]
|
|
325
|
+
): Readonly<Record<string, VocabularyVerdict>> => {
|
|
326
|
+
const priority: Readonly<Record<VocabularyVerdict, number>> = {
|
|
327
|
+
applied: 1,
|
|
328
|
+
deferred: 3,
|
|
329
|
+
modified: 2,
|
|
330
|
+
skipped: 0,
|
|
331
|
+
};
|
|
332
|
+
const forms: Record<string, VocabularyVerdict> = {};
|
|
333
|
+
for (const occurrence of occurrences) {
|
|
334
|
+
if (occurrence.verdict === 'applied') {
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
const current = forms[occurrence.form];
|
|
338
|
+
if (
|
|
339
|
+
current === undefined ||
|
|
340
|
+
priority[occurrence.verdict] > priority[current]
|
|
341
|
+
) {
|
|
342
|
+
forms[occurrence.form] = occurrence.verdict;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
return forms;
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
const vocabularyScopeTierCounts = (
|
|
349
|
+
occurrences: readonly VocabularyOccurrence[]
|
|
350
|
+
): Readonly<Record<VocabularyScopeTier, number>> => ({
|
|
351
|
+
'in-scope': occurrences.filter(
|
|
352
|
+
(occurrence) => occurrence.scopeTier === 'in-scope'
|
|
353
|
+
).length,
|
|
354
|
+
'policy-classified': occurrences.filter(
|
|
355
|
+
(occurrence) => occurrence.scopeTier === 'policy-classified'
|
|
356
|
+
).length,
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
const vocabularyScopeEvidence = (
|
|
360
|
+
plan: VocabularyRegradePlan,
|
|
361
|
+
occurrences: readonly VocabularyOccurrence[]
|
|
362
|
+
): {
|
|
363
|
+
readonly gateReasons: readonly string[];
|
|
364
|
+
readonly scopeTiers: Readonly<Record<VocabularyScopeTier, number>>;
|
|
365
|
+
readonly teachingSurfaces: VocabularyRunReport['teachingSurfaces'];
|
|
366
|
+
} => {
|
|
367
|
+
const expected = uniqueSorted(plan.scope?.teachingSurfaces ?? []);
|
|
368
|
+
const touched = expected.filter((pattern) =>
|
|
369
|
+
occurrences.some(
|
|
370
|
+
(occurrence) =>
|
|
371
|
+
occurrence.scopeTier === 'in-scope' &&
|
|
372
|
+
matchesAnyPathGlob(occurrence.path, [pattern])
|
|
373
|
+
)
|
|
374
|
+
);
|
|
375
|
+
const missing = expected.filter((pattern) => !touched.includes(pattern));
|
|
376
|
+
const expectedPolicyMissing =
|
|
377
|
+
plan.scope?.policyClassified?.some(
|
|
378
|
+
(policy) =>
|
|
379
|
+
policy.expectMatches === true &&
|
|
380
|
+
!occurrences.some(
|
|
381
|
+
(occurrence) =>
|
|
382
|
+
occurrence.scopeTier === 'policy-classified' &&
|
|
383
|
+
matchesAnyPathGlob(occurrence.path, policy.paths)
|
|
384
|
+
)
|
|
385
|
+
) ?? false;
|
|
386
|
+
return {
|
|
387
|
+
gateReasons: [
|
|
388
|
+
...(expectedPolicyMissing
|
|
389
|
+
? ['expected-policy-classified-evidence-missing']
|
|
390
|
+
: []),
|
|
391
|
+
...(missing.length === 0 ? [] : ['expected-teaching-surfaces-missing']),
|
|
392
|
+
],
|
|
393
|
+
scopeTiers: vocabularyScopeTierCounts(occurrences),
|
|
394
|
+
teachingSurfaces: { expected, missing, touched },
|
|
395
|
+
};
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
const isVocabularyTokenCharacter = (
|
|
399
|
+
value: string,
|
|
400
|
+
routeLike: boolean
|
|
401
|
+
): boolean => /[A-Za-z0-9_$-]/.test(value) || (routeLike && value === '/');
|
|
402
|
+
|
|
403
|
+
const isVocabularyTokenCharacterAt = (
|
|
404
|
+
source: string,
|
|
405
|
+
index: number,
|
|
406
|
+
routeLike: boolean
|
|
407
|
+
): boolean => {
|
|
408
|
+
if (index < 0 || index >= source.length) {
|
|
409
|
+
return false;
|
|
410
|
+
}
|
|
411
|
+
const value = source.at(index) ?? '';
|
|
412
|
+
if (isVocabularyTokenCharacter(value, routeLike)) {
|
|
413
|
+
return true;
|
|
414
|
+
}
|
|
415
|
+
if (!routeLike || value !== '.') {
|
|
416
|
+
return false;
|
|
417
|
+
}
|
|
418
|
+
return (
|
|
419
|
+
isVocabularyTokenCharacter(source.at(index - 1) ?? '', false) &&
|
|
420
|
+
isVocabularyTokenCharacter(source.at(index + 1) ?? '', false)
|
|
421
|
+
);
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
const hasWordBoundary = (
|
|
425
|
+
source: string,
|
|
426
|
+
start: number,
|
|
427
|
+
end: number,
|
|
428
|
+
form: string
|
|
429
|
+
): boolean => {
|
|
430
|
+
const routeLike = form.includes('/');
|
|
431
|
+
return (
|
|
432
|
+
!isVocabularyTokenCharacterAt(source, start - 1, routeLike) &&
|
|
433
|
+
!isVocabularyTokenCharacterAt(source, end, routeLike)
|
|
434
|
+
);
|
|
435
|
+
};
|
|
436
|
+
|
|
437
|
+
const expandVocabularyNeighborSpan = (
|
|
438
|
+
source: string,
|
|
439
|
+
start: number,
|
|
440
|
+
end: number,
|
|
441
|
+
form: string
|
|
442
|
+
): { readonly end: number; readonly start: number } => {
|
|
443
|
+
const routeLike = form.includes('/');
|
|
444
|
+
let expandedStart = start;
|
|
445
|
+
while (
|
|
446
|
+
expandedStart > 0 &&
|
|
447
|
+
isVocabularyTokenCharacterAt(source, expandedStart - 1, routeLike)
|
|
448
|
+
) {
|
|
449
|
+
expandedStart -= 1;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
let expandedEnd = end;
|
|
453
|
+
while (
|
|
454
|
+
expandedEnd < source.length &&
|
|
455
|
+
isVocabularyTokenCharacterAt(source, expandedEnd, routeLike)
|
|
456
|
+
) {
|
|
457
|
+
expandedEnd += 1;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
return { end: expandedEnd, start: expandedStart };
|
|
461
|
+
};
|
|
462
|
+
|
|
463
|
+
const lineColumnForOffset = (
|
|
464
|
+
source: string,
|
|
465
|
+
offset: number
|
|
466
|
+
): { readonly column: number; readonly line: number } => {
|
|
467
|
+
let line = 1;
|
|
468
|
+
let column = 1;
|
|
469
|
+
for (let index = 0; index < offset; index += 1) {
|
|
470
|
+
const codePoint = source.codePointAt(index);
|
|
471
|
+
if (
|
|
472
|
+
codePoint === 10 ||
|
|
473
|
+
codePoint === 13 ||
|
|
474
|
+
codePoint === 0x20_28 ||
|
|
475
|
+
codePoint === 0x20_29
|
|
476
|
+
) {
|
|
477
|
+
if (codePoint === 13 && source.codePointAt(index + 1) === 10) {
|
|
478
|
+
index += 1;
|
|
479
|
+
}
|
|
480
|
+
line += 1;
|
|
481
|
+
column = 1;
|
|
482
|
+
} else {
|
|
483
|
+
column += 1;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
return { column, line };
|
|
487
|
+
};
|
|
488
|
+
|
|
489
|
+
const contextDetailsForOffset = (
|
|
490
|
+
source: string,
|
|
491
|
+
start: number,
|
|
492
|
+
end: number
|
|
493
|
+
): { readonly context: string; readonly contextColumn: number } => {
|
|
494
|
+
let lineStart = start;
|
|
495
|
+
while (lineStart > 0) {
|
|
496
|
+
const codePoint = source.codePointAt(lineStart - 1);
|
|
497
|
+
if (
|
|
498
|
+
codePoint === 10 ||
|
|
499
|
+
codePoint === 13 ||
|
|
500
|
+
codePoint === 0x20_28 ||
|
|
501
|
+
codePoint === 0x20_29
|
|
502
|
+
) {
|
|
503
|
+
break;
|
|
504
|
+
}
|
|
505
|
+
lineStart -= 1;
|
|
506
|
+
}
|
|
507
|
+
let lineEnd = end;
|
|
508
|
+
while (lineEnd < source.length) {
|
|
509
|
+
const codePoint = source.codePointAt(lineEnd);
|
|
510
|
+
if (
|
|
511
|
+
codePoint === 10 ||
|
|
512
|
+
codePoint === 13 ||
|
|
513
|
+
codePoint === 0x20_28 ||
|
|
514
|
+
codePoint === 0x20_29
|
|
515
|
+
) {
|
|
516
|
+
break;
|
|
517
|
+
}
|
|
518
|
+
lineEnd += 1;
|
|
519
|
+
}
|
|
520
|
+
const rawLine = source.slice(lineStart, lineEnd);
|
|
521
|
+
const leadingTrimmed = rawLine.length - rawLine.trimStart().length;
|
|
522
|
+
return {
|
|
523
|
+
context: rawLine.trim(),
|
|
524
|
+
contextColumn: start - lineStart - leadingTrimmed + 1,
|
|
525
|
+
};
|
|
526
|
+
};
|
|
527
|
+
|
|
528
|
+
const isMarkdownPath = (path: string): boolean =>
|
|
529
|
+
path.endsWith('.md') || path.endsWith('.mdx');
|
|
530
|
+
|
|
531
|
+
const packageRouteCodeExtensions = new Set([
|
|
532
|
+
'.cjs',
|
|
533
|
+
'.cts',
|
|
534
|
+
'.js',
|
|
535
|
+
'.jsx',
|
|
536
|
+
'.mjs',
|
|
537
|
+
'.mts',
|
|
538
|
+
'.ts',
|
|
539
|
+
'.tsx',
|
|
540
|
+
]);
|
|
541
|
+
|
|
542
|
+
const isPackageRouteCodeOccurrence = (path: string, form: string): boolean =>
|
|
543
|
+
packageRouteCodeExtensions.has(extname(path)) &&
|
|
544
|
+
/^@[^/]+\/[^/]+(?:\/.*)?$/.test(form);
|
|
545
|
+
|
|
546
|
+
const isPackageManifestPath = (path: string): boolean =>
|
|
547
|
+
path === 'package.json' || path.endsWith('/package.json');
|
|
548
|
+
|
|
549
|
+
const sourceLineBoundsForOffset = (
|
|
550
|
+
source: string,
|
|
551
|
+
start: number,
|
|
552
|
+
end: number
|
|
553
|
+
): { readonly lineEnd: number; readonly lineStart: number } => {
|
|
554
|
+
const lineStart = source.lastIndexOf('\n', start - 1) + 1;
|
|
555
|
+
const nextLine = source.indexOf('\n', end);
|
|
556
|
+
return { lineEnd: nextLine === -1 ? source.length : nextLine, lineStart };
|
|
557
|
+
};
|
|
558
|
+
|
|
559
|
+
const markdownBacktickRuns = (value: string): readonly RegExpMatchArray[] => [
|
|
560
|
+
...value.matchAll(/(?<!\\)`+/g),
|
|
561
|
+
];
|
|
562
|
+
|
|
563
|
+
const isMarkdownInlineCodeContext = (
|
|
564
|
+
source: string,
|
|
565
|
+
start: number,
|
|
566
|
+
end: number
|
|
567
|
+
): boolean => {
|
|
568
|
+
const { lineEnd, lineStart } = sourceLineBoundsForOffset(source, start, end);
|
|
569
|
+
const line = source.slice(lineStart, lineEnd);
|
|
570
|
+
const relativeStart = start - lineStart;
|
|
571
|
+
const relativeEnd = end - lineStart;
|
|
572
|
+
let openRun: { readonly length: number; readonly start: number } | undefined;
|
|
573
|
+
|
|
574
|
+
for (const run of markdownBacktickRuns(line)) {
|
|
575
|
+
const runStart = run.index ?? 0;
|
|
576
|
+
const [value] = run;
|
|
577
|
+
const runLength = value.length;
|
|
578
|
+
if (openRun === undefined) {
|
|
579
|
+
openRun = { length: runLength, start: runStart };
|
|
580
|
+
continue;
|
|
581
|
+
}
|
|
582
|
+
if (runLength !== openRun.length) {
|
|
583
|
+
continue;
|
|
584
|
+
}
|
|
585
|
+
if (
|
|
586
|
+
openRun.start + openRun.length <= relativeStart &&
|
|
587
|
+
relativeEnd <= runStart
|
|
588
|
+
) {
|
|
589
|
+
return true;
|
|
590
|
+
}
|
|
591
|
+
openRun = undefined;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
return false;
|
|
595
|
+
};
|
|
596
|
+
|
|
597
|
+
const markdownFenceLinePattern = /^\s*(?:>\s*){0,8}(```|~~~)/;
|
|
598
|
+
|
|
599
|
+
const isMarkdownFenceContext = (source: string, start: number): boolean => {
|
|
600
|
+
const before = source.slice(0, start);
|
|
601
|
+
let fenced = false;
|
|
602
|
+
for (const line of before.split('\n')) {
|
|
603
|
+
if (markdownFenceLinePattern.test(line)) {
|
|
604
|
+
fenced = !fenced;
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
return fenced;
|
|
608
|
+
};
|
|
609
|
+
|
|
610
|
+
const isMarkdownCodeContext = (
|
|
611
|
+
file: SourceFile,
|
|
612
|
+
start: number,
|
|
613
|
+
end: number
|
|
614
|
+
): boolean =>
|
|
615
|
+
isMarkdownPath(file.path) &&
|
|
616
|
+
(isMarkdownInlineCodeContext(file.source, start, end) ||
|
|
617
|
+
isMarkdownFenceContext(file.source, start));
|
|
618
|
+
|
|
619
|
+
const vocabularyOccurrenceReason = (
|
|
620
|
+
preserveRule: VocabularyPreserveRule | undefined,
|
|
621
|
+
markdownCodeContext: boolean,
|
|
622
|
+
defaultReason: string
|
|
623
|
+
): string => {
|
|
624
|
+
if (preserveRule !== undefined) {
|
|
625
|
+
return preserveRule.reason ?? 'preserved-by-plan';
|
|
626
|
+
}
|
|
627
|
+
if (markdownCodeContext) {
|
|
628
|
+
return 'markdown-code-context';
|
|
629
|
+
}
|
|
630
|
+
return defaultReason;
|
|
631
|
+
};
|
|
632
|
+
|
|
633
|
+
const capturedVocabularyVerdict = (
|
|
634
|
+
preserveRule: VocabularyPreserveRule | undefined,
|
|
635
|
+
markdownCodeContext: boolean
|
|
636
|
+
): VocabularyVerdict => {
|
|
637
|
+
if (preserveRule !== undefined) {
|
|
638
|
+
return 'skipped';
|
|
639
|
+
}
|
|
640
|
+
if (markdownCodeContext) {
|
|
641
|
+
return 'deferred';
|
|
642
|
+
}
|
|
643
|
+
return 'modified';
|
|
644
|
+
};
|
|
645
|
+
|
|
646
|
+
const vocabularyOccurrenceDisposition = (
|
|
647
|
+
verdict: VocabularyVerdict,
|
|
648
|
+
preserveRule: VocabularyPreserveRule | undefined,
|
|
649
|
+
markdownCodeContext: boolean
|
|
650
|
+
): VocabularyDisposition => {
|
|
651
|
+
if (preserveRule !== undefined) {
|
|
652
|
+
return preserveRule.disposition ?? 'explicit-preserve';
|
|
653
|
+
}
|
|
654
|
+
if (markdownCodeContext) {
|
|
655
|
+
return 'code-context-out-of-engine';
|
|
656
|
+
}
|
|
657
|
+
if (verdict === 'modified') {
|
|
658
|
+
return 'in-family-modified';
|
|
659
|
+
}
|
|
660
|
+
return 'in-family-unresolved';
|
|
661
|
+
};
|
|
662
|
+
|
|
663
|
+
const preserveCase = (sourceForm: string, replacement: string): string => {
|
|
664
|
+
if (sourceForm.toUpperCase() === sourceForm) {
|
|
665
|
+
return replacement.toUpperCase();
|
|
666
|
+
}
|
|
667
|
+
const first = sourceForm.at(0);
|
|
668
|
+
if (first !== undefined && first.toUpperCase() === first) {
|
|
669
|
+
return replacement.at(0)?.toUpperCase() + replacement.slice(1);
|
|
670
|
+
}
|
|
671
|
+
return replacement;
|
|
672
|
+
};
|
|
673
|
+
|
|
674
|
+
const isSimpleVocabularyWord = (value: string): boolean =>
|
|
675
|
+
/^[A-Za-z]+$/.test(value);
|
|
676
|
+
|
|
677
|
+
const endsWithConsonantY = (value: string): boolean => {
|
|
678
|
+
const penultimate = value.at(-2);
|
|
679
|
+
return (
|
|
680
|
+
value.endsWith('y') &&
|
|
681
|
+
penultimate !== undefined &&
|
|
682
|
+
!/[aeiou]/.test(penultimate)
|
|
683
|
+
);
|
|
684
|
+
};
|
|
685
|
+
|
|
686
|
+
const pluralize = (value: string): string => {
|
|
687
|
+
const lower = value.toLowerCase();
|
|
688
|
+
let lowerForm: string;
|
|
689
|
+
if (endsWithConsonantY(lower)) {
|
|
690
|
+
lowerForm = `${lower.slice(0, -1)}ies`;
|
|
691
|
+
} else if (
|
|
692
|
+
lower.endsWith('s') ||
|
|
693
|
+
lower.endsWith('x') ||
|
|
694
|
+
lower.endsWith('ch')
|
|
695
|
+
) {
|
|
696
|
+
lowerForm = `${lower}es`;
|
|
697
|
+
} else {
|
|
698
|
+
lowerForm = `${lower}s`;
|
|
699
|
+
}
|
|
700
|
+
return preserveCase(value, lowerForm);
|
|
701
|
+
};
|
|
702
|
+
|
|
703
|
+
const pastTenseForm = (value: string): string => {
|
|
704
|
+
const lower = value.toLowerCase();
|
|
705
|
+
let lowerForm: string;
|
|
706
|
+
if (endsWithConsonantY(lower)) {
|
|
707
|
+
lowerForm = `${lower.slice(0, -1)}ied`;
|
|
708
|
+
} else if (lower.endsWith('e')) {
|
|
709
|
+
lowerForm = `${lower}d`;
|
|
710
|
+
} else {
|
|
711
|
+
lowerForm = `${lower}ed`;
|
|
712
|
+
}
|
|
713
|
+
return preserveCase(value, lowerForm);
|
|
714
|
+
};
|
|
715
|
+
|
|
716
|
+
const presentParticipleForm = (value: string): string => {
|
|
717
|
+
const lower = value.toLowerCase();
|
|
718
|
+
let lowerForm: string;
|
|
719
|
+
if (lower.endsWith('ie')) {
|
|
720
|
+
lowerForm = `${lower.slice(0, -2)}ying`;
|
|
721
|
+
} else if (lower.endsWith('e') && !lower.endsWith('ee')) {
|
|
722
|
+
lowerForm = `${lower.slice(0, -1)}ing`;
|
|
723
|
+
} else {
|
|
724
|
+
lowerForm = `${lower}ing`;
|
|
725
|
+
}
|
|
726
|
+
return preserveCase(value, lowerForm);
|
|
727
|
+
};
|
|
728
|
+
|
|
729
|
+
const defaultDeferredVocabularyForms = (from: string): readonly string[] => {
|
|
730
|
+
if (!isSimpleVocabularyWord(from)) {
|
|
731
|
+
return [];
|
|
732
|
+
}
|
|
733
|
+
return uniqueSorted([
|
|
734
|
+
pastTenseForm(from),
|
|
735
|
+
presentParticipleForm(from),
|
|
736
|
+
]).filter((form) => form !== from && form !== pluralize(from));
|
|
737
|
+
};
|
|
738
|
+
|
|
739
|
+
const defaultVocabularyForms = (from: string, to: string) => {
|
|
740
|
+
const forms = new Map<string, string>([[from, to]]);
|
|
741
|
+
if (isSimpleVocabularyWord(from) && isSimpleVocabularyWord(to)) {
|
|
742
|
+
forms.set(pluralize(from), pluralize(to));
|
|
743
|
+
}
|
|
744
|
+
return forms;
|
|
745
|
+
};
|
|
746
|
+
|
|
747
|
+
const normalizedOverrideEntries = (
|
|
748
|
+
overrides: Readonly<Record<string, string>> | undefined
|
|
749
|
+
): readonly [string, string][] =>
|
|
750
|
+
Object.entries(overrides ?? {}).toSorted(([left], [right]) =>
|
|
751
|
+
left.localeCompare(right)
|
|
752
|
+
);
|
|
753
|
+
|
|
754
|
+
const formIdentityForPlan = (
|
|
755
|
+
plan: VocabularyRegradePlan,
|
|
756
|
+
form: string
|
|
757
|
+
): string => (plan.caseSensitive === true ? form : form.toLowerCase());
|
|
758
|
+
|
|
759
|
+
const targetFormsForPlan = (
|
|
760
|
+
plan: VocabularyRegradePlan
|
|
761
|
+
): Map<string, string> => {
|
|
762
|
+
const forms = defaultVocabularyForms(plan.from, plan.to);
|
|
763
|
+
for (const [form, replacement] of normalizedOverrideEntries(plan.overrides)) {
|
|
764
|
+
forms.set(form, replacement);
|
|
765
|
+
}
|
|
766
|
+
return forms;
|
|
767
|
+
};
|
|
768
|
+
|
|
769
|
+
export const vocabularyRewriteFormsForPlan = (
|
|
770
|
+
plan: VocabularyRegradePlan
|
|
771
|
+
): readonly (readonly [string, string])[] => [...targetFormsForPlan(plan)];
|
|
772
|
+
|
|
773
|
+
const deferFormsForPlan = (plan: VocabularyRegradePlan): readonly string[] => {
|
|
774
|
+
const overrideForms = new Set(
|
|
775
|
+
normalizedOverrideEntries(plan.overrides).map(([form]) =>
|
|
776
|
+
formIdentityForPlan(plan, form)
|
|
777
|
+
)
|
|
778
|
+
);
|
|
779
|
+
return uniqueSorted([
|
|
780
|
+
...defaultDeferredVocabularyForms(plan.from).filter(
|
|
781
|
+
(form) => !overrideForms.has(formIdentityForPlan(plan, form))
|
|
782
|
+
),
|
|
783
|
+
...(plan.deferForms ?? []),
|
|
784
|
+
]);
|
|
785
|
+
};
|
|
786
|
+
|
|
787
|
+
const titleCaseVocabularyForm = (form: string): string => {
|
|
788
|
+
const first = form.at(0);
|
|
789
|
+
return first === undefined ? form : `${first.toUpperCase()}${form.slice(1)}`;
|
|
790
|
+
};
|
|
791
|
+
|
|
792
|
+
const commentReviewPlan = (
|
|
793
|
+
plan: VocabularyRegradePlan
|
|
794
|
+
): VocabularyRegradePlan => ({
|
|
795
|
+
...plan,
|
|
796
|
+
deferForms: uniqueSorted([
|
|
797
|
+
...(plan.deferForms ?? []),
|
|
798
|
+
...(plan.deferForms ?? []).map(titleCaseVocabularyForm),
|
|
799
|
+
]),
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
/**
|
|
803
|
+
* Synthesize the deterministic form proposal carried by a minimal plan seed.
|
|
804
|
+
*
|
|
805
|
+
* @example
|
|
806
|
+
* ```ts
|
|
807
|
+
* const forms = deriveVocabularyFormProposals({
|
|
808
|
+
* from: 'legacy',
|
|
809
|
+
* kind: 'vocabulary',
|
|
810
|
+
* to: 'current',
|
|
811
|
+
* });
|
|
812
|
+
* ```
|
|
813
|
+
*/
|
|
814
|
+
export const deriveVocabularyFormProposals = (
|
|
815
|
+
plan: VocabularyRegradePlan
|
|
816
|
+
): readonly VocabularyFormProposal[] => {
|
|
817
|
+
const overrideForms = new Set(Object.keys(plan.overrides ?? {}));
|
|
818
|
+
const explicitDefers = new Set(
|
|
819
|
+
(plan.deferForms ?? []).map((form) => formIdentityForPlan(plan, form))
|
|
820
|
+
);
|
|
821
|
+
const safeProposals = [...targetFormsForPlan(plan).entries()].flatMap(
|
|
822
|
+
([from, to]): readonly VocabularyFormProposal[] => {
|
|
823
|
+
if (explicitDefers.has(formIdentityForPlan(plan, from))) {
|
|
824
|
+
return [];
|
|
825
|
+
}
|
|
826
|
+
if (from === plan.from) {
|
|
827
|
+
return [
|
|
828
|
+
{
|
|
829
|
+
from,
|
|
830
|
+
kind: 'safe-rewrite',
|
|
831
|
+
reason: 'minimal-seed',
|
|
832
|
+
source: 'seed',
|
|
833
|
+
to,
|
|
834
|
+
},
|
|
835
|
+
];
|
|
836
|
+
}
|
|
837
|
+
if (overrideForms.has(from)) {
|
|
838
|
+
return [
|
|
839
|
+
{
|
|
840
|
+
from,
|
|
841
|
+
kind: 'safe-rewrite',
|
|
842
|
+
reason: 'authored-or-governed-override',
|
|
843
|
+
source: 'plan-override',
|
|
844
|
+
to,
|
|
845
|
+
},
|
|
846
|
+
];
|
|
847
|
+
}
|
|
848
|
+
return [
|
|
849
|
+
{
|
|
850
|
+
from,
|
|
851
|
+
kind: 'safe-rewrite',
|
|
852
|
+
reason: 'default-morphology',
|
|
853
|
+
source: 'default-morphology',
|
|
854
|
+
to,
|
|
855
|
+
},
|
|
856
|
+
];
|
|
857
|
+
}
|
|
858
|
+
);
|
|
859
|
+
const casingProposals: VocabularyFormProposal[] = [];
|
|
860
|
+
if (isSimpleVocabularyWord(plan.from) && isSimpleVocabularyWord(plan.to)) {
|
|
861
|
+
const from = `${plan.from.slice(0, 1).toUpperCase()}${plan.from.slice(1)}`;
|
|
862
|
+
const to = `${plan.to.slice(0, 1).toUpperCase()}${plan.to.slice(1)}`;
|
|
863
|
+
if (from !== plan.from && !explicitDefers.has(from)) {
|
|
864
|
+
casingProposals.push({
|
|
865
|
+
from,
|
|
866
|
+
kind: 'review',
|
|
867
|
+
reason: 'uncertain-casing-or-public-name',
|
|
868
|
+
source: 'default-morphology',
|
|
869
|
+
to,
|
|
870
|
+
});
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
return [
|
|
874
|
+
...safeProposals,
|
|
875
|
+
...casingProposals,
|
|
876
|
+
...deferFormsForPlan(plan).map((from) => ({
|
|
877
|
+
from,
|
|
878
|
+
kind: 'review' as const,
|
|
879
|
+
reason: explicitDefers.has(from)
|
|
880
|
+
? 'authored-or-governed-defer'
|
|
881
|
+
: 'uncertain-morphology',
|
|
882
|
+
source: explicitDefers.has(from)
|
|
883
|
+
? ('plan-defer' as const)
|
|
884
|
+
: ('default-morphology' as const),
|
|
885
|
+
})),
|
|
886
|
+
].toSorted((left, right) =>
|
|
887
|
+
left.from === right.from
|
|
888
|
+
? left.kind.localeCompare(right.kind)
|
|
889
|
+
: left.from.localeCompare(right.from)
|
|
890
|
+
);
|
|
891
|
+
};
|
|
892
|
+
|
|
893
|
+
const validateVocabularyScope = (
|
|
894
|
+
scope: VocabularyRegradeScope | undefined
|
|
895
|
+
): Result<void, ValidationError> => {
|
|
896
|
+
const excludedDocsPattern = scope?.exclude?.find(
|
|
897
|
+
(pattern) =>
|
|
898
|
+
/(^|\/)docs(?:\/|$)/.test(pattern) ||
|
|
899
|
+
matchesAnyPathGlob('docs/regrade-teaching.md', [pattern]) ||
|
|
900
|
+
matchesAnyPathGlob('docs/regrade-teaching.mdx', [pattern])
|
|
901
|
+
);
|
|
902
|
+
if (excludedDocsPattern !== undefined) {
|
|
903
|
+
return Result.err(
|
|
904
|
+
new ValidationError(
|
|
905
|
+
`Vocabulary Regrade plans cannot hard-exclude docs with "${excludedDocsPattern}"; use a policyClassified rule with a reason.`
|
|
906
|
+
)
|
|
907
|
+
);
|
|
908
|
+
}
|
|
909
|
+
for (const policy of scope?.policyClassified ?? []) {
|
|
910
|
+
if (policy.paths.length === 0 || policy.reason.trim().length === 0) {
|
|
911
|
+
return Result.err(
|
|
912
|
+
new ValidationError(
|
|
913
|
+
'Vocabulary Regrade policyClassified rules require paths and a reason.'
|
|
914
|
+
)
|
|
915
|
+
);
|
|
916
|
+
}
|
|
917
|
+
const excludedPolicyPath = policy.paths.find((policyPath) =>
|
|
918
|
+
scope?.exclude?.some(
|
|
919
|
+
(excludedPath) =>
|
|
920
|
+
excludedPath === policyPath ||
|
|
921
|
+
matchesAnyPathGlob(policyPath, [excludedPath]) ||
|
|
922
|
+
matchesAnyPathGlob(excludedPath, [policyPath])
|
|
923
|
+
)
|
|
924
|
+
);
|
|
925
|
+
if (excludedPolicyPath !== undefined) {
|
|
926
|
+
return Result.err(
|
|
927
|
+
new ValidationError(
|
|
928
|
+
`Vocabulary Regrade scope cannot both exclude and policy-classify "${excludedPolicyPath}".`
|
|
929
|
+
)
|
|
930
|
+
);
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
return Result.ok();
|
|
934
|
+
};
|
|
935
|
+
|
|
936
|
+
const validateVocabularyPlan = (
|
|
937
|
+
plan: VocabularyRegradePlan
|
|
938
|
+
): Result<void, ValidationError> => {
|
|
939
|
+
if (plan.from.trim().length === 0) {
|
|
940
|
+
return Result.err(
|
|
941
|
+
new ValidationError('Vocabulary Regrade plan `from` cannot be empty.')
|
|
942
|
+
);
|
|
943
|
+
}
|
|
944
|
+
if (plan.to.trim().length === 0) {
|
|
945
|
+
return Result.err(
|
|
946
|
+
new ValidationError('Vocabulary Regrade plan `to` cannot be empty.')
|
|
947
|
+
);
|
|
948
|
+
}
|
|
949
|
+
for (const [form, replacement] of normalizedOverrideEntries(plan.overrides)) {
|
|
950
|
+
if (form.trim().length === 0) {
|
|
951
|
+
return Result.err(
|
|
952
|
+
new ValidationError(
|
|
953
|
+
'Vocabulary Regrade plan override keys cannot be empty.'
|
|
954
|
+
)
|
|
955
|
+
);
|
|
956
|
+
}
|
|
957
|
+
if (replacement.trim().length === 0) {
|
|
958
|
+
return Result.err(
|
|
959
|
+
new ValidationError(
|
|
960
|
+
`Vocabulary Regrade plan override "${form}" cannot map to an empty replacement.`
|
|
961
|
+
)
|
|
962
|
+
);
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
for (const form of deferFormsForPlan(plan)) {
|
|
966
|
+
if (form.trim().length === 0) {
|
|
967
|
+
return Result.err(
|
|
968
|
+
new ValidationError(
|
|
969
|
+
'Vocabulary Regrade plan deferForms entries cannot be empty.'
|
|
970
|
+
)
|
|
971
|
+
);
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
for (const rule of plan.preserve ?? []) {
|
|
975
|
+
if (rule.pattern.trim().length === 0) {
|
|
976
|
+
return Result.err(
|
|
977
|
+
new ValidationError(
|
|
978
|
+
'Vocabulary Regrade plan preserve patterns cannot be empty.'
|
|
979
|
+
)
|
|
980
|
+
);
|
|
981
|
+
}
|
|
982
|
+
if (
|
|
983
|
+
rule.disposition !== undefined &&
|
|
984
|
+
!vocabularyDispositions.has(rule.disposition)
|
|
985
|
+
) {
|
|
986
|
+
return Result.err(
|
|
987
|
+
new ValidationError(
|
|
988
|
+
`Vocabulary Regrade plan preserve disposition "${rule.disposition}" is not supported.`
|
|
989
|
+
)
|
|
990
|
+
);
|
|
991
|
+
}
|
|
992
|
+
if (rule.forms?.some((form) => form.trim().length === 0) === true) {
|
|
993
|
+
return Result.err(
|
|
994
|
+
new ValidationError(
|
|
995
|
+
'Vocabulary Regrade plan preserve forms cannot be empty.'
|
|
996
|
+
)
|
|
997
|
+
);
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
return validateVocabularyScope(plan.scope);
|
|
1001
|
+
};
|
|
1002
|
+
|
|
1003
|
+
const validatePreserveInventory = (
|
|
1004
|
+
inventory: readonly VocabularyPreserveInventoryEntry[] | undefined
|
|
1005
|
+
): Result<void, ValidationError> => {
|
|
1006
|
+
for (const entry of inventory ?? []) {
|
|
1007
|
+
if (entry.pattern.trim().length === 0) {
|
|
1008
|
+
return Result.err(
|
|
1009
|
+
new ValidationError(
|
|
1010
|
+
'Vocabulary Regrade preserve inventory patterns cannot be empty.'
|
|
1011
|
+
)
|
|
1012
|
+
);
|
|
1013
|
+
}
|
|
1014
|
+
if (entry.forms?.some((form) => form.trim().length === 0) === true) {
|
|
1015
|
+
return Result.err(
|
|
1016
|
+
new ValidationError(
|
|
1017
|
+
'Vocabulary Regrade preserve inventory forms cannot be empty.'
|
|
1018
|
+
)
|
|
1019
|
+
);
|
|
1020
|
+
}
|
|
1021
|
+
if (
|
|
1022
|
+
entry.disposition !== undefined &&
|
|
1023
|
+
!vocabularyDispositions.has(entry.disposition)
|
|
1024
|
+
) {
|
|
1025
|
+
return Result.err(
|
|
1026
|
+
new ValidationError(
|
|
1027
|
+
`Vocabulary Regrade preserve inventory disposition "${entry.disposition}" is not supported.`
|
|
1028
|
+
)
|
|
1029
|
+
);
|
|
1030
|
+
}
|
|
1031
|
+
if (entry.evidence.length === 0) {
|
|
1032
|
+
return Result.err(
|
|
1033
|
+
new ValidationError(
|
|
1034
|
+
'Vocabulary Regrade preserve inventory entries need evidence.'
|
|
1035
|
+
)
|
|
1036
|
+
);
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
return Result.ok();
|
|
1040
|
+
};
|
|
1041
|
+
|
|
1042
|
+
const effectivePlanForRun = (
|
|
1043
|
+
plan: VocabularyRegradePlan,
|
|
1044
|
+
preserveInventory: readonly VocabularyPreserveInventoryEntry[] | undefined
|
|
1045
|
+
): VocabularyRegradePlan => {
|
|
1046
|
+
if (preserveInventory === undefined || preserveInventory.length === 0) {
|
|
1047
|
+
return plan;
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
return {
|
|
1051
|
+
...plan,
|
|
1052
|
+
preserve: [...(plan.preserve ?? []), ...preserveInventory],
|
|
1053
|
+
};
|
|
1054
|
+
};
|
|
1055
|
+
|
|
1056
|
+
const vocabularyScanFlags = (plan: VocabularyRegradePlan): string =>
|
|
1057
|
+
plan.caseSensitive === true ? 'g' : 'gi';
|
|
1058
|
+
|
|
1059
|
+
const includedByScope = (
|
|
1060
|
+
path: string,
|
|
1061
|
+
scope: VocabularyRegradeScope | undefined
|
|
1062
|
+
): boolean =>
|
|
1063
|
+
(scope?.include === undefined ||
|
|
1064
|
+
scope.include.length === 0 ||
|
|
1065
|
+
matchesAnyPathGlob(path, scope.include)) &&
|
|
1066
|
+
!matchesAnyPathGlob(path, scope?.exclude);
|
|
1067
|
+
|
|
1068
|
+
const compilePreservePattern = (pattern: string): RegExp => {
|
|
1069
|
+
try {
|
|
1070
|
+
return new RegExp(pattern);
|
|
1071
|
+
} catch {
|
|
1072
|
+
return new RegExp(escapeRegExp(pattern));
|
|
1073
|
+
}
|
|
1074
|
+
};
|
|
1075
|
+
|
|
1076
|
+
const globalPreservePattern = (pattern: RegExp): RegExp => {
|
|
1077
|
+
const flags = pattern.flags.includes('g')
|
|
1078
|
+
? pattern.flags
|
|
1079
|
+
: `${pattern.flags}g`;
|
|
1080
|
+
return new RegExp(pattern.source, flags);
|
|
1081
|
+
};
|
|
1082
|
+
|
|
1083
|
+
const patternOverlapsOccurrence = (
|
|
1084
|
+
pattern: RegExp,
|
|
1085
|
+
occurrence: SourceOccurrenceDraft
|
|
1086
|
+
): boolean => {
|
|
1087
|
+
const occurrenceStart = occurrence.contextColumn - 1;
|
|
1088
|
+
const occurrenceEnd = occurrenceStart + occurrence.form.length;
|
|
1089
|
+
|
|
1090
|
+
for (const match of occurrence.context.matchAll(
|
|
1091
|
+
globalPreservePattern(pattern)
|
|
1092
|
+
)) {
|
|
1093
|
+
const matchStart = match.index ?? 0;
|
|
1094
|
+
const matchEnd = matchStart + match[0].length;
|
|
1095
|
+
if (
|
|
1096
|
+
matchStart !== matchEnd &&
|
|
1097
|
+
occurrenceStart < matchEnd &&
|
|
1098
|
+
matchStart < occurrenceEnd
|
|
1099
|
+
) {
|
|
1100
|
+
return true;
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
return false;
|
|
1105
|
+
};
|
|
1106
|
+
|
|
1107
|
+
const preserveRuleForOccurrence = (
|
|
1108
|
+
occurrence: SourceOccurrenceDraft,
|
|
1109
|
+
plan: VocabularyRegradePlan
|
|
1110
|
+
): VocabularyPreserveRule | undefined =>
|
|
1111
|
+
plan.preserve?.find((rule) => {
|
|
1112
|
+
if (rule.forms !== undefined && !rule.forms.includes(occurrence.form)) {
|
|
1113
|
+
return false;
|
|
1114
|
+
}
|
|
1115
|
+
if (
|
|
1116
|
+
rule.paths !== undefined &&
|
|
1117
|
+
!matchesAnyPathGlob(occurrence.path, rule.paths)
|
|
1118
|
+
) {
|
|
1119
|
+
return false;
|
|
1120
|
+
}
|
|
1121
|
+
const pattern = compilePreservePattern(rule.pattern);
|
|
1122
|
+
if (
|
|
1123
|
+
pattern.test(occurrence.form) ||
|
|
1124
|
+
patternOverlapsOccurrence(pattern, occurrence)
|
|
1125
|
+
) {
|
|
1126
|
+
return true;
|
|
1127
|
+
}
|
|
1128
|
+
return rule.forms === undefined && pattern.test(occurrence.context);
|
|
1129
|
+
});
|
|
1130
|
+
|
|
1131
|
+
const scopePolicyForPath = (
|
|
1132
|
+
path: string,
|
|
1133
|
+
scope: VocabularyRegradeScope | undefined
|
|
1134
|
+
): VocabularyScopePolicy | undefined =>
|
|
1135
|
+
scope?.policyClassified?.find((policy) =>
|
|
1136
|
+
matchesAnyPathGlob(path, policy.paths)
|
|
1137
|
+
);
|
|
1138
|
+
|
|
1139
|
+
const scopePolicyForOccurrence = (
|
|
1140
|
+
occurrence: SourceOccurrenceDraft,
|
|
1141
|
+
plan: VocabularyRegradePlan
|
|
1142
|
+
): VocabularyScopePolicy | undefined =>
|
|
1143
|
+
scopePolicyForPath(occurrence.path, plan.scope);
|
|
1144
|
+
|
|
1145
|
+
const occurrenceClassification = (
|
|
1146
|
+
occurrence: SourceOccurrenceDraft,
|
|
1147
|
+
plan: VocabularyRegradePlan
|
|
1148
|
+
): {
|
|
1149
|
+
readonly preserveRule: VocabularyPreserveRule | undefined;
|
|
1150
|
+
readonly scopeTier: VocabularyScopeTier;
|
|
1151
|
+
} => {
|
|
1152
|
+
const policy = scopePolicyForOccurrence(occurrence, plan);
|
|
1153
|
+
const preserveRule = preserveRuleForOccurrence(occurrence, plan);
|
|
1154
|
+
return {
|
|
1155
|
+
preserveRule:
|
|
1156
|
+
preserveRule ??
|
|
1157
|
+
(policy === undefined
|
|
1158
|
+
? undefined
|
|
1159
|
+
: {
|
|
1160
|
+
disposition: policy.disposition,
|
|
1161
|
+
pattern: occurrence.form,
|
|
1162
|
+
reason: policy.reason,
|
|
1163
|
+
}),
|
|
1164
|
+
scopeTier: policy === undefined ? 'in-scope' : 'policy-classified',
|
|
1165
|
+
};
|
|
1166
|
+
};
|
|
1167
|
+
|
|
1168
|
+
const occurrenceOverlaps = (
|
|
1169
|
+
occurrences: readonly {
|
|
1170
|
+
readonly end: number;
|
|
1171
|
+
readonly start: number;
|
|
1172
|
+
}[],
|
|
1173
|
+
start: number,
|
|
1174
|
+
end: number
|
|
1175
|
+
): boolean =>
|
|
1176
|
+
occurrences.some(
|
|
1177
|
+
(occurrence) => start < occurrence.end && occurrence.start < end
|
|
1178
|
+
);
|
|
1179
|
+
|
|
1180
|
+
const occurrenceDraftForSpan = (
|
|
1181
|
+
file: SourceFile,
|
|
1182
|
+
start: number,
|
|
1183
|
+
end: number,
|
|
1184
|
+
form = file.source.slice(start, end)
|
|
1185
|
+
): SourceOccurrenceDraft => {
|
|
1186
|
+
const { column, line } = lineColumnForOffset(file.source, start);
|
|
1187
|
+
const context = contextDetailsForOffset(file.source, start, end);
|
|
1188
|
+
return {
|
|
1189
|
+
absolutePath: file.absolutePath,
|
|
1190
|
+
column,
|
|
1191
|
+
context: context.context,
|
|
1192
|
+
contextColumn: context.contextColumn,
|
|
1193
|
+
end,
|
|
1194
|
+
form,
|
|
1195
|
+
line,
|
|
1196
|
+
path: file.path,
|
|
1197
|
+
start,
|
|
1198
|
+
};
|
|
1199
|
+
};
|
|
1200
|
+
|
|
1201
|
+
const deferredOccurrenceFromDraft = (
|
|
1202
|
+
file: SourceFile,
|
|
1203
|
+
plan: VocabularyRegradePlan,
|
|
1204
|
+
baseOccurrence: SourceOccurrenceDraft,
|
|
1205
|
+
reason = 'unclassified-neighbor'
|
|
1206
|
+
): SourceOccurrence => {
|
|
1207
|
+
const { preserveRule, scopeTier } = occurrenceClassification(
|
|
1208
|
+
baseOccurrence,
|
|
1209
|
+
plan
|
|
1210
|
+
);
|
|
1211
|
+
const markdownCodeContext = isMarkdownCodeContext(
|
|
1212
|
+
file,
|
|
1213
|
+
baseOccurrence.start,
|
|
1214
|
+
baseOccurrence.end
|
|
1215
|
+
);
|
|
1216
|
+
const verdict = preserveRule === undefined ? 'deferred' : 'skipped';
|
|
1217
|
+
return {
|
|
1218
|
+
absolutePath: baseOccurrence.absolutePath,
|
|
1219
|
+
column: baseOccurrence.column,
|
|
1220
|
+
context: baseOccurrence.context,
|
|
1221
|
+
disposition: vocabularyOccurrenceDisposition(
|
|
1222
|
+
verdict,
|
|
1223
|
+
preserveRule,
|
|
1224
|
+
markdownCodeContext
|
|
1225
|
+
),
|
|
1226
|
+
end: baseOccurrence.end,
|
|
1227
|
+
form: baseOccurrence.form,
|
|
1228
|
+
line: baseOccurrence.line,
|
|
1229
|
+
path: baseOccurrence.path,
|
|
1230
|
+
reason: vocabularyOccurrenceReason(
|
|
1231
|
+
preserveRule,
|
|
1232
|
+
markdownCodeContext,
|
|
1233
|
+
reason
|
|
1234
|
+
),
|
|
1235
|
+
scopeTier,
|
|
1236
|
+
start: baseOccurrence.start,
|
|
1237
|
+
verdict,
|
|
1238
|
+
};
|
|
1239
|
+
};
|
|
1240
|
+
|
|
1241
|
+
const exactDeferredFormOccurrencesForFile = (
|
|
1242
|
+
file: SourceFile,
|
|
1243
|
+
plan: VocabularyRegradePlan,
|
|
1244
|
+
deferForms: readonly string[],
|
|
1245
|
+
targetFormSpans: readonly {
|
|
1246
|
+
readonly end: number;
|
|
1247
|
+
readonly start: number;
|
|
1248
|
+
}[]
|
|
1249
|
+
): readonly SourceOccurrence[] => {
|
|
1250
|
+
const occurrences: SourceOccurrence[] = [];
|
|
1251
|
+
const authoredDeferForms = new Set(
|
|
1252
|
+
(plan.deferForms ?? []).map((form) => formIdentityForPlan(plan, form))
|
|
1253
|
+
);
|
|
1254
|
+
for (const form of deferForms) {
|
|
1255
|
+
const pattern = new RegExp(escapeRegExp(form), vocabularyScanFlags(plan));
|
|
1256
|
+
for (const match of file.source.matchAll(pattern)) {
|
|
1257
|
+
const start = match.index ?? 0;
|
|
1258
|
+
const end = start + match[0].length;
|
|
1259
|
+
const isAuthoredDefer = authoredDeferForms.has(
|
|
1260
|
+
formIdentityForPlan(plan, form)
|
|
1261
|
+
);
|
|
1262
|
+
if (
|
|
1263
|
+
!hasWordBoundary(file.source, start, end, form) ||
|
|
1264
|
+
occurrenceOverlaps(occurrences, start, end) ||
|
|
1265
|
+
(!isAuthoredDefer && occurrenceOverlaps(targetFormSpans, start, end))
|
|
1266
|
+
) {
|
|
1267
|
+
continue;
|
|
1268
|
+
}
|
|
1269
|
+
occurrences.push(
|
|
1270
|
+
deferredOccurrenceFromDraft(
|
|
1271
|
+
file,
|
|
1272
|
+
plan,
|
|
1273
|
+
occurrenceDraftForSpan(file, start, end, match[0]),
|
|
1274
|
+
'deferred-form'
|
|
1275
|
+
)
|
|
1276
|
+
);
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
return occurrences;
|
|
1280
|
+
};
|
|
1281
|
+
|
|
1282
|
+
const targetFormSpansForFile = (
|
|
1283
|
+
file: SourceFile,
|
|
1284
|
+
plan: VocabularyRegradePlan,
|
|
1285
|
+
targetForms: Map<string, string>
|
|
1286
|
+
): readonly {
|
|
1287
|
+
readonly end: number;
|
|
1288
|
+
readonly start: number;
|
|
1289
|
+
}[] => {
|
|
1290
|
+
const spans: { end: number; start: number }[] = [];
|
|
1291
|
+
for (const form of targetForms.keys()) {
|
|
1292
|
+
const pattern = new RegExp(escapeRegExp(form), vocabularyScanFlags(plan));
|
|
1293
|
+
for (const match of file.source.matchAll(pattern)) {
|
|
1294
|
+
const start = match.index ?? 0;
|
|
1295
|
+
const end = start + match[0].length;
|
|
1296
|
+
if (
|
|
1297
|
+
!hasWordBoundary(file.source, start, end, form) ||
|
|
1298
|
+
occurrenceOverlaps(spans, start, end)
|
|
1299
|
+
) {
|
|
1300
|
+
continue;
|
|
1301
|
+
}
|
|
1302
|
+
spans.push({ end, start });
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
return spans;
|
|
1306
|
+
};
|
|
1307
|
+
|
|
1308
|
+
const occurrencesForFile = (
|
|
1309
|
+
file: SourceFile,
|
|
1310
|
+
plan: VocabularyRegradePlan,
|
|
1311
|
+
targetForms: Map<string, string>,
|
|
1312
|
+
deferredOccurrences: readonly SourceOccurrence[]
|
|
1313
|
+
): readonly SourceOccurrence[] => {
|
|
1314
|
+
const occurrences: SourceOccurrence[] = [];
|
|
1315
|
+
const candidates: SourceOccurrence[] = [];
|
|
1316
|
+
const forms = [...targetForms.entries()].toSorted(
|
|
1317
|
+
([left], [right]) => right.length - left.length || left.localeCompare(right)
|
|
1318
|
+
);
|
|
1319
|
+
|
|
1320
|
+
for (const [form, replacement] of forms) {
|
|
1321
|
+
const pattern = new RegExp(escapeRegExp(form), vocabularyScanFlags(plan));
|
|
1322
|
+
for (const match of file.source.matchAll(pattern)) {
|
|
1323
|
+
const start = match.index ?? 0;
|
|
1324
|
+
const end = start + match[0].length;
|
|
1325
|
+
if (
|
|
1326
|
+
!hasWordBoundary(file.source, start, end, form) ||
|
|
1327
|
+
occurrenceOverlaps(deferredOccurrences, start, end)
|
|
1328
|
+
) {
|
|
1329
|
+
continue;
|
|
1330
|
+
}
|
|
1331
|
+
const { column, line } = lineColumnForOffset(file.source, start);
|
|
1332
|
+
const context = contextDetailsForOffset(file.source, start, end);
|
|
1333
|
+
const baseOccurrence = {
|
|
1334
|
+
absolutePath: file.absolutePath,
|
|
1335
|
+
column,
|
|
1336
|
+
context: context.context,
|
|
1337
|
+
contextColumn: context.contextColumn,
|
|
1338
|
+
end,
|
|
1339
|
+
form: match[0],
|
|
1340
|
+
line,
|
|
1341
|
+
path: file.path,
|
|
1342
|
+
start,
|
|
1343
|
+
};
|
|
1344
|
+
const { preserveRule, scopeTier } = occurrenceClassification(
|
|
1345
|
+
baseOccurrence,
|
|
1346
|
+
plan
|
|
1347
|
+
);
|
|
1348
|
+
const markdownCodeContext = isMarkdownCodeContext(file, start, end);
|
|
1349
|
+
const packageRouteCodeContext = isPackageRouteCodeOccurrence(
|
|
1350
|
+
file.path,
|
|
1351
|
+
form
|
|
1352
|
+
);
|
|
1353
|
+
let verdict = capturedVocabularyVerdict(
|
|
1354
|
+
preserveRule,
|
|
1355
|
+
markdownCodeContext
|
|
1356
|
+
);
|
|
1357
|
+
const packageManifestContext = isPackageManifestPath(file.path);
|
|
1358
|
+
if (
|
|
1359
|
+
preserveRule === undefined &&
|
|
1360
|
+
(packageRouteCodeContext || packageManifestContext)
|
|
1361
|
+
) {
|
|
1362
|
+
verdict = 'deferred';
|
|
1363
|
+
}
|
|
1364
|
+
let capturedReason = 'captured-form';
|
|
1365
|
+
if (packageRouteCodeContext) {
|
|
1366
|
+
capturedReason = 'package-route-ast-required';
|
|
1367
|
+
} else if (packageManifestContext) {
|
|
1368
|
+
capturedReason = 'package-manifest-structured-edit-required';
|
|
1369
|
+
}
|
|
1370
|
+
candidates.push({
|
|
1371
|
+
absolutePath: baseOccurrence.absolutePath,
|
|
1372
|
+
column: baseOccurrence.column,
|
|
1373
|
+
context: baseOccurrence.context,
|
|
1374
|
+
disposition: vocabularyOccurrenceDisposition(
|
|
1375
|
+
verdict,
|
|
1376
|
+
preserveRule,
|
|
1377
|
+
markdownCodeContext
|
|
1378
|
+
),
|
|
1379
|
+
end: baseOccurrence.end,
|
|
1380
|
+
form: baseOccurrence.form,
|
|
1381
|
+
line: baseOccurrence.line,
|
|
1382
|
+
path: baseOccurrence.path,
|
|
1383
|
+
reason: vocabularyOccurrenceReason(
|
|
1384
|
+
preserveRule,
|
|
1385
|
+
markdownCodeContext,
|
|
1386
|
+
capturedReason
|
|
1387
|
+
),
|
|
1388
|
+
scopeTier,
|
|
1389
|
+
...(preserveRule === undefined &&
|
|
1390
|
+
!markdownCodeContext &&
|
|
1391
|
+
!packageRouteCodeContext &&
|
|
1392
|
+
!packageManifestContext
|
|
1393
|
+
? { replacement: preserveCase(match[0], replacement) }
|
|
1394
|
+
: {}),
|
|
1395
|
+
start: baseOccurrence.start,
|
|
1396
|
+
verdict,
|
|
1397
|
+
});
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
for (const candidate of candidates.toSorted(
|
|
1402
|
+
(left, right) =>
|
|
1403
|
+
right.end - right.start - (left.end - left.start) ||
|
|
1404
|
+
left.start - right.start
|
|
1405
|
+
)) {
|
|
1406
|
+
const overlaps = occurrences.some(
|
|
1407
|
+
(occurrence) =>
|
|
1408
|
+
candidate.start < occurrence.end && occurrence.start < candidate.end
|
|
1409
|
+
);
|
|
1410
|
+
if (!overlaps) {
|
|
1411
|
+
occurrences.push(candidate);
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
return occurrences.toSorted((left, right) =>
|
|
1416
|
+
left.path === right.path
|
|
1417
|
+
? left.start - right.start
|
|
1418
|
+
: left.path.localeCompare(right.path)
|
|
1419
|
+
);
|
|
1420
|
+
};
|
|
1421
|
+
|
|
1422
|
+
const deferredOccurrencesForFile = (
|
|
1423
|
+
file: SourceFile,
|
|
1424
|
+
plan: VocabularyRegradePlan,
|
|
1425
|
+
targetForms: Map<string, string>
|
|
1426
|
+
): readonly SourceOccurrence[] => {
|
|
1427
|
+
const deferForms = deferFormsForPlan(plan);
|
|
1428
|
+
const targetFormSpans = targetFormSpansForFile(file, plan, targetForms);
|
|
1429
|
+
const knownForms = new Set(
|
|
1430
|
+
plan.caseSensitive === true
|
|
1431
|
+
? [...targetForms.keys(), ...deferForms]
|
|
1432
|
+
: [...targetForms.keys(), ...deferForms].flatMap((form) => [
|
|
1433
|
+
form,
|
|
1434
|
+
form.toLowerCase(),
|
|
1435
|
+
])
|
|
1436
|
+
);
|
|
1437
|
+
const lowerFrom = plan.from.toLowerCase();
|
|
1438
|
+
const tokenPattern = /[A-Za-z_$][A-Za-z0-9_$-]*/g;
|
|
1439
|
+
const occurrences = [
|
|
1440
|
+
...exactDeferredFormOccurrencesForFile(
|
|
1441
|
+
file,
|
|
1442
|
+
plan,
|
|
1443
|
+
deferForms,
|
|
1444
|
+
targetFormSpans
|
|
1445
|
+
),
|
|
1446
|
+
];
|
|
1447
|
+
|
|
1448
|
+
for (const form of targetForms.keys()) {
|
|
1449
|
+
const pattern = new RegExp(escapeRegExp(form), vocabularyScanFlags(plan));
|
|
1450
|
+
for (const match of file.source.matchAll(pattern)) {
|
|
1451
|
+
const matchStart = match.index ?? 0;
|
|
1452
|
+
const matchEnd = matchStart + match[0].length;
|
|
1453
|
+
if (hasWordBoundary(file.source, matchStart, matchEnd, form)) {
|
|
1454
|
+
continue;
|
|
1455
|
+
}
|
|
1456
|
+
const { end, start } = expandVocabularyNeighborSpan(
|
|
1457
|
+
file.source,
|
|
1458
|
+
matchStart,
|
|
1459
|
+
matchEnd,
|
|
1460
|
+
form
|
|
1461
|
+
);
|
|
1462
|
+
const matchedForm = file.source.slice(start, end);
|
|
1463
|
+
const lowerMatchedForm = matchedForm.toLowerCase();
|
|
1464
|
+
if (
|
|
1465
|
+
occurrenceOverlaps(occurrences, start, end) ||
|
|
1466
|
+
knownForms.has(matchedForm) ||
|
|
1467
|
+
(plan.caseSensitive !== true && knownForms.has(lowerMatchedForm)) ||
|
|
1468
|
+
!lowerMatchedForm.includes(lowerFrom)
|
|
1469
|
+
) {
|
|
1470
|
+
continue;
|
|
1471
|
+
}
|
|
1472
|
+
occurrences.push(
|
|
1473
|
+
deferredOccurrenceFromDraft(
|
|
1474
|
+
file,
|
|
1475
|
+
plan,
|
|
1476
|
+
occurrenceDraftForSpan(file, start, end, matchedForm)
|
|
1477
|
+
)
|
|
1478
|
+
);
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
for (const match of file.source.matchAll(tokenPattern)) {
|
|
1483
|
+
const [form] = match;
|
|
1484
|
+
const lower = form.toLowerCase();
|
|
1485
|
+
if (
|
|
1486
|
+
knownForms.has(form) ||
|
|
1487
|
+
(plan.caseSensitive !== true && knownForms.has(lower))
|
|
1488
|
+
) {
|
|
1489
|
+
continue;
|
|
1490
|
+
}
|
|
1491
|
+
if (!lower.includes(lowerFrom)) {
|
|
1492
|
+
continue;
|
|
1493
|
+
}
|
|
1494
|
+
const start = match.index ?? 0;
|
|
1495
|
+
const end = start + form.length;
|
|
1496
|
+
if (occurrenceOverlaps(occurrences, start, end)) {
|
|
1497
|
+
continue;
|
|
1498
|
+
}
|
|
1499
|
+
occurrences.push(
|
|
1500
|
+
deferredOccurrenceFromDraft(
|
|
1501
|
+
file,
|
|
1502
|
+
plan,
|
|
1503
|
+
occurrenceDraftForSpan(file, start, end, form)
|
|
1504
|
+
)
|
|
1505
|
+
);
|
|
1506
|
+
}
|
|
1507
|
+
return occurrences.toSorted((left, right) =>
|
|
1508
|
+
left.path === right.path
|
|
1509
|
+
? left.start - right.start
|
|
1510
|
+
: left.path.localeCompare(right.path)
|
|
1511
|
+
);
|
|
1512
|
+
};
|
|
1513
|
+
|
|
1514
|
+
const entryForOccurrences = (
|
|
1515
|
+
path: string,
|
|
1516
|
+
occurrences: readonly SourceOccurrence[]
|
|
1517
|
+
): RegradeReportEntry | null => {
|
|
1518
|
+
if (occurrences.length === 0) {
|
|
1519
|
+
return null;
|
|
1520
|
+
}
|
|
1521
|
+
const hasDeferred = occurrences.some(
|
|
1522
|
+
(occurrence) => occurrence.verdict === 'deferred'
|
|
1523
|
+
);
|
|
1524
|
+
const hasModified = occurrences.some(
|
|
1525
|
+
(occurrence) => occurrence.verdict === 'modified'
|
|
1526
|
+
);
|
|
1527
|
+
if (hasDeferred) {
|
|
1528
|
+
return {
|
|
1529
|
+
notes: [
|
|
1530
|
+
`Found ${occurrences.length} vocabulary occurrence(s); judgment deferred.`,
|
|
1531
|
+
],
|
|
1532
|
+
outcome: 'needs-review',
|
|
1533
|
+
path,
|
|
1534
|
+
reason: 'vocabulary-judgment-deferred',
|
|
1535
|
+
reviewDetails: occurrences
|
|
1536
|
+
.filter((occurrence) => occurrence.verdict === 'deferred')
|
|
1537
|
+
.map((occurrence) => ({
|
|
1538
|
+
context: occurrence.context,
|
|
1539
|
+
expectedTarget:
|
|
1540
|
+
'Add an override or preserve rule to the regrade plan.',
|
|
1541
|
+
judgment: 'unresolved' as const,
|
|
1542
|
+
matchedForm: occurrence.form,
|
|
1543
|
+
...(occurrence.sourceKind === undefined
|
|
1544
|
+
? {}
|
|
1545
|
+
: {
|
|
1546
|
+
nodeKind:
|
|
1547
|
+
occurrence.sourceKind === 'tsdoc'
|
|
1548
|
+
? 'TSDocComment'
|
|
1549
|
+
: 'SourceComment',
|
|
1550
|
+
}),
|
|
1551
|
+
reason: occurrence.reason,
|
|
1552
|
+
span: {
|
|
1553
|
+
column: occurrence.column,
|
|
1554
|
+
end: occurrence.end,
|
|
1555
|
+
line: occurrence.line,
|
|
1556
|
+
start: occurrence.start,
|
|
1557
|
+
},
|
|
1558
|
+
symbol: occurrence.form,
|
|
1559
|
+
})),
|
|
1560
|
+
};
|
|
1561
|
+
}
|
|
1562
|
+
if (hasModified) {
|
|
1563
|
+
return {
|
|
1564
|
+
notes: [
|
|
1565
|
+
`Found ${occurrences.length} vocabulary occurrence(s); safe modifications available.`,
|
|
1566
|
+
],
|
|
1567
|
+
outcome: 'rewrite',
|
|
1568
|
+
path,
|
|
1569
|
+
};
|
|
1570
|
+
}
|
|
1571
|
+
return {
|
|
1572
|
+
notes: [`Skipped ${occurrences.length} vocabulary occurrence(s).`],
|
|
1573
|
+
outcome: 'no-op',
|
|
1574
|
+
path,
|
|
1575
|
+
};
|
|
1576
|
+
};
|
|
1577
|
+
|
|
1578
|
+
const applyOccurrenceRewrites = (
|
|
1579
|
+
file: SourceFile,
|
|
1580
|
+
occurrences: readonly SourceOccurrence[]
|
|
1581
|
+
): string => {
|
|
1582
|
+
let nextSource = file.source;
|
|
1583
|
+
for (const occurrence of occurrences.toReversed()) {
|
|
1584
|
+
if (
|
|
1585
|
+
occurrence.verdict !== 'modified' ||
|
|
1586
|
+
occurrence.replacement === undefined
|
|
1587
|
+
) {
|
|
1588
|
+
continue;
|
|
1589
|
+
}
|
|
1590
|
+
nextSource =
|
|
1591
|
+
nextSource.slice(0, occurrence.start) +
|
|
1592
|
+
occurrence.replacement +
|
|
1593
|
+
nextSource.slice(occurrence.end);
|
|
1594
|
+
}
|
|
1595
|
+
return nextSource;
|
|
1596
|
+
};
|
|
1597
|
+
|
|
1598
|
+
const buildVocabularyEvaluation = (params: {
|
|
1599
|
+
readonly apply?: boolean;
|
|
1600
|
+
readonly effectivePlan?: VocabularyRegradePlan | undefined;
|
|
1601
|
+
readonly files: readonly SourceFile[];
|
|
1602
|
+
readonly plan: VocabularyRegradePlan;
|
|
1603
|
+
readonly preserveInventory?: readonly VocabularyPreserveInventoryEntry[];
|
|
1604
|
+
readonly root: string;
|
|
1605
|
+
readonly skipped: readonly SkippedSource[];
|
|
1606
|
+
readonly sourceKindForPath?:
|
|
1607
|
+
| ((path: string) => VocabularySourceKind)
|
|
1608
|
+
| undefined;
|
|
1609
|
+
}): VocabularyEvaluation => {
|
|
1610
|
+
const effectivePlan = params.effectivePlan ?? params.plan;
|
|
1611
|
+
const targetForms = targetFormsForPlan(effectivePlan);
|
|
1612
|
+
const scopedFiles = params.files.filter((file) =>
|
|
1613
|
+
includedByScope(file.path, effectivePlan.scope)
|
|
1614
|
+
);
|
|
1615
|
+
const scopeSkipped: SkippedSource[] = params.files
|
|
1616
|
+
.filter((file) => !includedByScope(file.path, effectivePlan.scope))
|
|
1617
|
+
.map((file) => ({ path: file.path, reason: 'excluded-by-regrade-scope' }));
|
|
1618
|
+
const commentParseSkipped: SkippedSource[] = [];
|
|
1619
|
+
const occurrences = scopedFiles.flatMap((file) => {
|
|
1620
|
+
const sourceKind = params.sourceKindForPath?.(file.path) ?? 'all';
|
|
1621
|
+
const scanPlan =
|
|
1622
|
+
sourceKind === 'comments'
|
|
1623
|
+
? commentReviewPlan(effectivePlan)
|
|
1624
|
+
: effectivePlan;
|
|
1625
|
+
const deferredOccurrences = deferredOccurrencesForFile(
|
|
1626
|
+
file,
|
|
1627
|
+
scanPlan,
|
|
1628
|
+
targetForms
|
|
1629
|
+
);
|
|
1630
|
+
const fileOccurrences = [
|
|
1631
|
+
...occurrencesForFile(file, scanPlan, targetForms, deferredOccurrences),
|
|
1632
|
+
...deferredOccurrences,
|
|
1633
|
+
];
|
|
1634
|
+
if (sourceKind === 'all') {
|
|
1635
|
+
return fileOccurrences;
|
|
1636
|
+
}
|
|
1637
|
+
if (fileOccurrences.length === 0) {
|
|
1638
|
+
return [];
|
|
1639
|
+
}
|
|
1640
|
+
const parsed = parseWithDiagnostics(file.path, file.source);
|
|
1641
|
+
if (parsed.diagnostics.length > 0 || parsed.ast === null) {
|
|
1642
|
+
commentParseSkipped.push({
|
|
1643
|
+
path: file.path,
|
|
1644
|
+
reason: 'source-comment-parse-diagnostics',
|
|
1645
|
+
});
|
|
1646
|
+
return [];
|
|
1647
|
+
}
|
|
1648
|
+
return fileOccurrences.flatMap((occurrence) => {
|
|
1649
|
+
const comment = parsed.comments.find(
|
|
1650
|
+
(candidate) =>
|
|
1651
|
+
candidate.start <= occurrence.start && occurrence.end <= candidate.end
|
|
1652
|
+
);
|
|
1653
|
+
if (comment === undefined) {
|
|
1654
|
+
return [];
|
|
1655
|
+
}
|
|
1656
|
+
const sourceKindForOccurrence = sourceCommentKind(file, comment);
|
|
1657
|
+
if (occurrence.verdict === 'skipped') {
|
|
1658
|
+
return [{ ...occurrence, sourceKind: sourceKindForOccurrence }];
|
|
1659
|
+
}
|
|
1660
|
+
const { replacement: _replacement, ...reviewOccurrence } = occurrence;
|
|
1661
|
+
return [
|
|
1662
|
+
{
|
|
1663
|
+
...reviewOccurrence,
|
|
1664
|
+
disposition: 'in-family-unresolved' as const,
|
|
1665
|
+
reason: 'source-comment-requires-review',
|
|
1666
|
+
sourceKind: sourceKindForOccurrence,
|
|
1667
|
+
verdict: 'deferred' as const,
|
|
1668
|
+
},
|
|
1669
|
+
];
|
|
1670
|
+
});
|
|
1671
|
+
});
|
|
1672
|
+
const occurrencesByPath = new Map<string, SourceOccurrence[]>();
|
|
1673
|
+
for (const occurrence of occurrences) {
|
|
1674
|
+
const existing = occurrencesByPath.get(occurrence.path) ?? [];
|
|
1675
|
+
occurrencesByPath.set(occurrence.path, [...existing, occurrence]);
|
|
1676
|
+
}
|
|
1677
|
+
const entries = [...occurrencesByPath.entries()]
|
|
1678
|
+
.flatMap(([path, pathOccurrences]) => {
|
|
1679
|
+
const entry = entryForOccurrences(path, pathOccurrences);
|
|
1680
|
+
return entry === null ? [] : [entry];
|
|
1681
|
+
})
|
|
1682
|
+
.toSorted((left, right) => left.path.localeCompare(right.path));
|
|
1683
|
+
const rewrittenPaths = new Set(
|
|
1684
|
+
occurrences
|
|
1685
|
+
.filter((occurrence) => occurrence.verdict === 'modified')
|
|
1686
|
+
.map((occurrence) => occurrence.path)
|
|
1687
|
+
);
|
|
1688
|
+
const deferredForms = uniqueSorted(
|
|
1689
|
+
occurrences
|
|
1690
|
+
.filter((occurrence) => occurrence.verdict === 'deferred')
|
|
1691
|
+
.map((occurrence) => occurrence.form)
|
|
1692
|
+
);
|
|
1693
|
+
const modifiedOccurrences = occurrences.filter(
|
|
1694
|
+
(occurrence) => occurrence.verdict === 'modified'
|
|
1695
|
+
);
|
|
1696
|
+
const skippedOccurrences = occurrences.filter(
|
|
1697
|
+
(occurrence) => occurrence.verdict === 'skipped'
|
|
1698
|
+
);
|
|
1699
|
+
const deferredOccurrences = occurrences.filter(
|
|
1700
|
+
(occurrence) => occurrence.verdict === 'deferred'
|
|
1701
|
+
);
|
|
1702
|
+
const unresolvedOccurrences = occurrences.filter(
|
|
1703
|
+
(occurrence) =>
|
|
1704
|
+
occurrence.verdict === 'modified' || occurrence.verdict === 'deferred'
|
|
1705
|
+
);
|
|
1706
|
+
const forms = vocabularyFormVerdicts(occurrences);
|
|
1707
|
+
const gateReasons: string[] = [];
|
|
1708
|
+
if (modifiedOccurrences.length > 0) {
|
|
1709
|
+
gateReasons.push(
|
|
1710
|
+
params.apply === true
|
|
1711
|
+
? 'source-forms-remain-after-apply'
|
|
1712
|
+
: 'safe-modifications-not-yet-applied'
|
|
1713
|
+
);
|
|
1714
|
+
}
|
|
1715
|
+
if (deferredForms.length > 0) {
|
|
1716
|
+
gateReasons.push('deferred-forms-or-occurrences');
|
|
1717
|
+
}
|
|
1718
|
+
if (commentParseSkipped.length > 0) {
|
|
1719
|
+
gateReasons.push('source-comment-parse-diagnostics');
|
|
1720
|
+
}
|
|
1721
|
+
const open = unresolvedOccurrences.length;
|
|
1722
|
+
const scopeEvidence = vocabularyScopeEvidence(effectivePlan, occurrences);
|
|
1723
|
+
gateReasons.push(...scopeEvidence.gateReasons);
|
|
1724
|
+
|
|
1725
|
+
return {
|
|
1726
|
+
entries: [
|
|
1727
|
+
...entries,
|
|
1728
|
+
...params.skipped.map((entry) => ({
|
|
1729
|
+
outcome: 'skip' as const,
|
|
1730
|
+
path: entry.path,
|
|
1731
|
+
reason: entry.reason,
|
|
1732
|
+
})),
|
|
1733
|
+
...scopeSkipped.map((entry) => ({
|
|
1734
|
+
outcome: 'skip' as const,
|
|
1735
|
+
path: entry.path,
|
|
1736
|
+
reason: entry.reason,
|
|
1737
|
+
})),
|
|
1738
|
+
...commentParseSkipped.map((entry) => ({
|
|
1739
|
+
outcome: 'skip' as const,
|
|
1740
|
+
path: entry.path,
|
|
1741
|
+
reason: entry.reason,
|
|
1742
|
+
})),
|
|
1743
|
+
].toSorted((left, right) => left.path.localeCompare(right.path)),
|
|
1744
|
+
occurrences,
|
|
1745
|
+
run: {
|
|
1746
|
+
ledger: {
|
|
1747
|
+
cycle: 1,
|
|
1748
|
+
forms,
|
|
1749
|
+
occurrences: occurrences.map(
|
|
1750
|
+
({ absolutePath: _absolutePath, ...occurrence }) => occurrence
|
|
1751
|
+
),
|
|
1752
|
+
},
|
|
1753
|
+
plan: params.plan,
|
|
1754
|
+
...(params.preserveInventory === undefined ||
|
|
1755
|
+
params.preserveInventory.length === 0
|
|
1756
|
+
? {}
|
|
1757
|
+
: { preserveInventory: params.preserveInventory }),
|
|
1758
|
+
report: {
|
|
1759
|
+
applied: params.apply === true ? modifiedOccurrences.length : 0,
|
|
1760
|
+
deferred: deferredOccurrences.length,
|
|
1761
|
+
dispositions: vocabularyDispositionCounts(occurrences),
|
|
1762
|
+
filesChanged: params.apply === true ? rewrittenPaths.size : 0,
|
|
1763
|
+
gate: {
|
|
1764
|
+
reasons: gateReasons,
|
|
1765
|
+
remaining: open,
|
|
1766
|
+
remainingByDisposition: vocabularyDispositionCounts(
|
|
1767
|
+
unresolvedOccurrences
|
|
1768
|
+
),
|
|
1769
|
+
status: gateReasons.length === 0 ? 'green' : 'open',
|
|
1770
|
+
},
|
|
1771
|
+
modified: modifiedOccurrences.length,
|
|
1772
|
+
open,
|
|
1773
|
+
scopeTiers: scopeEvidence.scopeTiers,
|
|
1774
|
+
skipped: skippedOccurrences.length,
|
|
1775
|
+
teachingSurfaces: scopeEvidence.teachingSurfaces,
|
|
1776
|
+
},
|
|
1777
|
+
},
|
|
1778
|
+
scanned: scopedFiles.length,
|
|
1779
|
+
skipped: [...params.skipped, ...scopeSkipped, ...commentParseSkipped],
|
|
1780
|
+
};
|
|
1781
|
+
};
|
|
1782
|
+
|
|
1783
|
+
const skippedByReason = (
|
|
1784
|
+
skipped: readonly SkippedSource[]
|
|
1785
|
+
): Readonly<Record<string, number>> => {
|
|
1786
|
+
const counts = new Map<string, number>();
|
|
1787
|
+
for (const entry of skipped) {
|
|
1788
|
+
counts.set(entry.reason, (counts.get(entry.reason) ?? 0) + 1);
|
|
1789
|
+
}
|
|
1790
|
+
return Object.fromEntries(
|
|
1791
|
+
[...counts.entries()].toSorted(([left], [right]) =>
|
|
1792
|
+
left.localeCompare(right)
|
|
1793
|
+
)
|
|
1794
|
+
);
|
|
1795
|
+
};
|
|
1796
|
+
|
|
1797
|
+
const withScannedPaths = (
|
|
1798
|
+
report: RegradeReport,
|
|
1799
|
+
paths: readonly string[]
|
|
1800
|
+
): RegradeReport => {
|
|
1801
|
+
Object.defineProperty(report, 'scannedPaths', {
|
|
1802
|
+
configurable: false,
|
|
1803
|
+
enumerable: false,
|
|
1804
|
+
value: Object.freeze([...paths]),
|
|
1805
|
+
writable: false,
|
|
1806
|
+
});
|
|
1807
|
+
return report;
|
|
1808
|
+
};
|
|
1809
|
+
|
|
1810
|
+
const cloneVocabularyReport = (report: RegradeReport): RegradeReport => {
|
|
1811
|
+
const clone = structuredClone(report);
|
|
1812
|
+
return report.scannedPaths === undefined
|
|
1813
|
+
? clone
|
|
1814
|
+
: withScannedPaths(clone, report.scannedPaths);
|
|
1815
|
+
};
|
|
1816
|
+
|
|
1817
|
+
const withApplySummary = (
|
|
1818
|
+
report: RegradeReport,
|
|
1819
|
+
apply: RegradeApplySummary
|
|
1820
|
+
): RegradeReport =>
|
|
1821
|
+
withScannedPaths(
|
|
1822
|
+
{
|
|
1823
|
+
...report,
|
|
1824
|
+
apply,
|
|
1825
|
+
},
|
|
1826
|
+
report.scannedPaths ?? []
|
|
1827
|
+
);
|
|
1828
|
+
|
|
1829
|
+
const appliedVocabularyRunReport = (
|
|
1830
|
+
postApply: VocabularyRegradeRun,
|
|
1831
|
+
occurrences: readonly VocabularyOccurrence[],
|
|
1832
|
+
apply: RegradeApplySummary
|
|
1833
|
+
): VocabularyRunReport => {
|
|
1834
|
+
const scopeEvidence = vocabularyScopeEvidence(postApply.plan, occurrences);
|
|
1835
|
+
const reasons = uniqueSorted([
|
|
1836
|
+
...postApply.report.gate.reasons.filter(
|
|
1837
|
+
(reason) =>
|
|
1838
|
+
reason !== 'expected-policy-classified-evidence-missing' &&
|
|
1839
|
+
reason !== 'expected-teaching-surfaces-missing'
|
|
1840
|
+
),
|
|
1841
|
+
...scopeEvidence.gateReasons,
|
|
1842
|
+
]);
|
|
1843
|
+
return {
|
|
1844
|
+
...postApply.report,
|
|
1845
|
+
applied: apply.applied,
|
|
1846
|
+
dispositions: vocabularyDispositionCounts(occurrences),
|
|
1847
|
+
filesChanged: apply.filesChanged,
|
|
1848
|
+
gate: {
|
|
1849
|
+
...postApply.report.gate,
|
|
1850
|
+
reasons,
|
|
1851
|
+
status: reasons.length === 0 ? 'green' : 'open',
|
|
1852
|
+
},
|
|
1853
|
+
scopeTiers: scopeEvidence.scopeTiers,
|
|
1854
|
+
teachingSurfaces: scopeEvidence.teachingSurfaces,
|
|
1855
|
+
};
|
|
1856
|
+
};
|
|
1857
|
+
|
|
1858
|
+
const vocabularyRunWithAppliedOccurrences = (
|
|
1859
|
+
postApply: VocabularyRegradeRun,
|
|
1860
|
+
dryRun: VocabularyRegradeRun,
|
|
1861
|
+
apply: RegradeApplySummary
|
|
1862
|
+
): VocabularyRegradeRun => {
|
|
1863
|
+
const appliedOccurrences = dryRun.ledger.occurrences
|
|
1864
|
+
.filter((occurrence) => occurrence.verdict === 'modified')
|
|
1865
|
+
.map((occurrence) => ({ ...occurrence, verdict: 'applied' as const }));
|
|
1866
|
+
const occurrences = [
|
|
1867
|
+
...postApply.ledger.occurrences,
|
|
1868
|
+
...appliedOccurrences,
|
|
1869
|
+
].toSorted(
|
|
1870
|
+
(left, right) =>
|
|
1871
|
+
left.path.localeCompare(right.path) ||
|
|
1872
|
+
left.start - right.start ||
|
|
1873
|
+
left.verdict.localeCompare(right.verdict)
|
|
1874
|
+
);
|
|
1875
|
+
return {
|
|
1876
|
+
...postApply,
|
|
1877
|
+
ledger: {
|
|
1878
|
+
...postApply.ledger,
|
|
1879
|
+
forms: vocabularyFormVerdicts(occurrences),
|
|
1880
|
+
occurrences,
|
|
1881
|
+
},
|
|
1882
|
+
report: appliedVocabularyRunReport(postApply, occurrences, apply),
|
|
1883
|
+
};
|
|
1884
|
+
};
|
|
1885
|
+
|
|
1886
|
+
const applyVocabularyEvaluation = (
|
|
1887
|
+
files: readonly SourceFile[],
|
|
1888
|
+
evaluation: VocabularyEvaluation
|
|
1889
|
+
): Result<RegradeApplySummary, InternalError> => {
|
|
1890
|
+
const changedFiles = new Set<string>();
|
|
1891
|
+
let applied = 0;
|
|
1892
|
+
for (const file of files) {
|
|
1893
|
+
const fileOccurrences = evaluation.occurrences.filter(
|
|
1894
|
+
(occurrence) =>
|
|
1895
|
+
occurrence.path === file.path && occurrence.verdict === 'modified'
|
|
1896
|
+
);
|
|
1897
|
+
if (fileOccurrences.length === 0) {
|
|
1898
|
+
continue;
|
|
1899
|
+
}
|
|
1900
|
+
const nextSource = applyOccurrenceRewrites(file, fileOccurrences);
|
|
1901
|
+
if (nextSource === file.source) {
|
|
1902
|
+
continue;
|
|
1903
|
+
}
|
|
1904
|
+
try {
|
|
1905
|
+
writeFileSync(file.absolutePath, nextSource, 'utf8');
|
|
1906
|
+
} catch (error: unknown) {
|
|
1907
|
+
return Result.err(
|
|
1908
|
+
new InternalError(
|
|
1909
|
+
`Failed to apply vocabulary regrade rewrite for "${file.path}".`,
|
|
1910
|
+
{
|
|
1911
|
+
cause: error instanceof Error ? error : new Error(String(error)),
|
|
1912
|
+
context: {
|
|
1913
|
+
applied,
|
|
1914
|
+
filesChanged: changedFiles.size,
|
|
1915
|
+
path: file.path,
|
|
1916
|
+
},
|
|
1917
|
+
}
|
|
1918
|
+
)
|
|
1919
|
+
);
|
|
1920
|
+
}
|
|
1921
|
+
applied += fileOccurrences.length;
|
|
1922
|
+
changedFiles.add(file.path);
|
|
1923
|
+
}
|
|
1924
|
+
|
|
1925
|
+
const reviewFiles = new Set(
|
|
1926
|
+
evaluation.occurrences
|
|
1927
|
+
.filter((occurrence) => occurrence.verdict === 'deferred')
|
|
1928
|
+
.map((occurrence) => occurrence.path)
|
|
1929
|
+
);
|
|
1930
|
+
const skippedOccurrences = evaluation.occurrences.filter(
|
|
1931
|
+
(occurrence) => occurrence.verdict === 'skipped'
|
|
1932
|
+
);
|
|
1933
|
+
|
|
1934
|
+
return Result.ok({
|
|
1935
|
+
applied,
|
|
1936
|
+
filesChanged: changedFiles.size,
|
|
1937
|
+
review: reviewFiles.size,
|
|
1938
|
+
skipped: skippedOccurrences.length + evaluation.skipped.length,
|
|
1939
|
+
unknown: 0,
|
|
1940
|
+
});
|
|
1941
|
+
};
|
|
1942
|
+
|
|
1943
|
+
const readVocabularySourceFiles = (
|
|
1944
|
+
collected: NonNullable<ReturnType<typeof collectDownstreamSources>>
|
|
1945
|
+
): {
|
|
1946
|
+
readonly files: readonly SourceFile[];
|
|
1947
|
+
readonly skipped: readonly SkippedSource[];
|
|
1948
|
+
} => {
|
|
1949
|
+
const files: SourceFile[] = [];
|
|
1950
|
+
const skipped: SkippedSource[] = [...collected.skipped];
|
|
1951
|
+
for (const file of collected.files) {
|
|
1952
|
+
try {
|
|
1953
|
+
const bytes = readFileSync(file.absolutePath);
|
|
1954
|
+
files.push({
|
|
1955
|
+
absolutePath: file.absolutePath,
|
|
1956
|
+
path: file.path,
|
|
1957
|
+
source: bytes.toString('utf8'),
|
|
1958
|
+
sourceBytes: bytes.toString('base64'),
|
|
1959
|
+
});
|
|
1960
|
+
} catch {
|
|
1961
|
+
skipped.push({ path: file.path, reason: 'unreadable-file' });
|
|
1962
|
+
}
|
|
1963
|
+
}
|
|
1964
|
+
return { files, skipped };
|
|
1965
|
+
};
|
|
1966
|
+
|
|
1967
|
+
const buildRunVocabularyEvaluation = (params: {
|
|
1968
|
+
readonly apply: boolean;
|
|
1969
|
+
readonly effectivePlan: VocabularyRegradePlan;
|
|
1970
|
+
readonly files: readonly SourceFile[];
|
|
1971
|
+
readonly plan: VocabularyRegradePlan;
|
|
1972
|
+
readonly preserveInventory:
|
|
1973
|
+
| readonly VocabularyPreserveInventoryEntry[]
|
|
1974
|
+
| undefined;
|
|
1975
|
+
readonly root: string;
|
|
1976
|
+
readonly skipped: readonly SkippedSource[];
|
|
1977
|
+
readonly sourceKindForPath?:
|
|
1978
|
+
| ((path: string) => VocabularySourceKind)
|
|
1979
|
+
| undefined;
|
|
1980
|
+
}): VocabularyEvaluation =>
|
|
1981
|
+
buildVocabularyEvaluation({
|
|
1982
|
+
apply: params.apply,
|
|
1983
|
+
effectivePlan: params.effectivePlan,
|
|
1984
|
+
files: params.files,
|
|
1985
|
+
plan: params.plan,
|
|
1986
|
+
...(params.preserveInventory === undefined
|
|
1987
|
+
? {}
|
|
1988
|
+
: { preserveInventory: params.preserveInventory }),
|
|
1989
|
+
root: params.root,
|
|
1990
|
+
skipped: params.skipped,
|
|
1991
|
+
sourceKindForPath: params.sourceKindForPath,
|
|
1992
|
+
});
|
|
1993
|
+
|
|
1994
|
+
const ignoredDirectoriesOpenedByPolicy = (
|
|
1995
|
+
scope: VocabularyRegradeScope | undefined
|
|
1996
|
+
): readonly string[] =>
|
|
1997
|
+
(scope?.ignoredDirectories ?? DEFAULT_IGNORED_DIRECTORIES).filter(
|
|
1998
|
+
(directory) =>
|
|
1999
|
+
scope?.policyClassified?.some((policy) =>
|
|
2000
|
+
policy.paths.some((pattern) => pattern.split('/').includes(directory))
|
|
2001
|
+
)
|
|
2002
|
+
);
|
|
2003
|
+
|
|
2004
|
+
const vocabularyIgnoredDirectories = (
|
|
2005
|
+
scope: VocabularyRegradeScope | undefined
|
|
2006
|
+
): readonly string[] => {
|
|
2007
|
+
const opened = ignoredDirectoriesOpenedByPolicy(scope);
|
|
2008
|
+
return (scope?.ignoredDirectories ?? DEFAULT_IGNORED_DIRECTORIES).filter(
|
|
2009
|
+
(directory) => !opened.includes(directory)
|
|
2010
|
+
);
|
|
2011
|
+
};
|
|
2012
|
+
|
|
2013
|
+
const filterVocabularyCollection = (
|
|
2014
|
+
files: readonly { readonly absolutePath: string; readonly path: string }[],
|
|
2015
|
+
scope: VocabularyRegradeScope | undefined,
|
|
2016
|
+
sourceFilter: ((path: string) => boolean) | undefined
|
|
2017
|
+
): {
|
|
2018
|
+
readonly files: readonly {
|
|
2019
|
+
readonly absolutePath: string;
|
|
2020
|
+
readonly path: string;
|
|
2021
|
+
}[];
|
|
2022
|
+
readonly skipped: readonly SkippedSource[];
|
|
2023
|
+
} => {
|
|
2024
|
+
const opened = ignoredDirectoriesOpenedByPolicy(scope);
|
|
2025
|
+
const selected = files.filter((file) => {
|
|
2026
|
+
const openedDirectory = file.path
|
|
2027
|
+
.split('/')
|
|
2028
|
+
.some((segment) => opened.includes(segment));
|
|
2029
|
+
return (
|
|
2030
|
+
(!openedDirectory ||
|
|
2031
|
+
scopePolicyForPath(file.path, scope) !== undefined) &&
|
|
2032
|
+
(sourceFilter?.(file.path) ?? true)
|
|
2033
|
+
);
|
|
2034
|
+
});
|
|
2035
|
+
const selectedPaths = new Set(selected.map((file) => file.path));
|
|
2036
|
+
return {
|
|
2037
|
+
files: selected,
|
|
2038
|
+
skipped: files
|
|
2039
|
+
.filter((file) => !selectedPaths.has(file.path))
|
|
2040
|
+
.map((file) => ({
|
|
2041
|
+
path: file.path,
|
|
2042
|
+
reason:
|
|
2043
|
+
sourceFilter?.(file.path) === false
|
|
2044
|
+
? 'not-selected-source'
|
|
2045
|
+
: 'ignored-directory',
|
|
2046
|
+
})),
|
|
2047
|
+
};
|
|
2048
|
+
};
|
|
2049
|
+
|
|
2050
|
+
interface VocabularyRunInputs {
|
|
2051
|
+
readonly collectedRoot: string;
|
|
2052
|
+
readonly effectivePlan: VocabularyRegradePlan;
|
|
2053
|
+
readonly files: readonly SourceFile[];
|
|
2054
|
+
readonly skipped: readonly SkippedSource[];
|
|
2055
|
+
}
|
|
2056
|
+
|
|
2057
|
+
interface PrepareVocabularyRegradeRunParams {
|
|
2058
|
+
readonly includeEntries?: 'actionable' | 'all';
|
|
2059
|
+
readonly plan: VocabularyRegradePlan;
|
|
2060
|
+
readonly preserveInventory?: readonly VocabularyPreserveInventoryEntry[];
|
|
2061
|
+
readonly root: string;
|
|
2062
|
+
readonly sourceFilter?: (path: string) => boolean;
|
|
2063
|
+
readonly sourceKindForPath?: (path: string) => VocabularySourceKind;
|
|
2064
|
+
}
|
|
2065
|
+
|
|
2066
|
+
const snapshotPrepareVocabularyRegradeRunParams = (
|
|
2067
|
+
params: PrepareVocabularyRegradeRunParams
|
|
2068
|
+
): PrepareVocabularyRegradeRunParams => ({
|
|
2069
|
+
...(params.includeEntries === undefined
|
|
2070
|
+
? {}
|
|
2071
|
+
: { includeEntries: params.includeEntries }),
|
|
2072
|
+
plan: structuredClone(params.plan),
|
|
2073
|
+
...(params.preserveInventory === undefined
|
|
2074
|
+
? {}
|
|
2075
|
+
: { preserveInventory: structuredClone(params.preserveInventory) }),
|
|
2076
|
+
root: params.root,
|
|
2077
|
+
...(params.sourceFilter === undefined
|
|
2078
|
+
? {}
|
|
2079
|
+
: { sourceFilter: params.sourceFilter }),
|
|
2080
|
+
...(params.sourceKindForPath === undefined
|
|
2081
|
+
? {}
|
|
2082
|
+
: { sourceKindForPath: params.sourceKindForPath }),
|
|
2083
|
+
});
|
|
2084
|
+
|
|
2085
|
+
const collectVocabularyRunInputs = (
|
|
2086
|
+
params: PrepareVocabularyRegradeRunParams
|
|
2087
|
+
): VocabularyRunInputs | null => {
|
|
2088
|
+
const effectivePlan = effectivePlanForRun(
|
|
2089
|
+
params.plan,
|
|
2090
|
+
params.preserveInventory
|
|
2091
|
+
);
|
|
2092
|
+
const collected = collectDownstreamSources(params.root, {
|
|
2093
|
+
extensions: effectivePlan.scope?.extensions ?? VOCABULARY_SOURCE_EXTENSIONS,
|
|
2094
|
+
...(effectivePlan.scope?.exclude === undefined
|
|
2095
|
+
? {}
|
|
2096
|
+
: { exclude: effectivePlan.scope.exclude }),
|
|
2097
|
+
...(effectivePlan.scope?.include === undefined
|
|
2098
|
+
? {}
|
|
2099
|
+
: { include: effectivePlan.scope.include }),
|
|
2100
|
+
ignoredDirectories: vocabularyIgnoredDirectories(effectivePlan.scope),
|
|
2101
|
+
} satisfies DownstreamCollectionOptions);
|
|
2102
|
+
if (collected === null) {
|
|
2103
|
+
return null;
|
|
2104
|
+
}
|
|
2105
|
+
const filtered = filterVocabularyCollection(
|
|
2106
|
+
collected.files,
|
|
2107
|
+
effectivePlan.scope,
|
|
2108
|
+
params.sourceFilter
|
|
2109
|
+
);
|
|
2110
|
+
const { files, skipped } = readVocabularySourceFiles({
|
|
2111
|
+
...collected,
|
|
2112
|
+
files: filtered.files,
|
|
2113
|
+
skipped: [...collected.skipped, ...filtered.skipped],
|
|
2114
|
+
});
|
|
2115
|
+
return { collectedRoot: collected.root, effectivePlan, files, skipped };
|
|
2116
|
+
};
|
|
2117
|
+
|
|
2118
|
+
const vocabularyReportFromEvaluation = (params: {
|
|
2119
|
+
readonly applySummary?: RegradeApplySummary;
|
|
2120
|
+
readonly collectedRoot: string;
|
|
2121
|
+
readonly dryRunEvaluation: VocabularyEvaluation;
|
|
2122
|
+
readonly effectivePlan: VocabularyRegradePlan;
|
|
2123
|
+
readonly entrySelection: 'actionable' | 'all';
|
|
2124
|
+
readonly files: readonly SourceFile[];
|
|
2125
|
+
readonly plan: VocabularyRegradePlan;
|
|
2126
|
+
readonly reportEvaluation: VocabularyEvaluation;
|
|
2127
|
+
readonly skipped: readonly SkippedSource[];
|
|
2128
|
+
}): RegradeReport => {
|
|
2129
|
+
const reportEntries = params.reportEvaluation.entries;
|
|
2130
|
+
const actionableEntries = reportEntries.filter(
|
|
2131
|
+
(entry) => entry.outcome === 'rewrite' || entry.outcome === 'needs-review'
|
|
2132
|
+
);
|
|
2133
|
+
const reportSkippedByReason = skippedByReason(
|
|
2134
|
+
params.reportEvaluation.skipped
|
|
2135
|
+
);
|
|
2136
|
+
const report: RegradeReport = withScannedPaths(
|
|
2137
|
+
{
|
|
2138
|
+
entries:
|
|
2139
|
+
params.entrySelection === 'all' ? reportEntries : actionableEntries,
|
|
2140
|
+
matched: actionableEntries.length,
|
|
2141
|
+
review: reportEntries.filter((entry) => entry.outcome === 'needs-review')
|
|
2142
|
+
.length,
|
|
2143
|
+
rewritten: reportEntries.filter((entry) => entry.outcome === 'rewrite')
|
|
2144
|
+
.length,
|
|
2145
|
+
root: params.collectedRoot,
|
|
2146
|
+
run:
|
|
2147
|
+
params.applySummary === undefined
|
|
2148
|
+
? params.reportEvaluation.run
|
|
2149
|
+
: vocabularyRunWithAppliedOccurrences(
|
|
2150
|
+
params.reportEvaluation.run,
|
|
2151
|
+
params.dryRunEvaluation.run,
|
|
2152
|
+
params.applySummary
|
|
2153
|
+
),
|
|
2154
|
+
scan: buildRegradeScanSummary({
|
|
2155
|
+
matchedPaths: actionableEntries.map((entry) => entry.path),
|
|
2156
|
+
occurrencePaths: params.reportEvaluation.occurrences.map(
|
|
2157
|
+
(occurrence) => occurrence.path
|
|
2158
|
+
),
|
|
2159
|
+
scanned: params.reportEvaluation.scanned,
|
|
2160
|
+
skipped: params.reportEvaluation.skipped.length,
|
|
2161
|
+
skippedByReason: reportSkippedByReason,
|
|
2162
|
+
}),
|
|
2163
|
+
scanned: params.reportEvaluation.scanned,
|
|
2164
|
+
selectedClassIds: [
|
|
2165
|
+
params.plan.id ?? `vocabulary:${params.plan.from}->${params.plan.to}`,
|
|
2166
|
+
],
|
|
2167
|
+
skipped: params.reportEvaluation.skipped.length,
|
|
2168
|
+
skipsByReason: reportSkippedByReason,
|
|
2169
|
+
unknownClassIds: [],
|
|
2170
|
+
},
|
|
2171
|
+
params.files
|
|
2172
|
+
.filter((file) => includedByScope(file.path, params.effectivePlan.scope))
|
|
2173
|
+
.map((file) => file.path)
|
|
2174
|
+
);
|
|
2175
|
+
return params.applySummary === undefined
|
|
2176
|
+
? report
|
|
2177
|
+
: withApplySummary(report, params.applySummary);
|
|
2178
|
+
};
|
|
2179
|
+
|
|
2180
|
+
/**
|
|
2181
|
+
* In-memory vocabulary evaluation ready for freshness-checked apply.
|
|
2182
|
+
*
|
|
2183
|
+
* @example
|
|
2184
|
+
* ```ts
|
|
2185
|
+
* const prepared = prepareVocabularyRegradeRun({ identity, plan, root });
|
|
2186
|
+
* if (prepared.isOk() && prepared.value !== null) {
|
|
2187
|
+
* console.log(prepared.value.report.run?.gate.status);
|
|
2188
|
+
* }
|
|
2189
|
+
* ```
|
|
2190
|
+
*/
|
|
2191
|
+
export interface PreparedVocabularyRegradeRun {
|
|
2192
|
+
readonly identity: PreparedRegradeRunIdentity;
|
|
2193
|
+
readonly report: RegradeReport;
|
|
2194
|
+
readonly sourceStateHash: string;
|
|
2195
|
+
}
|
|
2196
|
+
|
|
2197
|
+
interface PreparedVocabularyRegradeRunState {
|
|
2198
|
+
readonly dryRunEvaluation: VocabularyEvaluation;
|
|
2199
|
+
readonly identity: PreparedRegradeRunIdentity;
|
|
2200
|
+
readonly inputs: VocabularyRunInputs;
|
|
2201
|
+
readonly params: PrepareVocabularyRegradeRunParams;
|
|
2202
|
+
readonly sourceStateHash: string;
|
|
2203
|
+
}
|
|
2204
|
+
|
|
2205
|
+
const preparedVocabularyRunStates = new WeakMap<
|
|
2206
|
+
PreparedVocabularyRegradeRun,
|
|
2207
|
+
PreparedVocabularyRegradeRunState
|
|
2208
|
+
>();
|
|
2209
|
+
|
|
2210
|
+
const vocabularySourceStateHash = (files: readonly SourceFile[]): string =>
|
|
2211
|
+
createHash('sha256')
|
|
2212
|
+
.update(
|
|
2213
|
+
JSON.stringify({
|
|
2214
|
+
sources: files
|
|
2215
|
+
.map((file) => ({ bytes: file.sourceBytes, path: file.path }))
|
|
2216
|
+
.toSorted((left, right) => {
|
|
2217
|
+
if (left.path < right.path) {
|
|
2218
|
+
return -1;
|
|
2219
|
+
}
|
|
2220
|
+
if (left.path > right.path) {
|
|
2221
|
+
return 1;
|
|
2222
|
+
}
|
|
2223
|
+
return 0;
|
|
2224
|
+
}),
|
|
2225
|
+
})
|
|
2226
|
+
)
|
|
2227
|
+
.digest('hex');
|
|
2228
|
+
|
|
2229
|
+
const unreadableVocabularyPaths = (
|
|
2230
|
+
skipped: readonly SkippedSource[]
|
|
2231
|
+
): readonly string[] =>
|
|
2232
|
+
skipped
|
|
2233
|
+
.filter(
|
|
2234
|
+
(entry) =>
|
|
2235
|
+
entry.reason === 'unreadable-file' ||
|
|
2236
|
+
entry.reason === 'unreadable-directory'
|
|
2237
|
+
)
|
|
2238
|
+
.map((entry) => entry.path);
|
|
2239
|
+
|
|
2240
|
+
const validateVocabularyPreparedIdentity = (
|
|
2241
|
+
expected: PreparedRegradeRunIdentity,
|
|
2242
|
+
actual: PreparedRegradeRunIdentity
|
|
2243
|
+
): Result<void, ValidationError> => {
|
|
2244
|
+
for (const field of [
|
|
2245
|
+
'planContentHash',
|
|
2246
|
+
'policyHash',
|
|
2247
|
+
'scopeHash',
|
|
2248
|
+
'lockStateHash',
|
|
2249
|
+
'toolVersion',
|
|
2250
|
+
] as const) {
|
|
2251
|
+
if (expected[field] !== actual[field]) {
|
|
2252
|
+
return Result.err(
|
|
2253
|
+
new ValidationError(
|
|
2254
|
+
`Prepared vocabulary Regrade identity field \`${field}\` is stale.`,
|
|
2255
|
+
{
|
|
2256
|
+
context: {
|
|
2257
|
+
actual: actual[field],
|
|
2258
|
+
expected: expected[field],
|
|
2259
|
+
field,
|
|
2260
|
+
},
|
|
2261
|
+
}
|
|
2262
|
+
)
|
|
2263
|
+
);
|
|
2264
|
+
}
|
|
2265
|
+
}
|
|
2266
|
+
return Result.ok();
|
|
2267
|
+
};
|
|
2268
|
+
|
|
2269
|
+
/**
|
|
2270
|
+
* Classify a vocabulary Regrade run once and retain it for checked apply.
|
|
2271
|
+
*
|
|
2272
|
+
* @example
|
|
2273
|
+
* ```ts
|
|
2274
|
+
* const prepared = prepareVocabularyRegradeRun({ identity, plan, root });
|
|
2275
|
+
* if (prepared.isErr()) throw prepared.error;
|
|
2276
|
+
* ```
|
|
2277
|
+
*/
|
|
2278
|
+
export const prepareVocabularyRegradeRun = (
|
|
2279
|
+
params: PrepareVocabularyRegradeRunParams & {
|
|
2280
|
+
readonly identity: PreparedRegradeRunIdentity;
|
|
2281
|
+
}
|
|
2282
|
+
): Result<PreparedVocabularyRegradeRun | null, ValidationError> => {
|
|
2283
|
+
const preparedParams = snapshotPrepareVocabularyRegradeRunParams(params);
|
|
2284
|
+
const planValidation = validateVocabularyPlan(preparedParams.plan);
|
|
2285
|
+
if (planValidation.isErr()) {
|
|
2286
|
+
return planValidation;
|
|
2287
|
+
}
|
|
2288
|
+
const inventoryValidation = validatePreserveInventory(
|
|
2289
|
+
preparedParams.preserveInventory
|
|
2290
|
+
);
|
|
2291
|
+
if (inventoryValidation.isErr()) {
|
|
2292
|
+
return inventoryValidation;
|
|
2293
|
+
}
|
|
2294
|
+
const inputs = collectVocabularyRunInputs(preparedParams);
|
|
2295
|
+
if (inputs === null) {
|
|
2296
|
+
return Result.ok(null);
|
|
2297
|
+
}
|
|
2298
|
+
const unreadable = unreadableVocabularyPaths(inputs.skipped);
|
|
2299
|
+
if (unreadable.length > 0) {
|
|
2300
|
+
return Result.err(
|
|
2301
|
+
new ValidationError(
|
|
2302
|
+
'Prepared vocabulary Regrade sources must all be readable.',
|
|
2303
|
+
{ context: { paths: unreadable } }
|
|
2304
|
+
)
|
|
2305
|
+
);
|
|
2306
|
+
}
|
|
2307
|
+
const dryRunEvaluation = buildRunVocabularyEvaluation({
|
|
2308
|
+
apply: false,
|
|
2309
|
+
effectivePlan: inputs.effectivePlan,
|
|
2310
|
+
files: inputs.files,
|
|
2311
|
+
plan: preparedParams.plan,
|
|
2312
|
+
preserveInventory: preparedParams.preserveInventory,
|
|
2313
|
+
root: preparedParams.root,
|
|
2314
|
+
skipped: inputs.skipped,
|
|
2315
|
+
sourceKindForPath: preparedParams.sourceKindForPath,
|
|
2316
|
+
});
|
|
2317
|
+
const report = vocabularyReportFromEvaluation({
|
|
2318
|
+
collectedRoot: inputs.collectedRoot,
|
|
2319
|
+
dryRunEvaluation,
|
|
2320
|
+
effectivePlan: inputs.effectivePlan,
|
|
2321
|
+
entrySelection: preparedParams.includeEntries ?? 'actionable',
|
|
2322
|
+
files: inputs.files,
|
|
2323
|
+
plan: preparedParams.plan,
|
|
2324
|
+
reportEvaluation: dryRunEvaluation,
|
|
2325
|
+
skipped: inputs.skipped,
|
|
2326
|
+
});
|
|
2327
|
+
const prepared: PreparedVocabularyRegradeRun = {
|
|
2328
|
+
identity: { ...params.identity },
|
|
2329
|
+
report: cloneVocabularyReport(report),
|
|
2330
|
+
sourceStateHash: vocabularySourceStateHash(inputs.files),
|
|
2331
|
+
};
|
|
2332
|
+
preparedVocabularyRunStates.set(prepared, {
|
|
2333
|
+
dryRunEvaluation,
|
|
2334
|
+
identity: { ...params.identity },
|
|
2335
|
+
inputs,
|
|
2336
|
+
params: preparedParams,
|
|
2337
|
+
sourceStateHash: prepared.sourceStateHash,
|
|
2338
|
+
});
|
|
2339
|
+
return Result.ok(prepared);
|
|
2340
|
+
};
|
|
2341
|
+
|
|
2342
|
+
/**
|
|
2343
|
+
* Apply an in-memory vocabulary evaluation after identity and source checks.
|
|
2344
|
+
*
|
|
2345
|
+
* @example
|
|
2346
|
+
* ```ts
|
|
2347
|
+
* const applied = applyPreparedVocabularyRegradeRun(prepared, identity);
|
|
2348
|
+
* if (applied.isErr()) throw applied.error;
|
|
2349
|
+
* ```
|
|
2350
|
+
*/
|
|
2351
|
+
export const applyPreparedVocabularyRegradeRun = (
|
|
2352
|
+
prepared: PreparedVocabularyRegradeRun,
|
|
2353
|
+
identity: PreparedRegradeRunIdentity
|
|
2354
|
+
): Result<RegradeReport, InternalError | ValidationError> => {
|
|
2355
|
+
const state = preparedVocabularyRunStates.get(prepared);
|
|
2356
|
+
if (state === undefined) {
|
|
2357
|
+
return Result.err(
|
|
2358
|
+
new ValidationError(
|
|
2359
|
+
'Prepared vocabulary Regrade run is not the original in-memory evaluation.'
|
|
2360
|
+
)
|
|
2361
|
+
);
|
|
2362
|
+
}
|
|
2363
|
+
const identityValidation = validateVocabularyPreparedIdentity(
|
|
2364
|
+
state.identity,
|
|
2365
|
+
identity
|
|
2366
|
+
);
|
|
2367
|
+
if (identityValidation.isErr()) {
|
|
2368
|
+
return identityValidation;
|
|
2369
|
+
}
|
|
2370
|
+
const current = collectVocabularyRunInputs(state.params);
|
|
2371
|
+
if (current === null) {
|
|
2372
|
+
return Result.err(
|
|
2373
|
+
new ValidationError(
|
|
2374
|
+
'Prepared vocabulary Regrade root is no longer readable.'
|
|
2375
|
+
)
|
|
2376
|
+
);
|
|
2377
|
+
}
|
|
2378
|
+
const unreadable = unreadableVocabularyPaths(current.skipped);
|
|
2379
|
+
if (unreadable.length > 0) {
|
|
2380
|
+
return Result.err(
|
|
2381
|
+
new ValidationError(
|
|
2382
|
+
'Prepared vocabulary Regrade sources are no longer readable.',
|
|
2383
|
+
{ context: { paths: unreadable } }
|
|
2384
|
+
)
|
|
2385
|
+
);
|
|
2386
|
+
}
|
|
2387
|
+
const currentSourceStateHash = vocabularySourceStateHash(current.files);
|
|
2388
|
+
if (currentSourceStateHash !== state.sourceStateHash) {
|
|
2389
|
+
return Result.err(
|
|
2390
|
+
new ValidationError(
|
|
2391
|
+
'Prepared vocabulary Regrade source state is stale.',
|
|
2392
|
+
{
|
|
2393
|
+
context: {
|
|
2394
|
+
actual: currentSourceStateHash,
|
|
2395
|
+
expected: state.sourceStateHash,
|
|
2396
|
+
},
|
|
2397
|
+
}
|
|
2398
|
+
)
|
|
2399
|
+
);
|
|
2400
|
+
}
|
|
2401
|
+
const applyResult = applyVocabularyEvaluation(
|
|
2402
|
+
state.inputs.files,
|
|
2403
|
+
state.dryRunEvaluation
|
|
2404
|
+
);
|
|
2405
|
+
if (applyResult.isErr()) {
|
|
2406
|
+
return applyResult;
|
|
2407
|
+
}
|
|
2408
|
+
const appliedFiles = state.inputs.files.map((file) => ({
|
|
2409
|
+
...file,
|
|
2410
|
+
source: readFileSync(file.absolutePath, 'utf8'),
|
|
2411
|
+
}));
|
|
2412
|
+
const postApplyEvaluation = buildRunVocabularyEvaluation({
|
|
2413
|
+
apply: true,
|
|
2414
|
+
effectivePlan: state.inputs.effectivePlan,
|
|
2415
|
+
files: appliedFiles,
|
|
2416
|
+
plan: state.params.plan,
|
|
2417
|
+
preserveInventory: state.params.preserveInventory,
|
|
2418
|
+
root: state.params.root,
|
|
2419
|
+
skipped: state.inputs.skipped,
|
|
2420
|
+
sourceKindForPath: state.params.sourceKindForPath,
|
|
2421
|
+
});
|
|
2422
|
+
return Result.ok(
|
|
2423
|
+
vocabularyReportFromEvaluation({
|
|
2424
|
+
applySummary: applyResult.value,
|
|
2425
|
+
collectedRoot: state.inputs.collectedRoot,
|
|
2426
|
+
dryRunEvaluation: state.dryRunEvaluation,
|
|
2427
|
+
effectivePlan: state.inputs.effectivePlan,
|
|
2428
|
+
entrySelection: state.params.includeEntries ?? 'actionable',
|
|
2429
|
+
files: appliedFiles,
|
|
2430
|
+
plan: state.params.plan,
|
|
2431
|
+
reportEvaluation: postApplyEvaluation,
|
|
2432
|
+
skipped: state.inputs.skipped,
|
|
2433
|
+
})
|
|
2434
|
+
);
|
|
2435
|
+
};
|
|
2436
|
+
|
|
2437
|
+
export const runVocabularyRegrade = (params: {
|
|
2438
|
+
readonly apply?: boolean;
|
|
2439
|
+
readonly includeEntries?: 'actionable' | 'all';
|
|
2440
|
+
readonly plan: VocabularyRegradePlan;
|
|
2441
|
+
readonly preserveInventory?: readonly VocabularyPreserveInventoryEntry[];
|
|
2442
|
+
readonly root: string;
|
|
2443
|
+
readonly sourceFilter?: (path: string) => boolean;
|
|
2444
|
+
readonly sourceKindForPath?: (path: string) => VocabularySourceKind;
|
|
2445
|
+
}): Result<RegradeReport | null, InternalError | ValidationError> => {
|
|
2446
|
+
const planValidation = validateVocabularyPlan(params.plan);
|
|
2447
|
+
if (planValidation.isErr()) {
|
|
2448
|
+
return planValidation;
|
|
2449
|
+
}
|
|
2450
|
+
const inventoryValidation = validatePreserveInventory(
|
|
2451
|
+
params.preserveInventory
|
|
2452
|
+
);
|
|
2453
|
+
if (inventoryValidation.isErr()) {
|
|
2454
|
+
return inventoryValidation;
|
|
2455
|
+
}
|
|
2456
|
+
|
|
2457
|
+
const inputs = collectVocabularyRunInputs(params);
|
|
2458
|
+
if (inputs === null) {
|
|
2459
|
+
return Result.ok(null);
|
|
2460
|
+
}
|
|
2461
|
+
const { collectedRoot, effectivePlan, files, skipped } = inputs;
|
|
2462
|
+
|
|
2463
|
+
const dryRunEffectiveEvaluation = buildRunVocabularyEvaluation({
|
|
2464
|
+
apply: false,
|
|
2465
|
+
effectivePlan,
|
|
2466
|
+
files,
|
|
2467
|
+
plan: params.plan,
|
|
2468
|
+
preserveInventory: params.preserveInventory,
|
|
2469
|
+
root: params.root,
|
|
2470
|
+
skipped,
|
|
2471
|
+
sourceKindForPath: params.sourceKindForPath,
|
|
2472
|
+
});
|
|
2473
|
+
let reportEvaluation = dryRunEffectiveEvaluation;
|
|
2474
|
+
let applySummary: RegradeApplySummary | undefined;
|
|
2475
|
+
|
|
2476
|
+
if (params.apply === true) {
|
|
2477
|
+
const applyResult = applyVocabularyEvaluation(
|
|
2478
|
+
files,
|
|
2479
|
+
dryRunEffectiveEvaluation
|
|
2480
|
+
);
|
|
2481
|
+
if (applyResult.isErr()) {
|
|
2482
|
+
return applyResult;
|
|
2483
|
+
}
|
|
2484
|
+
applySummary = applyResult.value;
|
|
2485
|
+
const appliedFiles = files.map((file) => ({
|
|
2486
|
+
...file,
|
|
2487
|
+
source: readFileSync(file.absolutePath, 'utf8'),
|
|
2488
|
+
}));
|
|
2489
|
+
reportEvaluation = buildRunVocabularyEvaluation({
|
|
2490
|
+
apply: true,
|
|
2491
|
+
effectivePlan,
|
|
2492
|
+
files: appliedFiles,
|
|
2493
|
+
plan: params.plan,
|
|
2494
|
+
preserveInventory: params.preserveInventory,
|
|
2495
|
+
root: params.root,
|
|
2496
|
+
skipped,
|
|
2497
|
+
sourceKindForPath: params.sourceKindForPath,
|
|
2498
|
+
});
|
|
2499
|
+
}
|
|
2500
|
+
|
|
2501
|
+
return Result.ok(
|
|
2502
|
+
vocabularyReportFromEvaluation({
|
|
2503
|
+
...(applySummary === undefined ? {} : { applySummary }),
|
|
2504
|
+
collectedRoot,
|
|
2505
|
+
dryRunEvaluation: dryRunEffectiveEvaluation,
|
|
2506
|
+
effectivePlan,
|
|
2507
|
+
entrySelection: params.includeEntries ?? 'actionable',
|
|
2508
|
+
files,
|
|
2509
|
+
plan: params.plan,
|
|
2510
|
+
reportEvaluation,
|
|
2511
|
+
skipped,
|
|
2512
|
+
})
|
|
2513
|
+
);
|
|
2514
|
+
};
|
|
2515
|
+
|
|
2516
|
+
const vocabularyPreserveRuleSchema = z.object({
|
|
2517
|
+
disposition: z
|
|
2518
|
+
.enum(vocabularyDispositionValues)
|
|
2519
|
+
.optional()
|
|
2520
|
+
.describe('Classification for occurrences preserved by this rule'),
|
|
2521
|
+
forms: z
|
|
2522
|
+
.array(z.string().min(1))
|
|
2523
|
+
.optional()
|
|
2524
|
+
.describe('Matched forms this preserve rule applies to'),
|
|
2525
|
+
paths: z
|
|
2526
|
+
.array(z.string())
|
|
2527
|
+
.optional()
|
|
2528
|
+
.describe('Root-relative path patterns where the preserve rule applies'),
|
|
2529
|
+
pattern: z.string().describe('Regex or literal pattern to preserve'),
|
|
2530
|
+
reason: z.string().optional().describe('Why this form is preserved'),
|
|
2531
|
+
});
|
|
2532
|
+
|
|
2533
|
+
const vocabularyScopePolicySchema = z.object({
|
|
2534
|
+
disposition: z
|
|
2535
|
+
.enum(vocabularyDispositionValues)
|
|
2536
|
+
.describe('Occurrence disposition assigned within this protected scope'),
|
|
2537
|
+
expectMatches: z
|
|
2538
|
+
.boolean()
|
|
2539
|
+
.optional()
|
|
2540
|
+
.describe('Require this policy scope to contribute occurrence evidence'),
|
|
2541
|
+
paths: z
|
|
2542
|
+
.array(z.string().min(1))
|
|
2543
|
+
.min(1)
|
|
2544
|
+
.describe('Root-relative protected path patterns'),
|
|
2545
|
+
reason: z.string().min(1).describe('Why matching files are classified'),
|
|
2546
|
+
});
|
|
2547
|
+
|
|
2548
|
+
const vocabularyPreserveInventoryEntrySchema =
|
|
2549
|
+
vocabularyPreserveRuleSchema.extend({
|
|
2550
|
+
evidence: z
|
|
2551
|
+
.array(z.string().min(1))
|
|
2552
|
+
.describe('Graph or surface facts that justify this derived preserve'),
|
|
2553
|
+
source: z.literal('derived-live-api').describe('Derived inventory source'),
|
|
2554
|
+
});
|
|
2555
|
+
|
|
2556
|
+
const vocabularyDispositionCountSchema = z.object(
|
|
2557
|
+
Object.fromEntries(
|
|
2558
|
+
vocabularyDispositionValues.map((disposition) => [
|
|
2559
|
+
disposition,
|
|
2560
|
+
z.number().optional(),
|
|
2561
|
+
])
|
|
2562
|
+
) as Record<VocabularyDisposition, z.ZodOptional<z.ZodNumber>>
|
|
2563
|
+
);
|
|
2564
|
+
|
|
2565
|
+
const vocabularyRegradeScopeSchema = z.object({
|
|
2566
|
+
exclude: z
|
|
2567
|
+
.array(z.string())
|
|
2568
|
+
.optional()
|
|
2569
|
+
.describe('Root-relative path patterns to exclude from this regrade'),
|
|
2570
|
+
extensions: z
|
|
2571
|
+
.array(z.string())
|
|
2572
|
+
.optional()
|
|
2573
|
+
.describe('Source file extensions to scan for this regrade'),
|
|
2574
|
+
ignoredDirectories: z
|
|
2575
|
+
.array(z.string())
|
|
2576
|
+
.optional()
|
|
2577
|
+
.describe(
|
|
2578
|
+
'Deprecated compatibility override for legacy plans. Use exclude globs for new plans.'
|
|
2579
|
+
),
|
|
2580
|
+
include: z
|
|
2581
|
+
.array(z.string())
|
|
2582
|
+
.optional()
|
|
2583
|
+
.describe('Root-relative path patterns to include in this regrade'),
|
|
2584
|
+
policyClassified: z
|
|
2585
|
+
.array(vocabularyScopePolicySchema)
|
|
2586
|
+
.optional()
|
|
2587
|
+
.describe(
|
|
2588
|
+
'Protected paths that remain scanned and counted but are never rewritten by default'
|
|
2589
|
+
),
|
|
2590
|
+
teachingSurfaces: z
|
|
2591
|
+
.array(z.string().min(1))
|
|
2592
|
+
.optional()
|
|
2593
|
+
.describe('Expected current teaching-surface path patterns'),
|
|
2594
|
+
});
|
|
2595
|
+
|
|
2596
|
+
export const vocabularyRegradePlanSchema = z.object({
|
|
2597
|
+
caseSensitive: z
|
|
2598
|
+
.boolean()
|
|
2599
|
+
.optional()
|
|
2600
|
+
.describe('Whether source form scanning preserves case exactly'),
|
|
2601
|
+
deferForms: z
|
|
2602
|
+
.array(z.string().min(1))
|
|
2603
|
+
.optional()
|
|
2604
|
+
.describe('Known forms that must be inventoried for review, not rewritten'),
|
|
2605
|
+
fileRenames: z
|
|
2606
|
+
.array(
|
|
2607
|
+
z.object({
|
|
2608
|
+
from: z.string().min(1).describe('Root-relative source file path'),
|
|
2609
|
+
to: z.string().min(1).describe('Root-relative target file path'),
|
|
2610
|
+
})
|
|
2611
|
+
)
|
|
2612
|
+
.optional()
|
|
2613
|
+
.describe('Governed file moves whose references are derived from scope'),
|
|
2614
|
+
from: z.string().min(1).describe('Source vocabulary term or phrase'),
|
|
2615
|
+
id: z.string().optional().describe('Stable authored regrade plan id'),
|
|
2616
|
+
intent: z.string().optional().describe('Human-authored migration intent'),
|
|
2617
|
+
kind: z.literal('vocabulary').describe('Regrade plan kind'),
|
|
2618
|
+
overrides: z
|
|
2619
|
+
.record(z.string().min(1), z.string().min(1))
|
|
2620
|
+
.optional()
|
|
2621
|
+
.describe('Explicit source-form to target-form mappings'),
|
|
2622
|
+
preserve: z
|
|
2623
|
+
.array(vocabularyPreserveRuleSchema)
|
|
2624
|
+
.optional()
|
|
2625
|
+
.describe('Forms or contexts that are intentionally preserved'),
|
|
2626
|
+
scope: vocabularyRegradeScopeSchema
|
|
2627
|
+
.optional()
|
|
2628
|
+
.describe('Source scope for this regrade plan'),
|
|
2629
|
+
to: z.string().min(1).describe('Target vocabulary term or phrase'),
|
|
2630
|
+
});
|
|
2631
|
+
|
|
2632
|
+
export const vocabularyRegradeRunOutput = z.object({
|
|
2633
|
+
ledger: z
|
|
2634
|
+
.object({
|
|
2635
|
+
cycle: z.number().describe('Observed regrade run cycle'),
|
|
2636
|
+
forms: z
|
|
2637
|
+
.record(
|
|
2638
|
+
z.string(),
|
|
2639
|
+
z.enum(['applied', 'deferred', 'modified', 'skipped'])
|
|
2640
|
+
)
|
|
2641
|
+
.describe('Observed per-form triage verdicts for this run'),
|
|
2642
|
+
occurrences: z
|
|
2643
|
+
.array(
|
|
2644
|
+
z.object({
|
|
2645
|
+
column: z.number().describe('One-based source column'),
|
|
2646
|
+
context: z.string().describe('Source-line context'),
|
|
2647
|
+
disposition: z
|
|
2648
|
+
.enum(vocabularyDispositionValues)
|
|
2649
|
+
.describe('Occurrence-level classification beside the verdict'),
|
|
2650
|
+
end: z.number().describe('Source end offset'),
|
|
2651
|
+
form: z.string().describe('Matched vocabulary form'),
|
|
2652
|
+
line: z.number().describe('One-based source line'),
|
|
2653
|
+
path: z.string().describe('Root-relative POSIX path'),
|
|
2654
|
+
reason: z.string().describe('Why the occurrence got this verdict'),
|
|
2655
|
+
replacement: z
|
|
2656
|
+
.string()
|
|
2657
|
+
.optional()
|
|
2658
|
+
.describe('Replacement text for modified verdicts'),
|
|
2659
|
+
scopeTier: z
|
|
2660
|
+
.enum(['in-scope', 'policy-classified'])
|
|
2661
|
+
.describe('Three-tier scope classification for this occurrence'),
|
|
2662
|
+
sourceKind: z
|
|
2663
|
+
.enum(['source-comment', 'tsdoc'])
|
|
2664
|
+
.optional()
|
|
2665
|
+
.describe('Exact source-comment construct for comment inventory'),
|
|
2666
|
+
start: z.number().describe('Source start offset'),
|
|
2667
|
+
verdict: z
|
|
2668
|
+
.enum(['applied', 'deferred', 'modified', 'skipped'])
|
|
2669
|
+
.describe('Occurrence-level verdict'),
|
|
2670
|
+
})
|
|
2671
|
+
)
|
|
2672
|
+
.describe('Observed occurrence-level ledger for this run'),
|
|
2673
|
+
})
|
|
2674
|
+
.describe('Observed run ledger'),
|
|
2675
|
+
plan: vocabularyRegradePlanSchema.describe('Authored regrade plan'),
|
|
2676
|
+
preserveInventory: z
|
|
2677
|
+
.array(vocabularyPreserveInventoryEntrySchema)
|
|
2678
|
+
.optional()
|
|
2679
|
+
.describe(
|
|
2680
|
+
'Derived live-API preserve inventory applied at run time without changing the authored plan'
|
|
2681
|
+
),
|
|
2682
|
+
report: z
|
|
2683
|
+
.object({
|
|
2684
|
+
applied: z.number().describe('Modified occurrences applied to disk'),
|
|
2685
|
+
deferred: z.number().describe('Deferred occurrence count'),
|
|
2686
|
+
dispositions: z
|
|
2687
|
+
.object(vocabularyDispositionCountSchema.shape)
|
|
2688
|
+
.describe('Occurrence counts grouped by disposition'),
|
|
2689
|
+
fileRenames: z
|
|
2690
|
+
.array(
|
|
2691
|
+
z.object({
|
|
2692
|
+
deferred: z.number(),
|
|
2693
|
+
from: z.string(),
|
|
2694
|
+
historical: z.number(),
|
|
2695
|
+
preserved: z.number(),
|
|
2696
|
+
rewritten: z.number(),
|
|
2697
|
+
skipped: z.number(),
|
|
2698
|
+
to: z.string(),
|
|
2699
|
+
})
|
|
2700
|
+
)
|
|
2701
|
+
.optional()
|
|
2702
|
+
.describe('Governed file moves and derived reference outcomes'),
|
|
2703
|
+
filesChanged: z.number().describe('Distinct files changed on disk'),
|
|
2704
|
+
gate: z
|
|
2705
|
+
.object({
|
|
2706
|
+
reasons: z.array(z.string()).describe('Open-gate reasons'),
|
|
2707
|
+
remaining: z.number().describe('Unresolved occurrence count'),
|
|
2708
|
+
remainingByDisposition: z
|
|
2709
|
+
.object(vocabularyDispositionCountSchema.shape)
|
|
2710
|
+
.describe('Unresolved occurrence counts grouped by disposition'),
|
|
2711
|
+
status: z
|
|
2712
|
+
.enum(['green', 'open'])
|
|
2713
|
+
.describe('Whether the run is complete'),
|
|
2714
|
+
})
|
|
2715
|
+
.describe('Completion gate derived from the run ledger'),
|
|
2716
|
+
modified: z.number().describe('Modified occurrence count'),
|
|
2717
|
+
open: z
|
|
2718
|
+
.number()
|
|
2719
|
+
.describe(
|
|
2720
|
+
'Deferred or unapplied modified occurrences holding the gate open'
|
|
2721
|
+
),
|
|
2722
|
+
scopeTiers: z
|
|
2723
|
+
.object({
|
|
2724
|
+
'in-scope': z.number(),
|
|
2725
|
+
'policy-classified': z.number(),
|
|
2726
|
+
})
|
|
2727
|
+
.describe('Occurrence counts grouped by scope tier'),
|
|
2728
|
+
skipped: z.number().describe('Skipped occurrence count'),
|
|
2729
|
+
teachingSurfaces: z
|
|
2730
|
+
.object({
|
|
2731
|
+
expected: z.array(z.string()),
|
|
2732
|
+
missing: z.array(z.string()),
|
|
2733
|
+
touched: z.array(z.string()),
|
|
2734
|
+
})
|
|
2735
|
+
.describe('Expected and observed current teaching surfaces'),
|
|
2736
|
+
})
|
|
2737
|
+
.describe('Derived run report'),
|
|
2738
|
+
});
|
|
2739
|
+
|
|
2740
|
+
const vocabularyTransitionRecordEnvironmentSchema = z
|
|
2741
|
+
.object({
|
|
2742
|
+
commitSha: z.string().optional(),
|
|
2743
|
+
engineVersion: z.string().optional(),
|
|
2744
|
+
graphHash: z.string().optional(),
|
|
2745
|
+
root: z.string(),
|
|
2746
|
+
})
|
|
2747
|
+
.strict();
|
|
2748
|
+
|
|
2749
|
+
const vocabularyTransitionRecordReportSchema = z
|
|
2750
|
+
.object({
|
|
2751
|
+
apply: z.unknown().optional(),
|
|
2752
|
+
entries: z.array(z.unknown()),
|
|
2753
|
+
matched: z.number(),
|
|
2754
|
+
review: z.number(),
|
|
2755
|
+
rewritten: z.number(),
|
|
2756
|
+
root: z.string(),
|
|
2757
|
+
run: vocabularyRegradeRunOutput,
|
|
2758
|
+
scan: z.unknown(),
|
|
2759
|
+
scanned: z.number(),
|
|
2760
|
+
selectedClassIds: z.array(z.string()),
|
|
2761
|
+
skipped: z.number(),
|
|
2762
|
+
skipsByReason: z.record(z.string(), z.number()),
|
|
2763
|
+
unknownClassIds: z.array(z.string()),
|
|
2764
|
+
})
|
|
2765
|
+
.strict();
|
|
2766
|
+
|
|
2767
|
+
const normalizeTransitionRecordPath = (path: string): string =>
|
|
2768
|
+
normalize(path).replaceAll('\\', '/');
|
|
2769
|
+
|
|
2770
|
+
const isSafeRootRelativeRecordPath = (path: string): boolean => {
|
|
2771
|
+
const normalized = normalizeTransitionRecordPath(path);
|
|
2772
|
+
return (
|
|
2773
|
+
normalized.length > 0 &&
|
|
2774
|
+
!isAbsolute(normalized) &&
|
|
2775
|
+
normalized !== '..' &&
|
|
2776
|
+
!normalized.startsWith('../')
|
|
2777
|
+
);
|
|
2778
|
+
};
|
|
2779
|
+
|
|
2780
|
+
export const vocabularyTransitionRecordSchema = z
|
|
2781
|
+
.object({
|
|
2782
|
+
environment: vocabularyTransitionRecordEnvironmentSchema,
|
|
2783
|
+
kind: z.literal('vocabulary-transition-record'),
|
|
2784
|
+
recordPath: z.string().refine(isSafeRootRelativeRecordPath),
|
|
2785
|
+
report: vocabularyTransitionRecordReportSchema,
|
|
2786
|
+
schemaVersion: z.literal(VOCABULARY_TRANSITION_RECORD_SCHEMA_VERSION),
|
|
2787
|
+
transition: z
|
|
2788
|
+
.object({
|
|
2789
|
+
from: z.string(),
|
|
2790
|
+
id: z.string(),
|
|
2791
|
+
to: z.string(),
|
|
2792
|
+
})
|
|
2793
|
+
.strict(),
|
|
2794
|
+
})
|
|
2795
|
+
.strict();
|
|
2796
|
+
|
|
2797
|
+
const normalizeLegacyTransitionRecordScopeEvidence = (
|
|
2798
|
+
value: unknown
|
|
2799
|
+
): unknown => {
|
|
2800
|
+
if (
|
|
2801
|
+
!isPlainObject(value) ||
|
|
2802
|
+
value['schemaVersion'] !== VOCABULARY_TRANSITION_RECORD_SCHEMA_VERSION
|
|
2803
|
+
) {
|
|
2804
|
+
return value;
|
|
2805
|
+
}
|
|
2806
|
+
const { report } = value;
|
|
2807
|
+
if (!isPlainObject(report)) {
|
|
2808
|
+
return value;
|
|
2809
|
+
}
|
|
2810
|
+
const { run } = report;
|
|
2811
|
+
if (!isPlainObject(run)) {
|
|
2812
|
+
return value;
|
|
2813
|
+
}
|
|
2814
|
+
const { ledger, report: runReport } = run;
|
|
2815
|
+
if (
|
|
2816
|
+
!isPlainObject(ledger) ||
|
|
2817
|
+
!Array.isArray(ledger['occurrences']) ||
|
|
2818
|
+
!isPlainObject(runReport)
|
|
2819
|
+
) {
|
|
2820
|
+
return value;
|
|
2821
|
+
}
|
|
2822
|
+
const plan = vocabularyRegradePlanSchema.safeParse(run['plan']);
|
|
2823
|
+
if (!plan.success) {
|
|
2824
|
+
return value;
|
|
2825
|
+
}
|
|
2826
|
+
const vocabularyPlan = plan.data as VocabularyRegradePlan;
|
|
2827
|
+
const occurrences = ledger['occurrences'].map((occurrence) =>
|
|
2828
|
+
isPlainObject(occurrence) &&
|
|
2829
|
+
typeof occurrence['path'] === 'string' &&
|
|
2830
|
+
occurrence['scopeTier'] === undefined
|
|
2831
|
+
? {
|
|
2832
|
+
...occurrence,
|
|
2833
|
+
scopeTier:
|
|
2834
|
+
scopePolicyForPath(occurrence['path'], vocabularyPlan.scope) ===
|
|
2835
|
+
undefined
|
|
2836
|
+
? 'in-scope'
|
|
2837
|
+
: 'policy-classified',
|
|
2838
|
+
}
|
|
2839
|
+
: occurrence
|
|
2840
|
+
);
|
|
2841
|
+
const scopeEvidence = vocabularyScopeEvidence(
|
|
2842
|
+
vocabularyPlan,
|
|
2843
|
+
occurrences.filter(
|
|
2844
|
+
(occurrence) =>
|
|
2845
|
+
isPlainObject(occurrence) &&
|
|
2846
|
+
typeof occurrence['path'] === 'string' &&
|
|
2847
|
+
(occurrence['scopeTier'] === 'in-scope' ||
|
|
2848
|
+
occurrence['scopeTier'] === 'policy-classified')
|
|
2849
|
+
) as unknown as readonly VocabularyOccurrence[]
|
|
2850
|
+
);
|
|
2851
|
+
return {
|
|
2852
|
+
...value,
|
|
2853
|
+
report: {
|
|
2854
|
+
...report,
|
|
2855
|
+
run: {
|
|
2856
|
+
...run,
|
|
2857
|
+
ledger: { ...ledger, occurrences },
|
|
2858
|
+
report: {
|
|
2859
|
+
...runReport,
|
|
2860
|
+
...(runReport['scopeTiers'] === undefined
|
|
2861
|
+
? { scopeTiers: scopeEvidence.scopeTiers }
|
|
2862
|
+
: {}),
|
|
2863
|
+
...(runReport['teachingSurfaces'] === undefined
|
|
2864
|
+
? { teachingSurfaces: scopeEvidence.teachingSurfaces }
|
|
2865
|
+
: {}),
|
|
2866
|
+
},
|
|
2867
|
+
},
|
|
2868
|
+
},
|
|
2869
|
+
};
|
|
2870
|
+
};
|
|
2871
|
+
|
|
2872
|
+
const transitionRecordSlug = (run: VocabularyRegradeRun): string =>
|
|
2873
|
+
`${run.plan.from}-to-${run.plan.to}`
|
|
2874
|
+
.toLowerCase()
|
|
2875
|
+
.replaceAll(/[^a-z0-9]+/g, '-')
|
|
2876
|
+
.replaceAll(/^-|-$/g, '');
|
|
2877
|
+
|
|
2878
|
+
const stableJson = (value: unknown): string =>
|
|
2879
|
+
JSON.stringify(value, (_key, nested) => {
|
|
2880
|
+
if (
|
|
2881
|
+
nested === null ||
|
|
2882
|
+
typeof nested !== 'object' ||
|
|
2883
|
+
Array.isArray(nested)
|
|
2884
|
+
) {
|
|
2885
|
+
return nested as unknown;
|
|
2886
|
+
}
|
|
2887
|
+
return Object.fromEntries(
|
|
2888
|
+
Object.entries(nested as Record<string, unknown>).toSorted(
|
|
2889
|
+
([left], [right]) => left.localeCompare(right)
|
|
2890
|
+
)
|
|
2891
|
+
);
|
|
2892
|
+
});
|
|
2893
|
+
|
|
2894
|
+
const shortHashForRun = (
|
|
2895
|
+
run: VocabularyRegradeRun,
|
|
2896
|
+
environment?: Partial<VocabularyTransitionRecordEnvironment>
|
|
2897
|
+
): string => {
|
|
2898
|
+
const explicitHash = environment?.graphHash ?? environment?.commitSha;
|
|
2899
|
+
if (explicitHash !== undefined && explicitHash.length > 0) {
|
|
2900
|
+
return explicitHash.slice(0, 7);
|
|
2901
|
+
}
|
|
2902
|
+
return createHash('sha256')
|
|
2903
|
+
.update(stableJson({ ledger: run.ledger, plan: run.plan }))
|
|
2904
|
+
.digest('hex')
|
|
2905
|
+
.slice(0, 7);
|
|
2906
|
+
};
|
|
2907
|
+
|
|
2908
|
+
export const vocabularyTransitionRecordPath = (params: {
|
|
2909
|
+
readonly environment?: Partial<VocabularyTransitionRecordEnvironment>;
|
|
2910
|
+
readonly root: string;
|
|
2911
|
+
readonly run: VocabularyRegradeRun;
|
|
2912
|
+
}): string =>
|
|
2913
|
+
join(
|
|
2914
|
+
'.trails',
|
|
2915
|
+
'regrade',
|
|
2916
|
+
'history',
|
|
2917
|
+
`${transitionRecordSlug(params.run)}-${shortHashForRun(params.run, params.environment)}.json`
|
|
2918
|
+
);
|
|
2919
|
+
|
|
2920
|
+
const reportWithoutRecord = (
|
|
2921
|
+
report: RegradeReport
|
|
2922
|
+
): Omit<RegradeReport, 'record'> => {
|
|
2923
|
+
const { record: _record, ...rest } = report;
|
|
2924
|
+
return rest;
|
|
2925
|
+
};
|
|
2926
|
+
|
|
2927
|
+
const transitionRecordPathForWrite = (params: {
|
|
2928
|
+
readonly environment: VocabularyTransitionRecordEnvironment;
|
|
2929
|
+
readonly recordPath?: string;
|
|
2930
|
+
readonly report: RegradeReport;
|
|
2931
|
+
readonly root: string;
|
|
2932
|
+
}): Result<string, ValidationError> => {
|
|
2933
|
+
const recordPath =
|
|
2934
|
+
params.recordPath ??
|
|
2935
|
+
vocabularyTransitionRecordPath({
|
|
2936
|
+
environment: params.environment,
|
|
2937
|
+
root: params.root,
|
|
2938
|
+
run: params.report.run as VocabularyRegradeRun,
|
|
2939
|
+
});
|
|
2940
|
+
const normalized = normalizeTransitionRecordPath(
|
|
2941
|
+
isAbsolute(recordPath) ? relative(params.root, recordPath) : recordPath
|
|
2942
|
+
);
|
|
2943
|
+
if (!isSafeRootRelativeRecordPath(normalized)) {
|
|
2944
|
+
return Result.err(
|
|
2945
|
+
new ValidationError(
|
|
2946
|
+
'Vocabulary transition record path must stay inside the regrade root.',
|
|
2947
|
+
{ context: { recordPath } }
|
|
2948
|
+
)
|
|
2949
|
+
);
|
|
2950
|
+
}
|
|
2951
|
+
return Result.ok(normalized);
|
|
2952
|
+
};
|
|
2953
|
+
|
|
2954
|
+
export const buildVocabularyTransitionRecord = (params: {
|
|
2955
|
+
readonly environment?: Partial<VocabularyTransitionRecordEnvironment>;
|
|
2956
|
+
readonly recordPath?: string;
|
|
2957
|
+
readonly report: RegradeReport;
|
|
2958
|
+
readonly root: string;
|
|
2959
|
+
}): Result<VocabularyTransitionRecord, ValidationError> => {
|
|
2960
|
+
if (params.report.run === undefined) {
|
|
2961
|
+
return Result.err(
|
|
2962
|
+
new ValidationError(
|
|
2963
|
+
'Vocabulary transition records require a vocabulary Regrade report.'
|
|
2964
|
+
)
|
|
2965
|
+
);
|
|
2966
|
+
}
|
|
2967
|
+
|
|
2968
|
+
const environment: VocabularyTransitionRecordEnvironment = {
|
|
2969
|
+
...(params.environment?.commitSha === undefined
|
|
2970
|
+
? {}
|
|
2971
|
+
: { commitSha: params.environment.commitSha }),
|
|
2972
|
+
...(params.environment?.engineVersion === undefined
|
|
2973
|
+
? {}
|
|
2974
|
+
: { engineVersion: params.environment.engineVersion }),
|
|
2975
|
+
...(params.environment?.graphHash === undefined
|
|
2976
|
+
? {}
|
|
2977
|
+
: { graphHash: params.environment.graphHash }),
|
|
2978
|
+
root: params.root,
|
|
2979
|
+
};
|
|
2980
|
+
const recordPathResult = transitionRecordPathForWrite({
|
|
2981
|
+
environment,
|
|
2982
|
+
report: params.report,
|
|
2983
|
+
root: params.root,
|
|
2984
|
+
...(params.recordPath === undefined
|
|
2985
|
+
? {}
|
|
2986
|
+
: { recordPath: params.recordPath }),
|
|
2987
|
+
});
|
|
2988
|
+
if (recordPathResult.isErr()) {
|
|
2989
|
+
return recordPathResult;
|
|
2990
|
+
}
|
|
2991
|
+
const recordPath = recordPathResult.value;
|
|
2992
|
+
const record: VocabularyTransitionRecord = {
|
|
2993
|
+
environment,
|
|
2994
|
+
kind: 'vocabulary-transition-record',
|
|
2995
|
+
recordPath,
|
|
2996
|
+
report: reportWithoutRecord(params.report),
|
|
2997
|
+
schemaVersion: VOCABULARY_TRANSITION_RECORD_SCHEMA_VERSION,
|
|
2998
|
+
transition: {
|
|
2999
|
+
from: params.report.run.plan.from,
|
|
3000
|
+
id:
|
|
3001
|
+
params.report.run.plan.id ??
|
|
3002
|
+
`vocabulary:${params.report.run.plan.from}->${params.report.run.plan.to}`,
|
|
3003
|
+
to: params.report.run.plan.to,
|
|
3004
|
+
},
|
|
3005
|
+
};
|
|
3006
|
+
const parsed = vocabularyTransitionRecordSchema.safeParse(record);
|
|
3007
|
+
if (!parsed.success) {
|
|
3008
|
+
return Result.err(
|
|
3009
|
+
new ValidationError('Invalid vocabulary transition record.', {
|
|
3010
|
+
context: { issues: parsed.error.issues },
|
|
3011
|
+
})
|
|
3012
|
+
);
|
|
3013
|
+
}
|
|
3014
|
+
return Result.ok(parsed.data as VocabularyTransitionRecord);
|
|
3015
|
+
};
|
|
3016
|
+
|
|
3017
|
+
export const writeVocabularyTransitionRecord = (params: {
|
|
3018
|
+
readonly environment?: Partial<VocabularyTransitionRecordEnvironment>;
|
|
3019
|
+
readonly recordPath?: string;
|
|
3020
|
+
readonly report: RegradeReport;
|
|
3021
|
+
readonly root: string;
|
|
3022
|
+
readonly status: VocabularyTransitionRecordSummary['status'];
|
|
3023
|
+
}): Result<
|
|
3024
|
+
{
|
|
3025
|
+
readonly record: VocabularyTransitionRecord;
|
|
3026
|
+
readonly summary: VocabularyTransitionRecordSummary;
|
|
3027
|
+
},
|
|
3028
|
+
InternalError | ValidationError
|
|
3029
|
+
> => {
|
|
3030
|
+
const recordResult = buildVocabularyTransitionRecord(params);
|
|
3031
|
+
if (recordResult.isErr()) {
|
|
3032
|
+
return recordResult;
|
|
3033
|
+
}
|
|
3034
|
+
const record = recordResult.value;
|
|
3035
|
+
const absolutePath = isAbsolute(record.recordPath)
|
|
3036
|
+
? record.recordPath
|
|
3037
|
+
: join(params.root, record.recordPath);
|
|
3038
|
+
try {
|
|
3039
|
+
mkdirSync(dirname(absolutePath), { recursive: true });
|
|
3040
|
+
writeFileSync(absolutePath, `${JSON.stringify(record, null, 2)}\n`);
|
|
3041
|
+
} catch (error) {
|
|
3042
|
+
return Result.err(
|
|
3043
|
+
new InternalError('Failed to write vocabulary transition record.', {
|
|
3044
|
+
...(error instanceof Error ? { cause: error } : {}),
|
|
3045
|
+
context: { path: record.recordPath },
|
|
3046
|
+
})
|
|
3047
|
+
);
|
|
3048
|
+
}
|
|
3049
|
+
return Result.ok({
|
|
3050
|
+
record,
|
|
3051
|
+
summary: {
|
|
3052
|
+
path: record.recordPath,
|
|
3053
|
+
schemaVersion: record.schemaVersion,
|
|
3054
|
+
status: params.status,
|
|
3055
|
+
},
|
|
3056
|
+
});
|
|
3057
|
+
};
|
|
3058
|
+
|
|
3059
|
+
export const readVocabularyTransitionRecord = (
|
|
3060
|
+
path: string
|
|
3061
|
+
): Result<VocabularyTransitionRecord, InternalError | ValidationError> => {
|
|
3062
|
+
if (!existsSync(path)) {
|
|
3063
|
+
return Result.err(
|
|
3064
|
+
new ValidationError(`Vocabulary transition record "${path}" not found.`)
|
|
3065
|
+
);
|
|
3066
|
+
}
|
|
3067
|
+
let parsedJson: unknown;
|
|
3068
|
+
try {
|
|
3069
|
+
parsedJson = JSON.parse(readFileSync(path, 'utf8'));
|
|
3070
|
+
} catch (error) {
|
|
3071
|
+
return Result.err(
|
|
3072
|
+
new InternalError('Failed to read vocabulary transition record.', {
|
|
3073
|
+
...(error instanceof Error ? { cause: error } : {}),
|
|
3074
|
+
context: { path },
|
|
3075
|
+
})
|
|
3076
|
+
);
|
|
3077
|
+
}
|
|
3078
|
+
const parsed = vocabularyTransitionRecordSchema.safeParse(
|
|
3079
|
+
normalizeLegacyTransitionRecordScopeEvidence(parsedJson)
|
|
3080
|
+
);
|
|
3081
|
+
if (!parsed.success) {
|
|
3082
|
+
return Result.err(
|
|
3083
|
+
new ValidationError('Invalid vocabulary transition record.', {
|
|
3084
|
+
context: { issues: parsed.error.issues, path },
|
|
3085
|
+
})
|
|
3086
|
+
);
|
|
3087
|
+
}
|
|
3088
|
+
return Result.ok(parsed.data as VocabularyTransitionRecord);
|
|
3089
|
+
};
|
|
3090
|
+
|
|
3091
|
+
export const transitionRecordReportWithSummary = (
|
|
3092
|
+
report: RegradeReport,
|
|
3093
|
+
summary: VocabularyTransitionRecordSummary
|
|
3094
|
+
): RegradeReport => ({ ...report, record: summary });
|