@ontrails/regrade 1.0.0-beta.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,969 @@
1
+ import { InternalError, Result } from '@ontrails/core';
2
+ import type {
3
+ WardenDiagnostic,
4
+ WardenFixEdit,
5
+ WardenRule,
6
+ } from '@ontrails/warden';
7
+ import {
8
+ getWardenRuleMetadata,
9
+ isWardenSourceScanTarget,
10
+ wardenRules,
11
+ } from '@ontrails/warden';
12
+ import { readFileSync, readdirSync, writeFileSync } from 'node:fs';
13
+ import { z } from 'zod';
14
+
15
+ import {
16
+ DEFAULT_IGNORED_DIRECTORIES,
17
+ DEFAULT_SOURCE_EXTENSIONS,
18
+ collectDownstreamSources,
19
+ } from './collect.js';
20
+ import type { DownstreamCollectionOptions, SkippedSource } from './collect.js';
21
+
22
+ /**
23
+ * Regrade-class selection and coverage reporting (TRL-845).
24
+ *
25
+ * A regrade class is one named, contract-aware transform (for example a single
26
+ * vocabulary rename). Selection lets a run apply one class without executing
27
+ * every available transform, and {@link RegradeReport} captures coverage —
28
+ * what was scanned, matched, rewritten, routed to review, and skipped — with
29
+ * enough per-entry detail to debug why a file was omitted.
30
+ *
31
+ * The report logic is pure ({@link buildRegradeReport}); the filesystem walk
32
+ * and file reads live in {@link runRegrade} and the wrapping trail. This keeps
33
+ * coverage semantics testable without disk and consistent with the downstream
34
+ * collection substrate (TRL-844).
35
+ */
36
+
37
+ /**
38
+ * Outcome a regrade class produces for a single source file. `skipped` means
39
+ * the class declined to inspect the file (for example, a scan-target filter
40
+ * excluded it) and it must not count as a scanned/clean no-op.
41
+ */
42
+ export type RegradeOutcomeKind =
43
+ | 'needs-review'
44
+ | 'no-op'
45
+ | 'rewrite'
46
+ | 'skipped';
47
+
48
+ /** Result of applying one regrade class to one source string. */
49
+ export interface RegradeClassResult {
50
+ readonly kind: RegradeOutcomeKind;
51
+ /** Rewritten source, present only when `kind` is `rewrite`. */
52
+ readonly nextSource?: string;
53
+ /** Human-readable notes explaining the outcome. */
54
+ readonly notes: readonly string[];
55
+ /** Machine-readable reason for review outcomes. */
56
+ readonly reason?: string;
57
+ /** Structured details for review outcomes. */
58
+ readonly reviewDetails?: readonly RegradeReviewDetail[];
59
+ }
60
+
61
+ /** Source-file context passed to Regrade classes. */
62
+ export interface RegradeClassContext {
63
+ /** Root-relative POSIX path. */
64
+ readonly path: string;
65
+ /** Absolute path on disk, when the caller has one. */
66
+ readonly absolutePath?: string;
67
+ }
68
+
69
+ /** Files a regrade class knows how to inspect. */
70
+ export interface RegradeScanTargets {
71
+ /** Source extensions the class can inspect. */
72
+ readonly extensions?: readonly string[];
73
+ /** Directory names to skip during collection. */
74
+ readonly ignoredDirectories?: readonly string[];
75
+ }
76
+
77
+ /** One named, contract-aware transform. */
78
+ export interface RegradeClass {
79
+ /** Stable identifier, e.g. `term-rewrite:signal->ping`. */
80
+ readonly id: string;
81
+ /** What the class does, for report and guide surfaces. */
82
+ readonly describe: string;
83
+ /** Apply the class to a source string. Must be pure and never throw. */
84
+ readonly apply: (
85
+ source: string,
86
+ context?: RegradeClassContext
87
+ ) => RegradeClassResult;
88
+ /** Scan targets this class knows how to inspect. */
89
+ readonly scanTargets?: RegradeScanTargets;
90
+ }
91
+
92
+ /** Which regrade classes a run should execute. */
93
+ export interface RegradeSelection {
94
+ /** Class ids to run. Omit to run every provided class. */
95
+ readonly classIds?: readonly string[];
96
+ }
97
+
98
+ /** Optional write summary for an apply-mode regrade run. */
99
+ export interface RegradeApplySummary {
100
+ /** Safe rewrite outcomes written to disk. */
101
+ readonly applied: number;
102
+ /** Distinct files changed on disk. */
103
+ readonly filesChanged: number;
104
+ /** Rewrite candidates intentionally not written. */
105
+ readonly skipped: number;
106
+ /** Files still requiring review. */
107
+ readonly review: number;
108
+ /** Unknown selected class ids; apply mode writes nothing when non-zero. */
109
+ readonly unknown: number;
110
+ }
111
+
112
+ /** Source location for a review-required match. */
113
+ export interface RegradeReviewSpan {
114
+ readonly column: number;
115
+ readonly end: number;
116
+ readonly line: number;
117
+ readonly start: number;
118
+ }
119
+
120
+ /** Structured detail explaining why a source match needs review. */
121
+ export interface RegradeReviewDetail {
122
+ /** Class that produced the review detail, injected by report building. */
123
+ readonly classId?: string;
124
+ /** Expected target shape when the class can describe one. */
125
+ readonly expectedTarget?: string;
126
+ /** Fixture or example reference that illustrates the expected migration. */
127
+ readonly fixture?: string;
128
+ /** AST node kind or source construct kind. */
129
+ readonly nodeKind?: string;
130
+ /** Machine-readable reason for review. */
131
+ readonly reason: string;
132
+ /** Source span and line/column for the review-required match. */
133
+ readonly span?: RegradeReviewSpan;
134
+ /** Suggested validation command after the review is resolved. */
135
+ readonly suggestedValidation?: string;
136
+ /** Symbol or term that triggered review. */
137
+ readonly symbol?: string;
138
+ }
139
+
140
+ const escapeRegExp = (value: string): string =>
141
+ value.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&');
142
+
143
+ /**
144
+ * Build a whole-word term-rewrite class.
145
+ *
146
+ * Whole-word occurrences of `from` are rewritten to `to`. When `from` appears
147
+ * only as part of a larger identifier (an ambiguous partial match), the class
148
+ * routes the file to review instead of rewriting it — the canonical
149
+ * "rename `signal` but do not touch `signalHandler`" case. This standalone
150
+ * class anticipates TRL-832/836, where the mappings become Warden-owned.
151
+ *
152
+ * Matching is raw-text and lexer-unaware: a whole-word `from` inside a comment
153
+ * or string literal counts exactly like a code reference (it is rewritten, and
154
+ * a partial occurrence there still routes the file to review). For a vocabulary
155
+ * migration this is usually desirable — comments and docs should track the
156
+ * rename too — but it means callers cannot assume comment/string occurrences
157
+ * are skipped. Lexer/AST-aware exclusion is deferred to the Warden-owned
158
+ * term-rewrite metadata work (TRL-832/836).
159
+ */
160
+ export const createTermRewriteClass = (options: {
161
+ readonly from: string;
162
+ readonly to: string;
163
+ readonly id?: string;
164
+ readonly describe?: string;
165
+ }): RegradeClass => {
166
+ const { from, to } = options;
167
+ const wholeWord = new RegExp(`\\b${escapeRegExp(from)}\\b`, 'g');
168
+ return {
169
+ apply: (source: string): RegradeClassResult => {
170
+ const matches = source.match(wholeWord);
171
+ const hasAmbiguousPartial = source.replace(wholeWord, '').includes(from);
172
+ if (hasAmbiguousPartial) {
173
+ return {
174
+ kind: 'needs-review',
175
+ notes: [
176
+ `Found "${from}" inside larger identifiers; routed to review.`,
177
+ ],
178
+ reason: 'ambiguous-match',
179
+ };
180
+ }
181
+ if (matches && matches.length > 0) {
182
+ return {
183
+ kind: 'rewrite',
184
+ nextSource: source.replace(wholeWord, to),
185
+ notes: [`Rewrote ${matches.length} whole-word "${from}" -> "${to}".`],
186
+ };
187
+ }
188
+ return { kind: 'no-op', notes: [`No "${from}" occurrences found.`] };
189
+ },
190
+ describe: options.describe ?? `Rewrite "${from}" to "${to}".`,
191
+ id: options.id ?? `term-rewrite:${from}->${to}`,
192
+ };
193
+ };
194
+
195
+ const TERM_REWRITE_FIX_CLASS = 'term-rewrite';
196
+
197
+ type WardenEditApplication =
198
+ | { readonly ok: true; readonly nextSource: string }
199
+ | { readonly ok: false; readonly reason: string };
200
+
201
+ const diagnosticNote = (diagnostic: WardenDiagnostic): string => {
202
+ const reason = diagnostic.fix?.reason ?? diagnostic.message;
203
+ return `${diagnostic.rule}:${diagnostic.line}: ${reason}`;
204
+ };
205
+
206
+ const firstQuotedValue = (value: string | undefined): string | undefined => {
207
+ if (value === undefined) {
208
+ return undefined;
209
+ }
210
+ const match = /['"]([^'"]+)['"]/.exec(value);
211
+ return match?.[1];
212
+ };
213
+
214
+ const reviewSpanForOffsets = (
215
+ source: string,
216
+ start: number,
217
+ end: number
218
+ ): RegradeReviewSpan => {
219
+ let line = 1;
220
+ let column = 1;
221
+ for (let index = 0; index < start; index += 1) {
222
+ if (source.codePointAt(index) === 10) {
223
+ line += 1;
224
+ column = 1;
225
+ } else {
226
+ column += 1;
227
+ }
228
+ }
229
+ return { column, end, line, start };
230
+ };
231
+
232
+ const spanForSymbolOnDiagnosticLine = (
233
+ source: string,
234
+ line: number,
235
+ symbol: string
236
+ ): RegradeReviewSpan | undefined => {
237
+ if (line < 1) {
238
+ return undefined;
239
+ }
240
+ let lineStart = 0;
241
+ for (let currentLine = 1; currentLine < line; currentLine += 1) {
242
+ const nextLineStart = source.indexOf('\n', lineStart);
243
+ if (nextLineStart === -1) {
244
+ return undefined;
245
+ }
246
+ lineStart = nextLineStart + 1;
247
+ }
248
+ const nextLineStart = source.indexOf('\n', lineStart);
249
+ const lineEnd = nextLineStart === -1 ? source.length : nextLineStart;
250
+ const symbolIndex = source.indexOf(symbol, lineStart);
251
+ if (symbolIndex === -1 || symbolIndex >= lineEnd) {
252
+ return undefined;
253
+ }
254
+ const nextSymbolIndex = source.indexOf(symbol, symbolIndex + symbol.length);
255
+ if (nextSymbolIndex !== -1 && nextSymbolIndex < lineEnd) {
256
+ return undefined;
257
+ }
258
+ return reviewSpanForOffsets(source, symbolIndex, symbolIndex + symbol.length);
259
+ };
260
+
261
+ const diagnosticSpan = (
262
+ source: string,
263
+ diagnostic: WardenDiagnostic,
264
+ symbol: string | undefined
265
+ ): RegradeReviewSpan | undefined => {
266
+ const [edit] = diagnostic.fix?.edits ?? [];
267
+ if (edit !== undefined) {
268
+ return reviewSpanForOffsets(source, edit.start, edit.end);
269
+ }
270
+ if (symbol === undefined) {
271
+ return undefined;
272
+ }
273
+ return spanForSymbolOnDiagnosticLine(source, diagnostic.line, symbol);
274
+ };
275
+
276
+ const expectedTarget = (diagnostic: WardenDiagnostic): string | undefined => {
277
+ const replacements = new Set(
278
+ (diagnostic.fix?.edits ?? []).map((edit) => edit.replacement)
279
+ );
280
+ if (replacements.size !== 1) {
281
+ return undefined;
282
+ }
283
+ const [replacement] = replacements;
284
+ return replacement === undefined
285
+ ? undefined
286
+ : `Replace with "${replacement}".`;
287
+ };
288
+
289
+ const reviewDetailsFromDiagnostics = (
290
+ source: string,
291
+ diagnostics: readonly WardenDiagnostic[],
292
+ reason: string
293
+ ): readonly RegradeReviewDetail[] | undefined => {
294
+ const details = diagnostics.map((diagnostic) => {
295
+ const symbol =
296
+ firstQuotedValue(diagnostic.fix?.reason) ??
297
+ firstQuotedValue(diagnostic.message);
298
+ const span = diagnosticSpan(source, diagnostic, symbol);
299
+ const target = expectedTarget(diagnostic);
300
+ return {
301
+ ...(target === undefined ? {} : { expectedTarget: target }),
302
+ ...(diagnostic.fix?.fixture === undefined
303
+ ? {}
304
+ : { fixture: diagnostic.fix.fixture }),
305
+ reason,
306
+ ...(span === undefined ? {} : { span }),
307
+ ...(symbol === undefined ? {} : { symbol }),
308
+ } satisfies RegradeReviewDetail;
309
+ });
310
+ return details.length === 0 ? undefined : details;
311
+ };
312
+
313
+ const wardenFilePath = (context: RegradeClassContext | undefined): string =>
314
+ context?.absolutePath ?? context?.path ?? '<regrade-source>';
315
+
316
+ const wardenScanPath = (context: RegradeClassContext | undefined): string =>
317
+ context?.path ?? context?.absolutePath ?? '<regrade-source>';
318
+
319
+ const applyWardenEdits = (
320
+ source: string,
321
+ edits: readonly WardenFixEdit[]
322
+ ): WardenEditApplication => {
323
+ const ordered = [...edits].toSorted((a, b) => a.start - b.start);
324
+ let previousEnd = 0;
325
+ for (const edit of ordered) {
326
+ if (
327
+ !Number.isInteger(edit.start) ||
328
+ !Number.isInteger(edit.end) ||
329
+ edit.start < 0 ||
330
+ edit.end < edit.start ||
331
+ edit.end > source.length
332
+ ) {
333
+ return { ok: false, reason: 'invalid-edit-span' };
334
+ }
335
+ if (edit.start < previousEnd) {
336
+ return { ok: false, reason: 'overlapping-edit-spans' };
337
+ }
338
+ previousEnd = edit.end;
339
+ }
340
+
341
+ let nextSource = source;
342
+ for (const edit of ordered.toReversed()) {
343
+ nextSource =
344
+ nextSource.slice(0, edit.start) +
345
+ edit.replacement +
346
+ nextSource.slice(edit.end);
347
+ }
348
+ return { nextSource, ok: true };
349
+ };
350
+
351
+ export const createWardenTermRewriteClass = (
352
+ rule: WardenRule
353
+ ): RegradeClass | null => {
354
+ const metadata = getWardenRuleMetadata(rule.name);
355
+ if (metadata?.fix?.class !== TERM_REWRITE_FIX_CLASS) {
356
+ return null;
357
+ }
358
+
359
+ return {
360
+ apply: (
361
+ source: string,
362
+ context?: RegradeClassContext
363
+ ): RegradeClassResult => {
364
+ if (!isWardenSourceScanTarget(wardenScanPath(context))) {
365
+ return {
366
+ kind: 'skipped',
367
+ notes: ['Skipped by Warden source scan-target filtering.'],
368
+ reason: 'warden-scan-target-filtered',
369
+ };
370
+ }
371
+
372
+ const diagnostics = rule
373
+ .check(source, wardenFilePath(context))
374
+ .filter(
375
+ (diagnostic) => diagnostic.fix?.class === TERM_REWRITE_FIX_CLASS
376
+ );
377
+
378
+ if (diagnostics.length === 0) {
379
+ return {
380
+ kind: 'no-op',
381
+ notes: [`No Warden ${TERM_REWRITE_FIX_CLASS} diagnostics found.`],
382
+ };
383
+ }
384
+
385
+ const reviewDiagnostics = diagnostics.filter(
386
+ (diagnostic) => diagnostic.fix?.safety !== 'safe'
387
+ );
388
+ if (reviewDiagnostics.length > 0) {
389
+ const reviewDetails = reviewDetailsFromDiagnostics(
390
+ source,
391
+ reviewDiagnostics,
392
+ 'warden-review-required'
393
+ );
394
+ return {
395
+ kind: 'needs-review',
396
+ notes: reviewDiagnostics.map(diagnosticNote),
397
+ reason: 'warden-review-required',
398
+ ...(reviewDetails === undefined ? {} : { reviewDetails }),
399
+ };
400
+ }
401
+
402
+ const diagnosticsMissingEdits = diagnostics.filter(
403
+ (diagnostic) => (diagnostic.fix?.edits?.length ?? 0) === 0
404
+ );
405
+ if (diagnosticsMissingEdits.length > 0) {
406
+ const reviewDetails = reviewDetailsFromDiagnostics(
407
+ source,
408
+ diagnosticsMissingEdits,
409
+ 'warden-fix-missing-edits'
410
+ );
411
+ return {
412
+ kind: 'needs-review',
413
+ notes: diagnostics.map(diagnosticNote),
414
+ reason: 'warden-fix-missing-edits',
415
+ ...(reviewDetails === undefined ? {} : { reviewDetails }),
416
+ };
417
+ }
418
+
419
+ const edits = diagnostics.flatMap(
420
+ (diagnostic) => diagnostic.fix?.edits ?? []
421
+ );
422
+ const application = applyWardenEdits(source, edits);
423
+ if (!application.ok) {
424
+ const reviewDetails = reviewDetailsFromDiagnostics(
425
+ source,
426
+ diagnostics,
427
+ 'warden-fix-invalid'
428
+ );
429
+ return {
430
+ kind: 'needs-review',
431
+ notes: [
432
+ ...diagnostics.map(diagnosticNote),
433
+ `Warden fix edits could not be applied: ${application.reason}.`,
434
+ ],
435
+ reason: 'warden-fix-invalid',
436
+ ...(reviewDetails === undefined ? {} : { reviewDetails }),
437
+ };
438
+ }
439
+
440
+ return {
441
+ kind: 'rewrite',
442
+ nextSource: application.nextSource,
443
+ notes: diagnostics.map(diagnosticNote),
444
+ };
445
+ },
446
+ describe: `${rule.description} (${metadata.fix.safety} ${metadata.fix.class})`,
447
+ id: `${metadata.fix.class}:${rule.name}`,
448
+ };
449
+ };
450
+
451
+ /**
452
+ * Resolve the selected classes, preserving the order of `classIds` when given.
453
+ * Unknown selected ids are returned so callers can report them.
454
+ */
455
+ export const selectRegradeClasses = (
456
+ classes: readonly RegradeClass[],
457
+ selection: RegradeSelection = {}
458
+ ): {
459
+ readonly selected: readonly RegradeClass[];
460
+ readonly unknownClassIds: readonly string[];
461
+ } => {
462
+ if (selection.classIds === undefined) {
463
+ return { selected: classes, unknownClassIds: [] };
464
+ }
465
+ const byId = new Map(classes.map((cls) => [cls.id, cls]));
466
+ const selected: RegradeClass[] = [];
467
+ const unknownClassIds: string[] = [];
468
+ for (const id of selection.classIds) {
469
+ const cls = byId.get(id);
470
+ if (cls === undefined) {
471
+ unknownClassIds.push(id);
472
+ } else {
473
+ selected.push(cls);
474
+ }
475
+ }
476
+ return { selected, unknownClassIds };
477
+ };
478
+
479
+ const uniqueSorted = (values: readonly string[]): readonly string[] =>
480
+ [...new Set(values)].toSorted((a, b) => a.localeCompare(b));
481
+
482
+ const deriveCollectionOptions = (
483
+ classes: readonly RegradeClass[],
484
+ collection: DownstreamCollectionOptions | undefined
485
+ ): DownstreamCollectionOptions => {
486
+ const targetExtensions = uniqueSorted(
487
+ classes.length === 0
488
+ ? DEFAULT_SOURCE_EXTENSIONS
489
+ : classes.flatMap(
490
+ (cls) => cls.scanTargets?.extensions ?? DEFAULT_SOURCE_EXTENSIONS
491
+ )
492
+ );
493
+ const ignoredDirectories = uniqueSorted(
494
+ classes.length === 0
495
+ ? DEFAULT_IGNORED_DIRECTORIES
496
+ : classes.flatMap(
497
+ (cls) =>
498
+ cls.scanTargets?.ignoredDirectories ?? DEFAULT_IGNORED_DIRECTORIES
499
+ )
500
+ );
501
+
502
+ return {
503
+ extensions: collection?.extensions ?? targetExtensions,
504
+ ignoredDirectories: collection?.ignoredDirectories ?? ignoredDirectories,
505
+ };
506
+ };
507
+
508
+ /** Per-entry detail describing what happened to one path. */
509
+ export interface RegradeReportEntry {
510
+ /** Root-relative POSIX path. */
511
+ readonly path: string;
512
+ /** What happened to the entry. */
513
+ readonly outcome: 'needs-review' | 'no-op' | 'rewrite' | 'skip';
514
+ /** Class that produced a rewrite or review outcome. */
515
+ readonly classId?: string;
516
+ /** Reason for a skip or review outcome. */
517
+ readonly reason?: string;
518
+ /** Notes carried from the producing class. */
519
+ readonly notes?: readonly string[];
520
+ /** Structured review details carried from the producing class. */
521
+ readonly reviewDetails?: readonly RegradeReviewDetail[];
522
+ }
523
+
524
+ /** Coverage report for a regrade run. */
525
+ export interface RegradeReport {
526
+ /** Root the run scanned. */
527
+ readonly root: string;
528
+ /** Class ids that were executed. */
529
+ readonly selectedClassIds: readonly string[];
530
+ /** Selected class ids that did not resolve to a known class. */
531
+ readonly unknownClassIds: readonly string[];
532
+ /** Source files inspected. */
533
+ readonly scanned: number;
534
+ /** Files where a selected class produced a rewrite or review outcome. */
535
+ readonly matched: number;
536
+ /** Files with a rewrite outcome. */
537
+ readonly rewritten: number;
538
+ /** Files routed to review. */
539
+ readonly review: number;
540
+ /** Entries skipped (collection skips plus any run-level skips). */
541
+ readonly skipped: number;
542
+ /** Per-entry detail, sorted by path. */
543
+ readonly entries: readonly RegradeReportEntry[];
544
+ /** Apply-mode summary; absent for dry-run report-only calls. */
545
+ readonly apply?: RegradeApplySummary;
546
+ }
547
+
548
+ interface RegradeRewriteCandidate {
549
+ readonly absolutePath: string;
550
+ readonly classId: string;
551
+ readonly nextSource: string;
552
+ readonly path: string;
553
+ }
554
+
555
+ interface RegradeClassifiedFile {
556
+ readonly entry: RegradeReportEntry;
557
+ readonly rewrite?: RegradeRewriteCandidate;
558
+ }
559
+
560
+ const classifyFile = (
561
+ path: string,
562
+ source: string,
563
+ context: RegradeClassContext,
564
+ selected: readonly RegradeClass[]
565
+ ): RegradeClassifiedFile => {
566
+ // First selected class that matches (rewrite or review) wins, mirroring the
567
+ // "run one class" emphasis. A scan-target skip is remembered so the file is
568
+ // accounted as skipped rather than a scanned/clean no-op. No-ops fall through.
569
+ let skipped:
570
+ | { readonly classId: string; readonly result: RegradeClassResult }
571
+ | undefined;
572
+ for (const cls of selected) {
573
+ const result = cls.apply(source, context);
574
+ if (result.kind === 'rewrite') {
575
+ if (typeof result.nextSource !== 'string') {
576
+ return {
577
+ entry: {
578
+ classId: cls.id,
579
+ notes: result.notes,
580
+ outcome: 'needs-review',
581
+ path,
582
+ reason: 'regrade-rewrite-missing-source',
583
+ },
584
+ };
585
+ }
586
+ const entry = {
587
+ classId: cls.id,
588
+ notes: result.notes,
589
+ outcome: 'rewrite',
590
+ path,
591
+ } satisfies RegradeReportEntry;
592
+ return {
593
+ entry,
594
+ ...(context.absolutePath === undefined
595
+ ? {}
596
+ : {
597
+ rewrite: {
598
+ absolutePath: context.absolutePath,
599
+ classId: cls.id,
600
+ nextSource: result.nextSource,
601
+ path,
602
+ },
603
+ }),
604
+ };
605
+ }
606
+ if (result.kind === 'needs-review') {
607
+ const reviewDetails = result.reviewDetails?.map((detail) => ({
608
+ ...detail,
609
+ classId: detail.classId ?? cls.id,
610
+ }));
611
+ return {
612
+ entry: {
613
+ classId: cls.id,
614
+ notes: result.notes,
615
+ outcome: 'needs-review',
616
+ path,
617
+ reason: result.reason ?? 'needs-review',
618
+ ...(reviewDetails === undefined ? {} : { reviewDetails }),
619
+ },
620
+ };
621
+ }
622
+ if (result.kind === 'skipped' && skipped === undefined) {
623
+ skipped = { classId: cls.id, result };
624
+ }
625
+ }
626
+ if (skipped !== undefined) {
627
+ return {
628
+ entry: {
629
+ classId: skipped.classId,
630
+ notes: skipped.result.notes,
631
+ outcome: 'skip',
632
+ path,
633
+ reason: skipped.result.reason ?? 'skipped',
634
+ },
635
+ };
636
+ }
637
+ return { entry: { outcome: 'no-op', path } };
638
+ };
639
+
640
+ interface RegradeEvaluation {
641
+ readonly report: RegradeReport;
642
+ readonly rewrites: readonly RegradeRewriteCandidate[];
643
+ }
644
+
645
+ /**
646
+ * Build a coverage report from already-read source files. Pure: no filesystem
647
+ * access, so coverage semantics are testable directly.
648
+ */
649
+ const buildRegradeEvaluation = (params: {
650
+ readonly root: string;
651
+ readonly files: readonly {
652
+ readonly path: string;
653
+ readonly source: string;
654
+ readonly absolutePath?: string;
655
+ }[];
656
+ readonly skipped: readonly SkippedSource[];
657
+ readonly classes: readonly RegradeClass[];
658
+ readonly selection?: RegradeSelection;
659
+ }): RegradeEvaluation => {
660
+ const { selected, unknownClassIds } = selectRegradeClasses(
661
+ params.classes,
662
+ params.selection
663
+ );
664
+
665
+ const classifiedFiles = params.files.map((file) =>
666
+ classifyFile(
667
+ file.path,
668
+ file.source,
669
+ {
670
+ ...(file.absolutePath === undefined
671
+ ? {}
672
+ : { absolutePath: file.absolutePath }),
673
+ path: file.path,
674
+ },
675
+ selected
676
+ )
677
+ );
678
+ const fileEntries = classifiedFiles.map((file) => file.entry);
679
+ const rewrites = classifiedFiles.flatMap((file) =>
680
+ file.rewrite === undefined ? [] : [file.rewrite]
681
+ );
682
+ const skipEntries: RegradeReportEntry[] = params.skipped.map((entry) => ({
683
+ outcome: 'skip',
684
+ path: entry.path,
685
+ reason: entry.reason,
686
+ }));
687
+
688
+ const entries = [...fileEntries, ...skipEntries].toSorted((a, b) =>
689
+ a.path.localeCompare(b.path)
690
+ );
691
+
692
+ // Class-level skips (e.g. scan-target filtering) are accounted as skipped, not
693
+ // as scanned/clean files.
694
+ const scannedEntries = fileEntries.filter((e) => e.outcome !== 'skip');
695
+ const fileSkipCount = fileEntries.length - scannedEntries.length;
696
+ const rewritten = scannedEntries.filter(
697
+ (e) => e.outcome === 'rewrite'
698
+ ).length;
699
+ const review = scannedEntries.filter(
700
+ (e) => e.outcome === 'needs-review'
701
+ ).length;
702
+
703
+ return {
704
+ report: {
705
+ entries,
706
+ matched: rewritten + review,
707
+ review,
708
+ rewritten,
709
+ root: params.root,
710
+ scanned: scannedEntries.length,
711
+ selectedClassIds: selected.map((cls) => cls.id),
712
+ skipped: skipEntries.length + fileSkipCount,
713
+ unknownClassIds,
714
+ },
715
+ rewrites,
716
+ };
717
+ };
718
+
719
+ export const buildRegradeReport = (params: {
720
+ readonly root: string;
721
+ readonly files: readonly {
722
+ readonly path: string;
723
+ readonly source: string;
724
+ readonly absolutePath?: string;
725
+ }[];
726
+ readonly skipped: readonly SkippedSource[];
727
+ readonly classes: readonly RegradeClass[];
728
+ readonly selection?: RegradeSelection;
729
+ }): RegradeReport => buildRegradeEvaluation(params).report;
730
+
731
+ const applyRegradeEvaluation = (
732
+ evaluation: RegradeEvaluation
733
+ ): Result<RegradeApplySummary, InternalError> => {
734
+ if (evaluation.report.unknownClassIds.length > 0) {
735
+ return Result.ok({
736
+ applied: 0,
737
+ filesChanged: 0,
738
+ review: evaluation.report.review,
739
+ skipped: evaluation.report.skipped + evaluation.rewrites.length,
740
+ unknown: evaluation.report.unknownClassIds.length,
741
+ });
742
+ }
743
+
744
+ const changedFiles = new Set<string>();
745
+ let applied = 0;
746
+ for (const rewrite of evaluation.rewrites) {
747
+ try {
748
+ writeFileSync(rewrite.absolutePath, rewrite.nextSource, 'utf8');
749
+ } catch (error: unknown) {
750
+ return Result.err(
751
+ new InternalError(
752
+ `Failed to apply regrade rewrite for "${rewrite.path}".`,
753
+ {
754
+ cause: error instanceof Error ? error : new Error(String(error)),
755
+ context: {
756
+ applied,
757
+ classId: rewrite.classId,
758
+ filesChanged: changedFiles.size,
759
+ path: rewrite.path,
760
+ },
761
+ }
762
+ )
763
+ );
764
+ }
765
+ applied += 1;
766
+ changedFiles.add(rewrite.path);
767
+ }
768
+
769
+ return Result.ok({
770
+ applied: evaluation.rewrites.length,
771
+ filesChanged: changedFiles.size,
772
+ review: evaluation.report.review,
773
+ skipped: evaluation.report.skipped,
774
+ unknown: 0,
775
+ });
776
+ };
777
+
778
+ const withApplySummary = (
779
+ report: RegradeReport,
780
+ apply: RegradeApplySummary
781
+ ): RegradeReport => ({
782
+ ...report,
783
+ apply,
784
+ });
785
+
786
+ const canReadDownstreamRoot = (root: string): boolean => {
787
+ try {
788
+ readdirSync(root, { withFileTypes: true });
789
+ return true;
790
+ } catch {
791
+ return false;
792
+ }
793
+ };
794
+
795
+ const runRegradeEvaluation = (params: {
796
+ readonly root: string;
797
+ readonly classes: readonly RegradeClass[];
798
+ readonly selection?: RegradeSelection;
799
+ readonly collection?: DownstreamCollectionOptions;
800
+ }): RegradeEvaluation | null => {
801
+ const { selected, unknownClassIds } = selectRegradeClasses(
802
+ params.classes,
803
+ params.selection
804
+ );
805
+ if (selected.length === 0 && unknownClassIds.length > 0) {
806
+ if (!canReadDownstreamRoot(params.root)) {
807
+ return null;
808
+ }
809
+ return buildRegradeEvaluation({
810
+ classes: params.classes,
811
+ files: [],
812
+ root: params.root,
813
+ skipped: [],
814
+ ...(params.selection === undefined
815
+ ? {}
816
+ : { selection: params.selection }),
817
+ });
818
+ }
819
+
820
+ const collected = collectDownstreamSources(
821
+ params.root,
822
+ deriveCollectionOptions(selected, params.collection)
823
+ );
824
+ if (collected === null) {
825
+ return null;
826
+ }
827
+
828
+ const files: { absolutePath: string; path: string; source: string }[] = [];
829
+ const skipped: SkippedSource[] = [...collected.skipped];
830
+ for (const file of collected.files) {
831
+ try {
832
+ files.push({
833
+ absolutePath: file.absolutePath,
834
+ path: file.path,
835
+ source: readFileSync(file.absolutePath, 'utf8'),
836
+ });
837
+ } catch {
838
+ skipped.push({ path: file.path, reason: 'unreadable-file' });
839
+ }
840
+ }
841
+
842
+ return buildRegradeEvaluation({
843
+ classes: params.classes,
844
+ files,
845
+ root: params.root,
846
+ skipped,
847
+ ...(params.selection === undefined ? {} : { selection: params.selection }),
848
+ });
849
+ };
850
+
851
+ /**
852
+ * Run a regrade over an explicit downstream root.
853
+ *
854
+ * Dry-run is the default and only reports candidate rewrites. Explicit apply
855
+ * mode writes safe rewrite outcomes with concrete `nextSource` payloads and
856
+ * summarizes what was written or intentionally skipped.
857
+ */
858
+ export const runRegrade = (params: {
859
+ readonly root: string;
860
+ readonly classes: readonly RegradeClass[];
861
+ readonly selection?: RegradeSelection;
862
+ readonly collection?: DownstreamCollectionOptions;
863
+ readonly apply?: boolean;
864
+ }): Result<RegradeReport | null, InternalError> => {
865
+ const evaluation = runRegradeEvaluation(params);
866
+ if (evaluation === null) {
867
+ return Result.ok(null);
868
+ }
869
+
870
+ if (params.apply !== true) {
871
+ return Result.ok(evaluation.report);
872
+ }
873
+
874
+ const applyResult = applyRegradeEvaluation(evaluation);
875
+ if (applyResult.isErr()) {
876
+ return applyResult;
877
+ }
878
+
879
+ return Result.ok(withApplySummary(evaluation.report, applyResult.value));
880
+ };
881
+
882
+ const regradeReportEntrySchema = z.object({
883
+ classId: z.string().optional().describe('Class that produced the outcome'),
884
+ notes: z.array(z.string()).optional().describe('Notes from the class'),
885
+ outcome: z
886
+ .enum(['needs-review', 'no-op', 'rewrite', 'skip'])
887
+ .describe('What happened to the entry'),
888
+ path: z.string().describe('Root-relative POSIX path'),
889
+ reason: z.string().optional().describe('Reason for a skip or review outcome'),
890
+ reviewDetails: z
891
+ .array(
892
+ z.object({
893
+ classId: z
894
+ .string()
895
+ .optional()
896
+ .describe('Class that produced the review detail'),
897
+ expectedTarget: z
898
+ .string()
899
+ .optional()
900
+ .describe('Expected target shape for the migration'),
901
+ fixture: z
902
+ .string()
903
+ .optional()
904
+ .describe('Fixture or example reference for the migration'),
905
+ nodeKind: z
906
+ .string()
907
+ .optional()
908
+ .describe('AST node kind or source construct kind'),
909
+ reason: z.string().describe('Machine-readable review reason'),
910
+ span: z
911
+ .object({
912
+ column: z.number().describe('One-based source column'),
913
+ end: z.number().describe('Source end offset'),
914
+ line: z.number().describe('One-based source line'),
915
+ start: z.number().describe('Source start offset'),
916
+ })
917
+ .optional()
918
+ .describe('Source span that needs review'),
919
+ suggestedValidation: z
920
+ .string()
921
+ .optional()
922
+ .describe('Suggested validation command after resolving review'),
923
+ symbol: z.string().optional().describe('Symbol or term under review'),
924
+ })
925
+ )
926
+ .optional()
927
+ .describe('Structured review details from the producing class'),
928
+ });
929
+
930
+ const regradeApplySummarySchema = z.object({
931
+ applied: z.number().describe('Safe rewrite outcomes written to disk'),
932
+ filesChanged: z.number().describe('Distinct files changed on disk'),
933
+ review: z.number().describe('Files still requiring review'),
934
+ skipped: z.number().describe('Rewrite candidates intentionally not written'),
935
+ unknown: z.number().describe('Unknown selected class ids'),
936
+ });
937
+
938
+ export const regradeReportOutput = z.object({
939
+ apply: regradeApplySummarySchema
940
+ .optional()
941
+ .describe('Apply-mode summary; absent for dry-run report-only calls'),
942
+ entries: z
943
+ .array(regradeReportEntrySchema)
944
+ .describe('Per-entry detail, sorted by path'),
945
+ matched: z.number().describe('Files with a rewrite or review outcome'),
946
+ review: z.number().describe('Files routed to review'),
947
+ rewritten: z.number().describe('Files with a rewrite outcome'),
948
+ root: z.string().describe('Root the run scanned'),
949
+ scanned: z.number().describe('Source files inspected'),
950
+ selectedClassIds: z.array(z.string()).describe('Class ids executed'),
951
+ skipped: z.number().describe('Entries skipped'),
952
+ unknownClassIds: z
953
+ .array(z.string())
954
+ .describe('Selected ids that did not resolve to a class'),
955
+ });
956
+
957
+ /**
958
+ * Built-in regrade classes available to the report trail.
959
+ *
960
+ * Warden owns term detection and fix metadata. Regrade projects Warden rules
961
+ * that advertise `term-rewrite` capability into reportable classes and then
962
+ * owns application/reporting of the resulting rewrite or review outcomes.
963
+ */
964
+ export const wardenTermRewriteClasses: readonly RegradeClass[] = Object.freeze(
965
+ [...wardenRules.values()].flatMap((rule) => {
966
+ const cls = createWardenTermRewriteClass(rule);
967
+ return cls === null ? [] : [cls];
968
+ })
969
+ );