@ontrails/regrade 1.0.0-beta.30 → 1.0.0-beta.32
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 +45 -0
- package/package.json +5 -5
- package/src/downstream/collect.ts +29 -3
- package/src/downstream/report.ts +291 -35
- package/src/downstream/scan-summary.ts +193 -0
- package/src/downstream/vocabulary.ts +965 -0
- package/src/index.ts +24 -0
|
@@ -0,0 +1,965 @@
|
|
|
1
|
+
import {
|
|
2
|
+
InternalError,
|
|
3
|
+
Result,
|
|
4
|
+
ValidationError,
|
|
5
|
+
escapeRegExp,
|
|
6
|
+
matchesAnyPathGlob,
|
|
7
|
+
} from '@ontrails/core';
|
|
8
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
9
|
+
import { z } from 'zod';
|
|
10
|
+
|
|
11
|
+
import { collectDownstreamSources } from './collect.js';
|
|
12
|
+
import type { DownstreamCollectionOptions, SkippedSource } from './collect.js';
|
|
13
|
+
import type {
|
|
14
|
+
RegradeApplySummary,
|
|
15
|
+
RegradeReport,
|
|
16
|
+
RegradeReportEntry,
|
|
17
|
+
} from './report.js';
|
|
18
|
+
import { buildRegradeScanSummary } from './scan-summary.js';
|
|
19
|
+
|
|
20
|
+
export type VocabularyVerdict = 'deferred' | 'modified' | 'skipped';
|
|
21
|
+
|
|
22
|
+
export interface VocabularyPreserveRule {
|
|
23
|
+
readonly pattern: string;
|
|
24
|
+
readonly reason?: string;
|
|
25
|
+
readonly paths?: readonly string[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface VocabularyRegradeScope {
|
|
29
|
+
readonly exclude?: readonly string[];
|
|
30
|
+
readonly extensions?: readonly string[];
|
|
31
|
+
/**
|
|
32
|
+
* @deprecated Use `exclude` path globs for new plans. This remains as a
|
|
33
|
+
* compatibility bridge for pre-path-scope plans that intentionally disabled
|
|
34
|
+
* the collector's default directory pruning.
|
|
35
|
+
*/
|
|
36
|
+
readonly ignoredDirectories?: readonly string[];
|
|
37
|
+
readonly include?: readonly string[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface VocabularyRegradePlan {
|
|
41
|
+
readonly from: string;
|
|
42
|
+
readonly id?: string;
|
|
43
|
+
readonly intent?: string;
|
|
44
|
+
readonly kind: 'vocabulary';
|
|
45
|
+
readonly overrides?: Readonly<Record<string, string>>;
|
|
46
|
+
readonly preserve?: readonly VocabularyPreserveRule[];
|
|
47
|
+
readonly scope?: VocabularyRegradeScope;
|
|
48
|
+
readonly to: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface VocabularyOccurrence {
|
|
52
|
+
readonly column: number;
|
|
53
|
+
readonly context: string;
|
|
54
|
+
readonly end: number;
|
|
55
|
+
readonly form: string;
|
|
56
|
+
readonly line: number;
|
|
57
|
+
readonly path: string;
|
|
58
|
+
readonly reason: string;
|
|
59
|
+
readonly replacement?: string;
|
|
60
|
+
readonly start: number;
|
|
61
|
+
readonly verdict: VocabularyVerdict;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface VocabularyRunLedger {
|
|
65
|
+
readonly cycle: number;
|
|
66
|
+
readonly forms: Readonly<Record<string, VocabularyVerdict>>;
|
|
67
|
+
readonly occurrences: readonly VocabularyOccurrence[];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface VocabularyRunGate {
|
|
71
|
+
readonly remaining: number;
|
|
72
|
+
readonly reasons: readonly string[];
|
|
73
|
+
readonly status: 'green' | 'open';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface VocabularyRunReport {
|
|
77
|
+
readonly applied: number;
|
|
78
|
+
readonly deferred: number;
|
|
79
|
+
readonly filesChanged: number;
|
|
80
|
+
readonly gate: VocabularyRunGate;
|
|
81
|
+
readonly modified: number;
|
|
82
|
+
readonly open: number;
|
|
83
|
+
readonly skipped: number;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface VocabularyRegradeRun {
|
|
87
|
+
readonly ledger: VocabularyRunLedger;
|
|
88
|
+
readonly plan: VocabularyRegradePlan;
|
|
89
|
+
readonly report: VocabularyRunReport;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
interface SourceFile {
|
|
93
|
+
readonly absolutePath: string;
|
|
94
|
+
readonly path: string;
|
|
95
|
+
readonly source: string;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
interface SourceOccurrence extends VocabularyOccurrence {
|
|
99
|
+
readonly absolutePath: string;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
interface VocabularyEvaluation {
|
|
103
|
+
readonly entries: readonly RegradeReportEntry[];
|
|
104
|
+
readonly occurrences: readonly SourceOccurrence[];
|
|
105
|
+
readonly scanned: number;
|
|
106
|
+
readonly skipped: readonly SkippedSource[];
|
|
107
|
+
readonly run: VocabularyRegradeRun;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const VOCABULARY_SOURCE_EXTENSIONS = Object.freeze([
|
|
111
|
+
'.js',
|
|
112
|
+
'.jsx',
|
|
113
|
+
'.json',
|
|
114
|
+
'.jsonc',
|
|
115
|
+
'.md',
|
|
116
|
+
'.mdx',
|
|
117
|
+
'.mjs',
|
|
118
|
+
'.ts',
|
|
119
|
+
'.tsx',
|
|
120
|
+
'.txt',
|
|
121
|
+
'.yaml',
|
|
122
|
+
'.yml',
|
|
123
|
+
]);
|
|
124
|
+
|
|
125
|
+
const uniqueSorted = (values: readonly string[]): readonly string[] =>
|
|
126
|
+
[...new Set(values)].toSorted((a, b) => a.localeCompare(b));
|
|
127
|
+
|
|
128
|
+
const isVocabularyTokenCharacter = (value: string): boolean =>
|
|
129
|
+
/[A-Za-z0-9_$-]/.test(value);
|
|
130
|
+
|
|
131
|
+
const hasWordBoundary = (
|
|
132
|
+
source: string,
|
|
133
|
+
start: number,
|
|
134
|
+
end: number
|
|
135
|
+
): boolean => {
|
|
136
|
+
const before = start === 0 ? '' : (source.at(start - 1) ?? '');
|
|
137
|
+
const after = end >= source.length ? '' : (source.at(end) ?? '');
|
|
138
|
+
return (
|
|
139
|
+
!isVocabularyTokenCharacter(before) && !isVocabularyTokenCharacter(after)
|
|
140
|
+
);
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const expandVocabularyNeighborSpan = (
|
|
144
|
+
source: string,
|
|
145
|
+
start: number,
|
|
146
|
+
end: number
|
|
147
|
+
): { readonly end: number; readonly start: number } => {
|
|
148
|
+
let expandedStart = start;
|
|
149
|
+
while (
|
|
150
|
+
expandedStart > 0 &&
|
|
151
|
+
isVocabularyTokenCharacter(source.at(expandedStart - 1) ?? '')
|
|
152
|
+
) {
|
|
153
|
+
expandedStart -= 1;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
let expandedEnd = end;
|
|
157
|
+
while (
|
|
158
|
+
expandedEnd < source.length &&
|
|
159
|
+
isVocabularyTokenCharacter(source.at(expandedEnd) ?? '')
|
|
160
|
+
) {
|
|
161
|
+
expandedEnd += 1;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return { end: expandedEnd, start: expandedStart };
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const lineColumnForOffset = (
|
|
168
|
+
source: string,
|
|
169
|
+
offset: number
|
|
170
|
+
): { readonly column: number; readonly line: number } => {
|
|
171
|
+
let line = 1;
|
|
172
|
+
let column = 1;
|
|
173
|
+
for (let index = 0; index < offset; index += 1) {
|
|
174
|
+
if (source.codePointAt(index) === 10) {
|
|
175
|
+
line += 1;
|
|
176
|
+
column = 1;
|
|
177
|
+
} else {
|
|
178
|
+
column += 1;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return { column, line };
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const contextForOffset = (
|
|
185
|
+
source: string,
|
|
186
|
+
start: number,
|
|
187
|
+
end: number
|
|
188
|
+
): string => {
|
|
189
|
+
const lineStart = source.lastIndexOf('\n', start - 1) + 1;
|
|
190
|
+
const nextLine = source.indexOf('\n', end);
|
|
191
|
+
const lineEnd = nextLine === -1 ? source.length : nextLine;
|
|
192
|
+
return source.slice(lineStart, lineEnd).trim();
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const preserveCase = (sourceForm: string, replacement: string): string => {
|
|
196
|
+
if (sourceForm.toUpperCase() === sourceForm) {
|
|
197
|
+
return replacement.toUpperCase();
|
|
198
|
+
}
|
|
199
|
+
const first = sourceForm.at(0);
|
|
200
|
+
if (first !== undefined && first.toUpperCase() === first) {
|
|
201
|
+
return replacement.at(0)?.toUpperCase() + replacement.slice(1);
|
|
202
|
+
}
|
|
203
|
+
return replacement;
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
const pluralize = (value: string): string =>
|
|
207
|
+
value.endsWith('s') || value.endsWith('x') || value.endsWith('ch')
|
|
208
|
+
? `${value}es`
|
|
209
|
+
: `${value}s`;
|
|
210
|
+
|
|
211
|
+
const defaultVocabularyForms = (from: string, to: string) =>
|
|
212
|
+
new Map<string, string>([
|
|
213
|
+
[from, to],
|
|
214
|
+
[pluralize(from), pluralize(to)],
|
|
215
|
+
]);
|
|
216
|
+
|
|
217
|
+
const normalizedOverrideEntries = (
|
|
218
|
+
overrides: Readonly<Record<string, string>> | undefined
|
|
219
|
+
): readonly [string, string][] =>
|
|
220
|
+
Object.entries(overrides ?? {}).toSorted(([left], [right]) =>
|
|
221
|
+
left.localeCompare(right)
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
const targetFormsForPlan = (
|
|
225
|
+
plan: VocabularyRegradePlan
|
|
226
|
+
): Map<string, string> => {
|
|
227
|
+
const forms = defaultVocabularyForms(plan.from, plan.to);
|
|
228
|
+
for (const [form, replacement] of normalizedOverrideEntries(plan.overrides)) {
|
|
229
|
+
forms.set(form, replacement);
|
|
230
|
+
}
|
|
231
|
+
return forms;
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
const validateVocabularyPlan = (
|
|
235
|
+
plan: VocabularyRegradePlan
|
|
236
|
+
): Result<void, ValidationError> => {
|
|
237
|
+
if (plan.from.trim().length === 0) {
|
|
238
|
+
return Result.err(
|
|
239
|
+
new ValidationError('Vocabulary Regrade plan `from` cannot be empty.')
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
if (plan.to.trim().length === 0) {
|
|
243
|
+
return Result.err(
|
|
244
|
+
new ValidationError('Vocabulary Regrade plan `to` cannot be empty.')
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
for (const [form, replacement] of normalizedOverrideEntries(plan.overrides)) {
|
|
248
|
+
if (form.trim().length === 0) {
|
|
249
|
+
return Result.err(
|
|
250
|
+
new ValidationError(
|
|
251
|
+
'Vocabulary Regrade plan override keys cannot be empty.'
|
|
252
|
+
)
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
if (replacement.trim().length === 0) {
|
|
256
|
+
return Result.err(
|
|
257
|
+
new ValidationError(
|
|
258
|
+
`Vocabulary Regrade plan override "${form}" cannot map to an empty replacement.`
|
|
259
|
+
)
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
for (const rule of plan.preserve ?? []) {
|
|
264
|
+
if (rule.pattern.trim().length === 0) {
|
|
265
|
+
return Result.err(
|
|
266
|
+
new ValidationError(
|
|
267
|
+
'Vocabulary Regrade plan preserve patterns cannot be empty.'
|
|
268
|
+
)
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return Result.ok();
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
const includedByScope = (
|
|
276
|
+
path: string,
|
|
277
|
+
scope: VocabularyRegradeScope | undefined
|
|
278
|
+
): boolean =>
|
|
279
|
+
(scope?.include === undefined ||
|
|
280
|
+
scope.include.length === 0 ||
|
|
281
|
+
matchesAnyPathGlob(path, scope.include)) &&
|
|
282
|
+
!matchesAnyPathGlob(path, scope?.exclude);
|
|
283
|
+
|
|
284
|
+
const compilePreservePattern = (pattern: string): RegExp => {
|
|
285
|
+
try {
|
|
286
|
+
return new RegExp(pattern);
|
|
287
|
+
} catch {
|
|
288
|
+
return new RegExp(escapeRegExp(pattern));
|
|
289
|
+
}
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
const preserveRuleForOccurrence = (
|
|
293
|
+
occurrence: Omit<SourceOccurrence, 'reason' | 'verdict'>,
|
|
294
|
+
plan: VocabularyRegradePlan
|
|
295
|
+
): VocabularyPreserveRule | undefined =>
|
|
296
|
+
plan.preserve?.find((rule) => {
|
|
297
|
+
if (
|
|
298
|
+
rule.paths !== undefined &&
|
|
299
|
+
!matchesAnyPathGlob(occurrence.path, rule.paths)
|
|
300
|
+
) {
|
|
301
|
+
return false;
|
|
302
|
+
}
|
|
303
|
+
return (
|
|
304
|
+
compilePreservePattern(rule.pattern).test(occurrence.context) ||
|
|
305
|
+
compilePreservePattern(rule.pattern).test(occurrence.form)
|
|
306
|
+
);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
const occurrencesForFile = (
|
|
310
|
+
file: SourceFile,
|
|
311
|
+
plan: VocabularyRegradePlan,
|
|
312
|
+
targetForms: Map<string, string>
|
|
313
|
+
): readonly SourceOccurrence[] => {
|
|
314
|
+
const occurrences: SourceOccurrence[] = [];
|
|
315
|
+
const candidates: SourceOccurrence[] = [];
|
|
316
|
+
const forms = [...targetForms.entries()].toSorted(
|
|
317
|
+
([left], [right]) => right.length - left.length || left.localeCompare(right)
|
|
318
|
+
);
|
|
319
|
+
|
|
320
|
+
for (const [form, replacement] of forms) {
|
|
321
|
+
const pattern = new RegExp(escapeRegExp(form), 'gi');
|
|
322
|
+
for (const match of file.source.matchAll(pattern)) {
|
|
323
|
+
const start = match.index ?? 0;
|
|
324
|
+
const end = start + match[0].length;
|
|
325
|
+
if (!hasWordBoundary(file.source, start, end)) {
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
const { column, line } = lineColumnForOffset(file.source, start);
|
|
329
|
+
const baseOccurrence = {
|
|
330
|
+
absolutePath: file.absolutePath,
|
|
331
|
+
column,
|
|
332
|
+
context: contextForOffset(file.source, start, end),
|
|
333
|
+
end,
|
|
334
|
+
form: match[0],
|
|
335
|
+
line,
|
|
336
|
+
path: file.path,
|
|
337
|
+
start,
|
|
338
|
+
};
|
|
339
|
+
const preserveRule = preserveRuleForOccurrence(baseOccurrence, plan);
|
|
340
|
+
candidates.push({
|
|
341
|
+
...baseOccurrence,
|
|
342
|
+
reason:
|
|
343
|
+
preserveRule?.reason ??
|
|
344
|
+
(preserveRule === undefined ? 'captured-form' : 'preserved-by-plan'),
|
|
345
|
+
...(preserveRule === undefined
|
|
346
|
+
? { replacement: preserveCase(match[0], replacement) }
|
|
347
|
+
: {}),
|
|
348
|
+
verdict: preserveRule === undefined ? 'modified' : 'skipped',
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
for (const candidate of candidates.toSorted(
|
|
354
|
+
(left, right) =>
|
|
355
|
+
right.end - right.start - (left.end - left.start) ||
|
|
356
|
+
left.start - right.start
|
|
357
|
+
)) {
|
|
358
|
+
const overlaps = occurrences.some(
|
|
359
|
+
(occurrence) =>
|
|
360
|
+
candidate.start < occurrence.end && occurrence.start < candidate.end
|
|
361
|
+
);
|
|
362
|
+
if (!overlaps) {
|
|
363
|
+
occurrences.push(candidate);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
return occurrences.toSorted((left, right) =>
|
|
368
|
+
left.path === right.path
|
|
369
|
+
? left.start - right.start
|
|
370
|
+
: left.path.localeCompare(right.path)
|
|
371
|
+
);
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
const deferredOccurrencesForFile = (
|
|
375
|
+
file: SourceFile,
|
|
376
|
+
plan: VocabularyRegradePlan,
|
|
377
|
+
targetForms: Map<string, string>
|
|
378
|
+
): readonly SourceOccurrence[] => {
|
|
379
|
+
const knownForms = new Set(
|
|
380
|
+
[...targetForms.keys()].flatMap((form) => [form, form.toLowerCase()])
|
|
381
|
+
);
|
|
382
|
+
const lowerFrom = plan.from.toLowerCase();
|
|
383
|
+
const tokenPattern = /[A-Za-z_$][A-Za-z0-9_$-]*/g;
|
|
384
|
+
const occurrences: SourceOccurrence[] = [];
|
|
385
|
+
const pushOccurrence = (
|
|
386
|
+
baseOccurrence: Omit<SourceOccurrence, 'reason' | 'verdict'>
|
|
387
|
+
): void => {
|
|
388
|
+
const preserveRule = preserveRuleForOccurrence(baseOccurrence, plan);
|
|
389
|
+
occurrences.push({
|
|
390
|
+
...baseOccurrence,
|
|
391
|
+
reason:
|
|
392
|
+
preserveRule?.reason ??
|
|
393
|
+
(preserveRule === undefined
|
|
394
|
+
? 'unclassified-neighbor'
|
|
395
|
+
: 'preserved-by-plan'),
|
|
396
|
+
verdict: preserveRule === undefined ? 'deferred' : 'skipped',
|
|
397
|
+
});
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
for (const form of targetForms.keys()) {
|
|
401
|
+
const pattern = new RegExp(escapeRegExp(form), 'gi');
|
|
402
|
+
for (const match of file.source.matchAll(pattern)) {
|
|
403
|
+
const matchStart = match.index ?? 0;
|
|
404
|
+
const matchEnd = matchStart + match[0].length;
|
|
405
|
+
if (hasWordBoundary(file.source, matchStart, matchEnd)) {
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
const { end, start } = expandVocabularyNeighborSpan(
|
|
409
|
+
file.source,
|
|
410
|
+
matchStart,
|
|
411
|
+
matchEnd
|
|
412
|
+
);
|
|
413
|
+
const matchedForm = file.source.slice(start, end);
|
|
414
|
+
const lowerMatchedForm = matchedForm.toLowerCase();
|
|
415
|
+
const overlaps = occurrences.some(
|
|
416
|
+
(occurrence) => start < occurrence.end && occurrence.start < end
|
|
417
|
+
);
|
|
418
|
+
if (
|
|
419
|
+
overlaps ||
|
|
420
|
+
knownForms.has(matchedForm) ||
|
|
421
|
+
knownForms.has(lowerMatchedForm) ||
|
|
422
|
+
!lowerMatchedForm.includes(lowerFrom)
|
|
423
|
+
) {
|
|
424
|
+
continue;
|
|
425
|
+
}
|
|
426
|
+
const { column, line } = lineColumnForOffset(file.source, start);
|
|
427
|
+
pushOccurrence({
|
|
428
|
+
absolutePath: file.absolutePath,
|
|
429
|
+
column,
|
|
430
|
+
context: contextForOffset(file.source, start, end),
|
|
431
|
+
end,
|
|
432
|
+
form: matchedForm,
|
|
433
|
+
line,
|
|
434
|
+
path: file.path,
|
|
435
|
+
start,
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
for (const match of file.source.matchAll(tokenPattern)) {
|
|
441
|
+
const [form] = match;
|
|
442
|
+
const lower = form.toLowerCase();
|
|
443
|
+
if (knownForms.has(form) || knownForms.has(lower)) {
|
|
444
|
+
continue;
|
|
445
|
+
}
|
|
446
|
+
if (!lower.includes(lowerFrom)) {
|
|
447
|
+
continue;
|
|
448
|
+
}
|
|
449
|
+
const start = match.index ?? 0;
|
|
450
|
+
const end = start + form.length;
|
|
451
|
+
const overlaps = occurrences.some(
|
|
452
|
+
(occurrence) => start < occurrence.end && occurrence.start < end
|
|
453
|
+
);
|
|
454
|
+
if (overlaps) {
|
|
455
|
+
continue;
|
|
456
|
+
}
|
|
457
|
+
const { column, line } = lineColumnForOffset(file.source, start);
|
|
458
|
+
pushOccurrence({
|
|
459
|
+
absolutePath: file.absolutePath,
|
|
460
|
+
column,
|
|
461
|
+
context: contextForOffset(file.source, start, end),
|
|
462
|
+
end,
|
|
463
|
+
form,
|
|
464
|
+
line,
|
|
465
|
+
path: file.path,
|
|
466
|
+
start,
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
return occurrences.toSorted((left, right) =>
|
|
470
|
+
left.path === right.path
|
|
471
|
+
? left.start - right.start
|
|
472
|
+
: left.path.localeCompare(right.path)
|
|
473
|
+
);
|
|
474
|
+
};
|
|
475
|
+
|
|
476
|
+
const entryForOccurrences = (
|
|
477
|
+
path: string,
|
|
478
|
+
occurrences: readonly SourceOccurrence[]
|
|
479
|
+
): RegradeReportEntry | null => {
|
|
480
|
+
if (occurrences.length === 0) {
|
|
481
|
+
return null;
|
|
482
|
+
}
|
|
483
|
+
const hasDeferred = occurrences.some(
|
|
484
|
+
(occurrence) => occurrence.verdict === 'deferred'
|
|
485
|
+
);
|
|
486
|
+
const hasModified = occurrences.some(
|
|
487
|
+
(occurrence) => occurrence.verdict === 'modified'
|
|
488
|
+
);
|
|
489
|
+
if (hasDeferred) {
|
|
490
|
+
return {
|
|
491
|
+
notes: [
|
|
492
|
+
`Found ${occurrences.length} vocabulary occurrence(s); judgment deferred.`,
|
|
493
|
+
],
|
|
494
|
+
outcome: 'needs-review',
|
|
495
|
+
path,
|
|
496
|
+
reason: 'vocabulary-judgment-deferred',
|
|
497
|
+
reviewDetails: occurrences
|
|
498
|
+
.filter((occurrence) => occurrence.verdict === 'deferred')
|
|
499
|
+
.map((occurrence) => ({
|
|
500
|
+
expectedTarget:
|
|
501
|
+
'Add an override or preserve rule to the regrade plan.',
|
|
502
|
+
reason: occurrence.reason,
|
|
503
|
+
span: {
|
|
504
|
+
column: occurrence.column,
|
|
505
|
+
end: occurrence.end,
|
|
506
|
+
line: occurrence.line,
|
|
507
|
+
start: occurrence.start,
|
|
508
|
+
},
|
|
509
|
+
symbol: occurrence.form,
|
|
510
|
+
})),
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
if (hasModified) {
|
|
514
|
+
return {
|
|
515
|
+
notes: [
|
|
516
|
+
`Found ${occurrences.length} vocabulary occurrence(s); safe modifications available.`,
|
|
517
|
+
],
|
|
518
|
+
outcome: 'rewrite',
|
|
519
|
+
path,
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
return {
|
|
523
|
+
notes: [`Skipped ${occurrences.length} vocabulary occurrence(s).`],
|
|
524
|
+
outcome: 'no-op',
|
|
525
|
+
path,
|
|
526
|
+
};
|
|
527
|
+
};
|
|
528
|
+
|
|
529
|
+
const applyOccurrenceRewrites = (
|
|
530
|
+
file: SourceFile,
|
|
531
|
+
occurrences: readonly SourceOccurrence[]
|
|
532
|
+
): string => {
|
|
533
|
+
let nextSource = file.source;
|
|
534
|
+
for (const occurrence of occurrences.toReversed()) {
|
|
535
|
+
if (
|
|
536
|
+
occurrence.verdict !== 'modified' ||
|
|
537
|
+
occurrence.replacement === undefined
|
|
538
|
+
) {
|
|
539
|
+
continue;
|
|
540
|
+
}
|
|
541
|
+
nextSource =
|
|
542
|
+
nextSource.slice(0, occurrence.start) +
|
|
543
|
+
occurrence.replacement +
|
|
544
|
+
nextSource.slice(occurrence.end);
|
|
545
|
+
}
|
|
546
|
+
return nextSource;
|
|
547
|
+
};
|
|
548
|
+
|
|
549
|
+
const buildVocabularyEvaluation = (params: {
|
|
550
|
+
readonly apply?: boolean;
|
|
551
|
+
readonly files: readonly SourceFile[];
|
|
552
|
+
readonly plan: VocabularyRegradePlan;
|
|
553
|
+
readonly root: string;
|
|
554
|
+
readonly skipped: readonly SkippedSource[];
|
|
555
|
+
}): VocabularyEvaluation => {
|
|
556
|
+
const targetForms = targetFormsForPlan(params.plan);
|
|
557
|
+
const scopedFiles = params.files.filter((file) =>
|
|
558
|
+
includedByScope(file.path, params.plan.scope)
|
|
559
|
+
);
|
|
560
|
+
const scopeSkipped: SkippedSource[] = params.files
|
|
561
|
+
.filter((file) => !includedByScope(file.path, params.plan.scope))
|
|
562
|
+
.map((file) => ({ path: file.path, reason: 'excluded-by-regrade-scope' }));
|
|
563
|
+
const occurrences = scopedFiles.flatMap((file) => [
|
|
564
|
+
...occurrencesForFile(file, params.plan, targetForms),
|
|
565
|
+
...deferredOccurrencesForFile(file, params.plan, targetForms),
|
|
566
|
+
]);
|
|
567
|
+
const occurrencesByPath = new Map<string, SourceOccurrence[]>();
|
|
568
|
+
for (const occurrence of occurrences) {
|
|
569
|
+
const existing = occurrencesByPath.get(occurrence.path) ?? [];
|
|
570
|
+
occurrencesByPath.set(occurrence.path, [...existing, occurrence]);
|
|
571
|
+
}
|
|
572
|
+
const entries = [...occurrencesByPath.entries()]
|
|
573
|
+
.flatMap(([path, pathOccurrences]) => {
|
|
574
|
+
const entry = entryForOccurrences(path, pathOccurrences);
|
|
575
|
+
return entry === null ? [] : [entry];
|
|
576
|
+
})
|
|
577
|
+
.toSorted((left, right) => left.path.localeCompare(right.path));
|
|
578
|
+
const rewrittenPaths = new Set(
|
|
579
|
+
occurrences
|
|
580
|
+
.filter((occurrence) => occurrence.verdict === 'modified')
|
|
581
|
+
.map((occurrence) => occurrence.path)
|
|
582
|
+
);
|
|
583
|
+
const deferredForms = uniqueSorted(
|
|
584
|
+
occurrences
|
|
585
|
+
.filter((occurrence) => occurrence.verdict === 'deferred')
|
|
586
|
+
.map((occurrence) => occurrence.form)
|
|
587
|
+
);
|
|
588
|
+
const modifiedOccurrences = occurrences.filter(
|
|
589
|
+
(occurrence) => occurrence.verdict === 'modified'
|
|
590
|
+
);
|
|
591
|
+
const skippedOccurrences = occurrences.filter(
|
|
592
|
+
(occurrence) => occurrence.verdict === 'skipped'
|
|
593
|
+
);
|
|
594
|
+
const deferredOccurrences = occurrences.filter(
|
|
595
|
+
(occurrence) => occurrence.verdict === 'deferred'
|
|
596
|
+
);
|
|
597
|
+
const forms: Record<string, VocabularyVerdict> = {};
|
|
598
|
+
for (const occurrence of occurrences) {
|
|
599
|
+
const current = forms[occurrence.form];
|
|
600
|
+
if (occurrence.verdict === 'deferred') {
|
|
601
|
+
forms[occurrence.form] = 'deferred';
|
|
602
|
+
continue;
|
|
603
|
+
}
|
|
604
|
+
if (occurrence.verdict === 'modified' && current !== 'deferred') {
|
|
605
|
+
forms[occurrence.form] = 'modified';
|
|
606
|
+
continue;
|
|
607
|
+
}
|
|
608
|
+
if (current === undefined) {
|
|
609
|
+
forms[occurrence.form] = 'skipped';
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
const gateReasons: string[] = [];
|
|
613
|
+
if (modifiedOccurrences.length > 0) {
|
|
614
|
+
gateReasons.push(
|
|
615
|
+
params.apply === true
|
|
616
|
+
? 'source-forms-remain-after-apply'
|
|
617
|
+
: 'safe-modifications-not-yet-applied'
|
|
618
|
+
);
|
|
619
|
+
}
|
|
620
|
+
if (deferredForms.length > 0) {
|
|
621
|
+
gateReasons.push('deferred-forms-or-occurrences');
|
|
622
|
+
}
|
|
623
|
+
const open = modifiedOccurrences.length + deferredOccurrences.length;
|
|
624
|
+
|
|
625
|
+
return {
|
|
626
|
+
entries: [
|
|
627
|
+
...entries,
|
|
628
|
+
...params.skipped.map((entry) => ({
|
|
629
|
+
outcome: 'skip' as const,
|
|
630
|
+
path: entry.path,
|
|
631
|
+
reason: entry.reason,
|
|
632
|
+
})),
|
|
633
|
+
...scopeSkipped.map((entry) => ({
|
|
634
|
+
outcome: 'skip' as const,
|
|
635
|
+
path: entry.path,
|
|
636
|
+
reason: entry.reason,
|
|
637
|
+
})),
|
|
638
|
+
].toSorted((left, right) => left.path.localeCompare(right.path)),
|
|
639
|
+
occurrences,
|
|
640
|
+
run: {
|
|
641
|
+
ledger: {
|
|
642
|
+
cycle: 1,
|
|
643
|
+
forms,
|
|
644
|
+
occurrences: occurrences.map(
|
|
645
|
+
({ absolutePath: _absolutePath, ...occurrence }) => occurrence
|
|
646
|
+
),
|
|
647
|
+
},
|
|
648
|
+
plan: params.plan,
|
|
649
|
+
report: {
|
|
650
|
+
applied: params.apply === true ? modifiedOccurrences.length : 0,
|
|
651
|
+
deferred: deferredOccurrences.length,
|
|
652
|
+
filesChanged: params.apply === true ? rewrittenPaths.size : 0,
|
|
653
|
+
gate: {
|
|
654
|
+
reasons: gateReasons,
|
|
655
|
+
remaining: open,
|
|
656
|
+
status: gateReasons.length === 0 ? 'green' : 'open',
|
|
657
|
+
},
|
|
658
|
+
modified: modifiedOccurrences.length,
|
|
659
|
+
open,
|
|
660
|
+
skipped: skippedOccurrences.length,
|
|
661
|
+
},
|
|
662
|
+
},
|
|
663
|
+
scanned: scopedFiles.length,
|
|
664
|
+
skipped: [...params.skipped, ...scopeSkipped],
|
|
665
|
+
};
|
|
666
|
+
};
|
|
667
|
+
|
|
668
|
+
const skippedByReason = (
|
|
669
|
+
skipped: readonly SkippedSource[]
|
|
670
|
+
): Readonly<Record<string, number>> => {
|
|
671
|
+
const counts = new Map<string, number>();
|
|
672
|
+
for (const entry of skipped) {
|
|
673
|
+
counts.set(entry.reason, (counts.get(entry.reason) ?? 0) + 1);
|
|
674
|
+
}
|
|
675
|
+
return Object.fromEntries(
|
|
676
|
+
[...counts.entries()].toSorted(([left], [right]) =>
|
|
677
|
+
left.localeCompare(right)
|
|
678
|
+
)
|
|
679
|
+
);
|
|
680
|
+
};
|
|
681
|
+
|
|
682
|
+
const withApplySummary = (
|
|
683
|
+
report: RegradeReport,
|
|
684
|
+
apply: RegradeApplySummary
|
|
685
|
+
): RegradeReport => ({
|
|
686
|
+
...report,
|
|
687
|
+
apply,
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
const applyVocabularyEvaluation = (
|
|
691
|
+
files: readonly SourceFile[],
|
|
692
|
+
evaluation: VocabularyEvaluation
|
|
693
|
+
): Result<RegradeApplySummary, InternalError> => {
|
|
694
|
+
const changedFiles = new Set<string>();
|
|
695
|
+
let applied = 0;
|
|
696
|
+
for (const file of files) {
|
|
697
|
+
const fileOccurrences = evaluation.occurrences.filter(
|
|
698
|
+
(occurrence) =>
|
|
699
|
+
occurrence.path === file.path && occurrence.verdict === 'modified'
|
|
700
|
+
);
|
|
701
|
+
if (fileOccurrences.length === 0) {
|
|
702
|
+
continue;
|
|
703
|
+
}
|
|
704
|
+
const nextSource = applyOccurrenceRewrites(file, fileOccurrences);
|
|
705
|
+
if (nextSource === file.source) {
|
|
706
|
+
continue;
|
|
707
|
+
}
|
|
708
|
+
try {
|
|
709
|
+
writeFileSync(file.absolutePath, nextSource, 'utf8');
|
|
710
|
+
} catch (error: unknown) {
|
|
711
|
+
return Result.err(
|
|
712
|
+
new InternalError(
|
|
713
|
+
`Failed to apply vocabulary regrade rewrite for "${file.path}".`,
|
|
714
|
+
{
|
|
715
|
+
cause: error instanceof Error ? error : new Error(String(error)),
|
|
716
|
+
context: {
|
|
717
|
+
applied,
|
|
718
|
+
filesChanged: changedFiles.size,
|
|
719
|
+
path: file.path,
|
|
720
|
+
},
|
|
721
|
+
}
|
|
722
|
+
)
|
|
723
|
+
);
|
|
724
|
+
}
|
|
725
|
+
applied += fileOccurrences.length;
|
|
726
|
+
changedFiles.add(file.path);
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
const reviewFiles = new Set(
|
|
730
|
+
evaluation.occurrences
|
|
731
|
+
.filter((occurrence) => occurrence.verdict === 'deferred')
|
|
732
|
+
.map((occurrence) => occurrence.path)
|
|
733
|
+
);
|
|
734
|
+
const skippedOccurrences = evaluation.occurrences.filter(
|
|
735
|
+
(occurrence) => occurrence.verdict === 'skipped'
|
|
736
|
+
);
|
|
737
|
+
|
|
738
|
+
return Result.ok({
|
|
739
|
+
applied,
|
|
740
|
+
filesChanged: changedFiles.size,
|
|
741
|
+
review: reviewFiles.size,
|
|
742
|
+
skipped: skippedOccurrences.length + evaluation.skipped.length,
|
|
743
|
+
unknown: 0,
|
|
744
|
+
});
|
|
745
|
+
};
|
|
746
|
+
|
|
747
|
+
export const runVocabularyRegrade = (params: {
|
|
748
|
+
readonly apply?: boolean;
|
|
749
|
+
readonly includeEntries?: 'actionable' | 'all';
|
|
750
|
+
readonly plan: VocabularyRegradePlan;
|
|
751
|
+
readonly root: string;
|
|
752
|
+
}): Result<RegradeReport | null, InternalError | ValidationError> => {
|
|
753
|
+
const planValidation = validateVocabularyPlan(params.plan);
|
|
754
|
+
if (planValidation.isErr()) {
|
|
755
|
+
return planValidation;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
const collected = collectDownstreamSources(params.root, {
|
|
759
|
+
extensions: params.plan.scope?.extensions ?? VOCABULARY_SOURCE_EXTENSIONS,
|
|
760
|
+
...(params.plan.scope?.exclude === undefined
|
|
761
|
+
? {}
|
|
762
|
+
: { exclude: params.plan.scope.exclude }),
|
|
763
|
+
...(params.plan.scope?.ignoredDirectories === undefined
|
|
764
|
+
? {}
|
|
765
|
+
: { ignoredDirectories: params.plan.scope.ignoredDirectories }),
|
|
766
|
+
} satisfies DownstreamCollectionOptions);
|
|
767
|
+
if (collected === null) {
|
|
768
|
+
return Result.ok(null);
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
const files: SourceFile[] = [];
|
|
772
|
+
const skipped: SkippedSource[] = [...collected.skipped];
|
|
773
|
+
for (const file of collected.files) {
|
|
774
|
+
try {
|
|
775
|
+
files.push({
|
|
776
|
+
absolutePath: file.absolutePath,
|
|
777
|
+
path: file.path,
|
|
778
|
+
source: readFileSync(file.absolutePath, 'utf8'),
|
|
779
|
+
});
|
|
780
|
+
} catch {
|
|
781
|
+
skipped.push({ path: file.path, reason: 'unreadable-file' });
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
const dryRunEvaluation = buildVocabularyEvaluation({
|
|
786
|
+
apply: false,
|
|
787
|
+
files,
|
|
788
|
+
plan: params.plan,
|
|
789
|
+
root: params.root,
|
|
790
|
+
skipped,
|
|
791
|
+
});
|
|
792
|
+
let reportEvaluation = dryRunEvaluation;
|
|
793
|
+
let applySummary: RegradeApplySummary | undefined;
|
|
794
|
+
|
|
795
|
+
if (params.apply === true) {
|
|
796
|
+
const applyResult = applyVocabularyEvaluation(files, dryRunEvaluation);
|
|
797
|
+
if (applyResult.isErr()) {
|
|
798
|
+
return applyResult;
|
|
799
|
+
}
|
|
800
|
+
applySummary = applyResult.value;
|
|
801
|
+
const appliedFiles = files.map((file) => ({
|
|
802
|
+
...file,
|
|
803
|
+
source: readFileSync(file.absolutePath, 'utf8'),
|
|
804
|
+
}));
|
|
805
|
+
reportEvaluation = buildVocabularyEvaluation({
|
|
806
|
+
apply: true,
|
|
807
|
+
files: appliedFiles,
|
|
808
|
+
plan: params.plan,
|
|
809
|
+
root: params.root,
|
|
810
|
+
skipped,
|
|
811
|
+
});
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
const entrySelection = params.includeEntries ?? 'actionable';
|
|
815
|
+
const reportEntries = reportEvaluation.entries;
|
|
816
|
+
const actionableEntries = reportEntries.filter(
|
|
817
|
+
(entry) => entry.outcome === 'rewrite' || entry.outcome === 'needs-review'
|
|
818
|
+
);
|
|
819
|
+
const reportSkippedByReason = skippedByReason(reportEvaluation.skipped);
|
|
820
|
+
const report: RegradeReport = {
|
|
821
|
+
entries: entrySelection === 'all' ? reportEntries : actionableEntries,
|
|
822
|
+
matched: actionableEntries.length,
|
|
823
|
+
review: reportEntries.filter((entry) => entry.outcome === 'needs-review')
|
|
824
|
+
.length,
|
|
825
|
+
rewritten: reportEntries.filter((entry) => entry.outcome === 'rewrite')
|
|
826
|
+
.length,
|
|
827
|
+
root: collected.root,
|
|
828
|
+
run:
|
|
829
|
+
applySummary === undefined
|
|
830
|
+
? reportEvaluation.run
|
|
831
|
+
: {
|
|
832
|
+
...reportEvaluation.run,
|
|
833
|
+
report: {
|
|
834
|
+
...reportEvaluation.run.report,
|
|
835
|
+
applied: applySummary.applied,
|
|
836
|
+
filesChanged: applySummary.filesChanged,
|
|
837
|
+
},
|
|
838
|
+
},
|
|
839
|
+
scan: buildRegradeScanSummary({
|
|
840
|
+
matchedPaths: actionableEntries.map((entry) => entry.path),
|
|
841
|
+
occurrencePaths: reportEvaluation.occurrences.map(
|
|
842
|
+
(occurrence) => occurrence.path
|
|
843
|
+
),
|
|
844
|
+
scanned: reportEvaluation.scanned,
|
|
845
|
+
skipped: reportEvaluation.skipped.length,
|
|
846
|
+
skippedByReason: reportSkippedByReason,
|
|
847
|
+
}),
|
|
848
|
+
scanned: reportEvaluation.scanned,
|
|
849
|
+
selectedClassIds: [
|
|
850
|
+
params.plan.id ?? `vocabulary:${params.plan.from}->${params.plan.to}`,
|
|
851
|
+
],
|
|
852
|
+
skipped: reportEvaluation.skipped.length,
|
|
853
|
+
skipsByReason: reportSkippedByReason,
|
|
854
|
+
unknownClassIds: [],
|
|
855
|
+
};
|
|
856
|
+
|
|
857
|
+
return Result.ok(
|
|
858
|
+
applySummary === undefined ? report : withApplySummary(report, applySummary)
|
|
859
|
+
);
|
|
860
|
+
};
|
|
861
|
+
|
|
862
|
+
const vocabularyPreserveRuleSchema = z.object({
|
|
863
|
+
paths: z
|
|
864
|
+
.array(z.string())
|
|
865
|
+
.optional()
|
|
866
|
+
.describe('Root-relative path patterns where the preserve rule applies'),
|
|
867
|
+
pattern: z.string().describe('Regex or literal pattern to preserve'),
|
|
868
|
+
reason: z.string().optional().describe('Why this form is preserved'),
|
|
869
|
+
});
|
|
870
|
+
|
|
871
|
+
const vocabularyRegradeScopeSchema = z.object({
|
|
872
|
+
exclude: z
|
|
873
|
+
.array(z.string())
|
|
874
|
+
.optional()
|
|
875
|
+
.describe('Root-relative path patterns to exclude from this regrade'),
|
|
876
|
+
extensions: z
|
|
877
|
+
.array(z.string())
|
|
878
|
+
.optional()
|
|
879
|
+
.describe('Source file extensions to scan for this regrade'),
|
|
880
|
+
ignoredDirectories: z
|
|
881
|
+
.array(z.string())
|
|
882
|
+
.optional()
|
|
883
|
+
.describe(
|
|
884
|
+
'Deprecated compatibility override for legacy plans. Use exclude globs for new plans.'
|
|
885
|
+
),
|
|
886
|
+
include: z
|
|
887
|
+
.array(z.string())
|
|
888
|
+
.optional()
|
|
889
|
+
.describe('Root-relative path patterns to include in this regrade'),
|
|
890
|
+
});
|
|
891
|
+
|
|
892
|
+
export const vocabularyRegradePlanSchema = z.object({
|
|
893
|
+
from: z.string().min(1).describe('Source vocabulary term or phrase'),
|
|
894
|
+
id: z.string().optional().describe('Stable authored regrade plan id'),
|
|
895
|
+
intent: z.string().optional().describe('Human-authored migration intent'),
|
|
896
|
+
kind: z.literal('vocabulary').describe('Regrade plan kind'),
|
|
897
|
+
overrides: z
|
|
898
|
+
.record(z.string().min(1), z.string().min(1))
|
|
899
|
+
.optional()
|
|
900
|
+
.describe('Explicit source-form to target-form mappings'),
|
|
901
|
+
preserve: z
|
|
902
|
+
.array(vocabularyPreserveRuleSchema)
|
|
903
|
+
.optional()
|
|
904
|
+
.describe('Forms or contexts that are intentionally preserved'),
|
|
905
|
+
scope: vocabularyRegradeScopeSchema
|
|
906
|
+
.optional()
|
|
907
|
+
.describe('Source scope for this regrade plan'),
|
|
908
|
+
to: z.string().min(1).describe('Target vocabulary term or phrase'),
|
|
909
|
+
});
|
|
910
|
+
|
|
911
|
+
export const vocabularyRegradeRunOutput = z.object({
|
|
912
|
+
ledger: z
|
|
913
|
+
.object({
|
|
914
|
+
cycle: z.number().describe('Observed regrade run cycle'),
|
|
915
|
+
forms: z
|
|
916
|
+
.record(z.string(), z.enum(['deferred', 'modified', 'skipped']))
|
|
917
|
+
.describe('Observed per-form triage verdicts for this run'),
|
|
918
|
+
occurrences: z
|
|
919
|
+
.array(
|
|
920
|
+
z.object({
|
|
921
|
+
column: z.number().describe('One-based source column'),
|
|
922
|
+
context: z.string().describe('Source-line context'),
|
|
923
|
+
end: z.number().describe('Source end offset'),
|
|
924
|
+
form: z.string().describe('Matched vocabulary form'),
|
|
925
|
+
line: z.number().describe('One-based source line'),
|
|
926
|
+
path: z.string().describe('Root-relative POSIX path'),
|
|
927
|
+
reason: z.string().describe('Why the occurrence got this verdict'),
|
|
928
|
+
replacement: z
|
|
929
|
+
.string()
|
|
930
|
+
.optional()
|
|
931
|
+
.describe('Replacement text for modified verdicts'),
|
|
932
|
+
start: z.number().describe('Source start offset'),
|
|
933
|
+
verdict: z
|
|
934
|
+
.enum(['deferred', 'modified', 'skipped'])
|
|
935
|
+
.describe('Occurrence-level verdict'),
|
|
936
|
+
})
|
|
937
|
+
)
|
|
938
|
+
.describe('Observed occurrence-level ledger for this run'),
|
|
939
|
+
})
|
|
940
|
+
.describe('Observed run ledger'),
|
|
941
|
+
plan: vocabularyRegradePlanSchema.describe('Authored regrade plan'),
|
|
942
|
+
report: z
|
|
943
|
+
.object({
|
|
944
|
+
applied: z.number().describe('Modified occurrences applied to disk'),
|
|
945
|
+
deferred: z.number().describe('Deferred occurrence count'),
|
|
946
|
+
filesChanged: z.number().describe('Distinct files changed on disk'),
|
|
947
|
+
gate: z
|
|
948
|
+
.object({
|
|
949
|
+
reasons: z.array(z.string()).describe('Open-gate reasons'),
|
|
950
|
+
remaining: z.number().describe('Unresolved occurrence count'),
|
|
951
|
+
status: z
|
|
952
|
+
.enum(['green', 'open'])
|
|
953
|
+
.describe('Whether the run is complete'),
|
|
954
|
+
})
|
|
955
|
+
.describe('Completion gate derived from the run ledger'),
|
|
956
|
+
modified: z.number().describe('Modified occurrence count'),
|
|
957
|
+
open: z
|
|
958
|
+
.number()
|
|
959
|
+
.describe(
|
|
960
|
+
'Deferred or unapplied modified occurrences holding the gate open'
|
|
961
|
+
),
|
|
962
|
+
skipped: z.number().describe('Skipped occurrence count'),
|
|
963
|
+
})
|
|
964
|
+
.describe('Projected run report'),
|
|
965
|
+
});
|