@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.
@@ -0,0 +1,2085 @@
1
+ import {
2
+ InternalError,
3
+ Result,
4
+ ValidationError,
5
+ escapeRegExp,
6
+ includedByPathScope,
7
+ } from '@ontrails/core';
8
+ import type { ScanTargets } from '@ontrails/core';
9
+ import type {
10
+ GovernedVocabularyHistoryProvenance,
11
+ WardenDiagnostic,
12
+ WardenFixEdit,
13
+ WardenGuidance,
14
+ WardenRule,
15
+ } from '@ontrails/warden';
16
+ import {
17
+ getWardenRuleMetadata,
18
+ governedVocabularyHistoryProvenanceSchema,
19
+ isWardenSourceScanTarget,
20
+ loadProjectWardenRules,
21
+ wardenRules,
22
+ } from '@ontrails/warden';
23
+ import { createHash } from 'node:crypto';
24
+ import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
25
+ import { dirname, join, relative, resolve } from 'node:path';
26
+ import { z } from 'zod';
27
+
28
+ import {
29
+ DEFAULT_IGNORED_DIRECTORIES,
30
+ DEFAULT_SOURCE_EXTENSIONS,
31
+ collectDownstreamSources,
32
+ } from './collect.js';
33
+ import type { DownstreamCollectionOptions, SkippedSource } from './collect.js';
34
+ import {
35
+ buildRegradeScanSummary,
36
+ regradeScanSummaryOutput,
37
+ } from './scan-summary.js';
38
+ import type { RegradeScanSummary } from './scan-summary.js';
39
+ import type { VocabularyRegradeRun } from './vocabulary.js';
40
+ import { vocabularyRegradeRunOutput } from './vocabulary.js';
41
+ import type { RegradePackageSourceEvidence } from './package-source.js';
42
+
43
+ /**
44
+ * Regrade-class selection and coverage reporting (TRL-845).
45
+ *
46
+ * A regrade class is one named, contract-aware transform (for example a single
47
+ * vocabulary rename). Selection lets a run apply one class without executing
48
+ * every available transform, and {@link RegradeReport} captures coverage —
49
+ * what was scanned, matched, rewritten, routed to review, and skipped — with
50
+ * enough per-entry detail to debug why a file was omitted.
51
+ *
52
+ * The report logic is pure ({@link buildRegradeReport}); the filesystem walk
53
+ * and file reads live in {@link runRegrade} and the wrapping trail. This keeps
54
+ * coverage semantics testable without disk and consistent with the downstream
55
+ * collection substrate (TRL-844).
56
+ */
57
+
58
+ /**
59
+ * Outcome a regrade class produces for a single source file. `skipped` means
60
+ * the class declined to inspect the file (for example, a scan-target filter
61
+ * excluded it) and it must not count as a scanned/clean no-op.
62
+ */
63
+ export type RegradeOutcomeKind =
64
+ | 'needs-review'
65
+ | 'no-op'
66
+ | 'rewrite'
67
+ | 'skipped';
68
+
69
+ /** Result of applying one regrade class to one source string. */
70
+ export interface RegradeClassResult {
71
+ readonly kind: RegradeOutcomeKind;
72
+ /** Rewritten source, present only when `kind` is `rewrite`. */
73
+ readonly nextSource?: string;
74
+ /** Human-readable notes explaining the outcome. */
75
+ readonly notes: readonly string[];
76
+ /** Machine-readable reason for review outcomes. */
77
+ readonly reason?: string;
78
+ /** Structured details for review outcomes. */
79
+ readonly reviewDetails?: readonly RegradeReviewDetail[];
80
+ }
81
+
82
+ /** Source-file context passed to Regrade classes. */
83
+ export interface RegradeClassContext {
84
+ /** Root-relative POSIX path. */
85
+ readonly path: string;
86
+ /** Absolute path on disk, when the caller has one. */
87
+ readonly absolutePath?: string;
88
+ /** Nearest owning package facts, when filesystem collection found a manifest. */
89
+ readonly package?: {
90
+ readonly dependencies: readonly string[];
91
+ /** Runtime-visible dependency declarations (dependencies, optional, peer). */
92
+ readonly runtimeDependencies?: readonly string[];
93
+ readonly manifestState?: 'invalid' | 'valid';
94
+ readonly name?: string;
95
+ /** Root-relative POSIX manifest path. */
96
+ readonly path: string;
97
+ };
98
+ }
99
+
100
+ /** Files a regrade class knows how to inspect. */
101
+ export type RegradeScanTargets = ScanTargets & {
102
+ /**
103
+ * @deprecated Use collection-level `exclude` globs. Preserved so existing
104
+ * Regrade classes can explicitly opt into directories the default collector
105
+ * prunes, such as `dist`, while migrating to PathScope.
106
+ */
107
+ readonly ignoredDirectories?: readonly string[];
108
+ };
109
+
110
+ /** One named, contract-aware transform. */
111
+ export interface RegradeClass {
112
+ /** Stable identifier, e.g. `term-rewrite:signal->ping`. */
113
+ readonly id: string;
114
+ /** What the class does, for report and guide surfaces. */
115
+ readonly describe: string;
116
+ /** Apply the class to a source string. Must be pure and never throw. */
117
+ readonly apply: (
118
+ source: string,
119
+ context?: RegradeClassContext
120
+ ) => RegradeClassResult;
121
+ /** Scan targets this class knows how to inspect. */
122
+ readonly scanTargets?: RegradeScanTargets;
123
+ }
124
+
125
+ export interface RegradeWardenClassSet {
126
+ /** Built-in and project-local Warden term-rewrite classes. */
127
+ readonly classes: readonly RegradeClass[];
128
+ /** Diagnostics raised while loading project-local rules. */
129
+ readonly diagnostics: readonly WardenDiagnostic[];
130
+ }
131
+
132
+ /** Which regrade classes a run should execute. */
133
+ export interface RegradeSelection {
134
+ /** Class ids to run. Omit to run every provided class. */
135
+ readonly classIds?: readonly string[];
136
+ }
137
+
138
+ /** Which report entries should be returned. Counts always cover the full run. */
139
+ export type RegradeReportEntrySelection = 'actionable' | 'all';
140
+
141
+ /** Optional write summary for an apply-mode regrade run. */
142
+ export interface RegradeApplySummary {
143
+ /** Safe rewrite outcomes written to disk. */
144
+ readonly applied: number;
145
+ /** Distinct files changed on disk. */
146
+ readonly filesChanged: number;
147
+ /** Rewrite candidates intentionally not written. */
148
+ readonly skipped: number;
149
+ /** Files still requiring review. */
150
+ readonly review: number;
151
+ /** Unknown selected class ids; apply mode writes nothing when non-zero. */
152
+ readonly unknown: number;
153
+ }
154
+
155
+ /**
156
+ * Caller-authored identity for one prepared Regrade evaluation.
157
+ *
158
+ * @example
159
+ * ```ts
160
+ * const identity: PreparedRegradeRunIdentity = {
161
+ * lockStateHash: 'lock-sha256',
162
+ * planContentHash: 'plan-sha256',
163
+ * policyHash: 'policy-sha256',
164
+ * scopeHash: 'scope-sha256',
165
+ * toolVersion: '1.0.0',
166
+ * };
167
+ * ```
168
+ */
169
+ export interface PreparedRegradeRunIdentity {
170
+ readonly lockStateHash: string;
171
+ readonly planContentHash: string;
172
+ readonly policyHash: string;
173
+ readonly scopeHash: string;
174
+ readonly toolVersion: string;
175
+ }
176
+
177
+ /**
178
+ * In-memory class/symbol Regrade evaluation ready for freshness-checked apply.
179
+ *
180
+ * @example
181
+ * ```ts
182
+ * const prepared = prepareRegradeRun({ classes, identity, root });
183
+ * if (prepared.isOk() && prepared.value !== null) {
184
+ * console.log(prepared.value.sourceStateHash);
185
+ * }
186
+ * ```
187
+ */
188
+ export interface PreparedRegradeRun {
189
+ readonly identity: PreparedRegradeRunIdentity;
190
+ readonly report: RegradeReport;
191
+ readonly sourceStateHash: string;
192
+ }
193
+
194
+ /** Source location for a review-required match. */
195
+ export interface RegradeReviewSpan {
196
+ readonly column: number;
197
+ readonly end: number;
198
+ readonly line: number;
199
+ readonly start: number;
200
+ }
201
+
202
+ /**
203
+ * Verdict state for a review detail.
204
+ *
205
+ * - `unresolved`: the class could not complete occurrence judgment; a human or
206
+ * agent decision is still needed.
207
+ * - `preserve`: a completed verdict to keep the occurrence as-is.
208
+ * - `rewrite`: a completed verdict that a rewrite is intended but this run
209
+ * could not apply it (for example invalid or missing edits).
210
+ */
211
+ export type RegradeReviewJudgment = 'preserve' | 'rewrite' | 'unresolved';
212
+
213
+ /** Structured detail explaining why a source match needs review. */
214
+ export interface RegradeReviewDetail {
215
+ /** Concrete replacement the class would apply if the occurrence were judged safe. */
216
+ readonly candidateReplacement?: string;
217
+ /** Class that produced the review detail, injected by report building. */
218
+ readonly classId?: string;
219
+ /** Exact source-line context containing the occurrence under review. */
220
+ readonly context?: string;
221
+ /** Expected target shape when the class can describe one. */
222
+ readonly expectedTarget?: string;
223
+ /** Fixture or example reference that illustrates the expected migration. */
224
+ readonly fixture?: string;
225
+ /** Whether occurrence judgment is unresolved or a preserve/rewrite verdict completed. */
226
+ readonly judgment?: RegradeReviewJudgment;
227
+ /** Exact matched source text for the occurrence under review. */
228
+ readonly matchedForm?: string;
229
+ /** AST node kind or source construct kind. */
230
+ readonly nodeKind?: string;
231
+ /** Cautions explaining why a blind rewrite of this occurrence is unsafe. */
232
+ readonly preserveCautions?: readonly string[];
233
+ /** Machine-readable reason for review. */
234
+ readonly reason: string;
235
+ /** Machine-readable provenance tags for the producing rule or class. */
236
+ readonly signals?: readonly string[];
237
+ /** Source span and line/column for the review-required match. */
238
+ readonly span?: RegradeReviewSpan;
239
+ /** Suggested validation command after the review is resolved. */
240
+ readonly suggestedValidation?: string;
241
+ /** Symbol or term that triggered review. */
242
+ readonly symbol?: string;
243
+ }
244
+
245
+ /**
246
+ * Build a whole-word term-rewrite class.
247
+ *
248
+ * Whole-word occurrences of `from` are rewritten to `to`. When `from` appears
249
+ * only as part of a larger identifier (an ambiguous partial match), the class
250
+ * routes the file to review instead of rewriting it — the canonical
251
+ * "rename `signal` but do not touch `signalHandler`" case. This standalone
252
+ * class anticipates TRL-832/836, where the mappings become Warden-owned.
253
+ *
254
+ * Matching is raw-text and lexer-unaware: a whole-word `from` inside a comment
255
+ * or string literal counts exactly like a code reference (it is rewritten, and
256
+ * a partial occurrence there still routes the file to review). For a vocabulary
257
+ * migration this is usually desirable — comments and docs should track the
258
+ * rename too — but it means callers cannot assume comment/string occurrences
259
+ * are skipped. Lexer/AST-aware exclusion is deferred to the Warden-owned
260
+ * term-rewrite metadata work (TRL-832/836).
261
+ */
262
+ export const createTermRewriteClass = (options: {
263
+ readonly from: string;
264
+ readonly to: string;
265
+ readonly id?: string;
266
+ readonly describe?: string;
267
+ }): RegradeClass => {
268
+ const { from, to } = options;
269
+ const wholeWord = new RegExp(`\\b${escapeRegExp(from)}\\b`, 'g');
270
+ return {
271
+ apply: (source: string): RegradeClassResult => {
272
+ const matches = source.match(wholeWord);
273
+ const hasAmbiguousPartial = source.replace(wholeWord, '').includes(from);
274
+ if (hasAmbiguousPartial) {
275
+ return {
276
+ kind: 'needs-review',
277
+ notes: [
278
+ `Found "${from}" inside larger identifiers; routed to review.`,
279
+ ],
280
+ reason: 'ambiguous-match',
281
+ };
282
+ }
283
+ if (matches && matches.length > 0) {
284
+ return {
285
+ kind: 'rewrite',
286
+ nextSource: source.replace(wholeWord, to),
287
+ notes: [`Rewrote ${matches.length} whole-word "${from}" -> "${to}".`],
288
+ };
289
+ }
290
+ return { kind: 'no-op', notes: [`No "${from}" occurrences found.`] };
291
+ },
292
+ describe: options.describe ?? `Rewrite "${from}" to "${to}".`,
293
+ id: options.id ?? `term-rewrite:${from}->${to}`,
294
+ };
295
+ };
296
+
297
+ const TERM_REWRITE_FIX_CLASS = 'term-rewrite';
298
+
299
+ type WardenEditApplication =
300
+ | { readonly ok: true; readonly nextSource: string }
301
+ | { readonly ok: false; readonly reason: string };
302
+
303
+ const regradeScanTargetsFromWardenFix = (
304
+ scanTargets: NonNullable<
305
+ NonNullable<ReturnType<typeof getWardenRuleMetadata>>['fix']
306
+ >['scanTargets']
307
+ ): RegradeScanTargets | undefined => {
308
+ if (scanTargets === undefined) {
309
+ return undefined;
310
+ }
311
+ return {
312
+ ...(scanTargets.exclude === undefined
313
+ ? {}
314
+ : { exclude: scanTargets.exclude }),
315
+ ...(scanTargets.extensions === undefined
316
+ ? {}
317
+ : { extensions: scanTargets.extensions }),
318
+ ...(scanTargets.ignoredDirectories === undefined
319
+ ? {}
320
+ : { ignoredDirectories: scanTargets.ignoredDirectories }),
321
+ };
322
+ };
323
+
324
+ const diagnosticNote = (diagnostic: WardenDiagnostic): string => {
325
+ const reason = diagnostic.fix?.reason ?? diagnostic.message;
326
+ return `${diagnostic.rule}:${diagnostic.line}: ${reason}`;
327
+ };
328
+
329
+ const firstQuotedValue = (value: string | undefined): string | undefined => {
330
+ if (value === undefined) {
331
+ return undefined;
332
+ }
333
+ const match = /['"]([^'"]+)['"]/.exec(value);
334
+ return match?.[1];
335
+ };
336
+
337
+ const reviewSpanForOffsets = (
338
+ source: string,
339
+ start: number,
340
+ end: number
341
+ ): RegradeReviewSpan => {
342
+ let line = 1;
343
+ let column = 1;
344
+ for (let index = 0; index < start; index += 1) {
345
+ if (source.codePointAt(index) === 10) {
346
+ line += 1;
347
+ column = 1;
348
+ } else {
349
+ column += 1;
350
+ }
351
+ }
352
+ return { column, end, line, start };
353
+ };
354
+
355
+ const spanForSymbolOnDiagnosticLine = (
356
+ source: string,
357
+ line: number,
358
+ symbol: string
359
+ ): RegradeReviewSpan | undefined => {
360
+ if (line < 1) {
361
+ return undefined;
362
+ }
363
+ let lineStart = 0;
364
+ for (let currentLine = 1; currentLine < line; currentLine += 1) {
365
+ const nextLineStart = source.indexOf('\n', lineStart);
366
+ if (nextLineStart === -1) {
367
+ return undefined;
368
+ }
369
+ lineStart = nextLineStart + 1;
370
+ }
371
+ const nextLineStart = source.indexOf('\n', lineStart);
372
+ const lineEnd = nextLineStart === -1 ? source.length : nextLineStart;
373
+ const symbolIndex = source.indexOf(symbol, lineStart);
374
+ if (symbolIndex === -1 || symbolIndex >= lineEnd) {
375
+ return undefined;
376
+ }
377
+ const nextSymbolIndex = source.indexOf(symbol, symbolIndex + symbol.length);
378
+ if (nextSymbolIndex !== -1 && nextSymbolIndex < lineEnd) {
379
+ return undefined;
380
+ }
381
+ return reviewSpanForOffsets(source, symbolIndex, symbolIndex + symbol.length);
382
+ };
383
+
384
+ const diagnosticSpan = (
385
+ source: string,
386
+ diagnostic: WardenDiagnostic,
387
+ symbol: string | undefined
388
+ ): RegradeReviewSpan | undefined => {
389
+ const [edit] = diagnostic.fix?.edits ?? [];
390
+ if (edit !== undefined) {
391
+ return reviewSpanForOffsets(source, edit.start, edit.end);
392
+ }
393
+ if (symbol === undefined) {
394
+ return undefined;
395
+ }
396
+ return spanForSymbolOnDiagnosticLine(source, diagnostic.line, symbol);
397
+ };
398
+
399
+ const diagnosticCandidateReplacement = (
400
+ diagnostic: WardenDiagnostic
401
+ ): string | undefined => {
402
+ const replacements = new Set(
403
+ (diagnostic.fix?.edits ?? []).map((edit) => edit.replacement)
404
+ );
405
+ if (replacements.size !== 1) {
406
+ return undefined;
407
+ }
408
+ const [replacement] = replacements;
409
+ return replacement;
410
+ };
411
+
412
+ const expectedTarget = (diagnostic: WardenDiagnostic): string | undefined => {
413
+ const replacement = diagnosticCandidateReplacement(diagnostic);
414
+ return replacement === undefined
415
+ ? undefined
416
+ : `Replace with "${replacement}".`;
417
+ };
418
+
419
+ const isValidEditSpan = (
420
+ source: string,
421
+ edit: WardenFixEdit | undefined
422
+ ): edit is WardenFixEdit =>
423
+ edit !== undefined &&
424
+ Number.isInteger(edit.start) &&
425
+ Number.isInteger(edit.end) &&
426
+ edit.start >= 0 &&
427
+ edit.end >= edit.start &&
428
+ edit.end <= source.length;
429
+
430
+ const diagnosticMatchedForm = (
431
+ source: string,
432
+ diagnostic: WardenDiagnostic,
433
+ symbol: string | undefined
434
+ ): string | undefined => {
435
+ const [edit] = diagnostic.fix?.edits ?? [];
436
+ if (isValidEditSpan(source, edit)) {
437
+ return source.slice(edit.start, edit.end);
438
+ }
439
+ return symbol;
440
+ };
441
+
442
+ const diagnosticSignals = (diagnostic: WardenDiagnostic): readonly string[] => [
443
+ `warden:${diagnostic.rule}`,
444
+ ...(diagnostic.code === undefined
445
+ ? []
446
+ : [`${diagnostic.rule}:${diagnostic.code}`]),
447
+ ];
448
+
449
+ interface WardenReviewMappingOptions {
450
+ /** Verdict state for this review path. */
451
+ readonly judgment: RegradeReviewJudgment;
452
+ /** Machine-readable review reason. */
453
+ readonly reason: string;
454
+ /** Rule-level guidance used when a finding carries none of its own. */
455
+ readonly ruleGuidance?: WardenGuidance;
456
+ }
457
+
458
+ const reviewDetailFromDiagnostic = (
459
+ source: string,
460
+ diagnostic: WardenDiagnostic,
461
+ options: WardenReviewMappingOptions
462
+ ): RegradeReviewDetail => {
463
+ const symbol =
464
+ firstQuotedValue(diagnostic.fix?.reason) ??
465
+ firstQuotedValue(diagnostic.message);
466
+ const span = diagnosticSpan(source, diagnostic, symbol);
467
+ const target = expectedTarget(diagnostic);
468
+ const replacement = diagnosticCandidateReplacement(diagnostic);
469
+ const matchedForm = diagnosticMatchedForm(source, diagnostic, symbol);
470
+ const guidance = diagnostic.guidance ?? options.ruleGuidance;
471
+ const preserveCautions =
472
+ guidance === undefined
473
+ ? undefined
474
+ : [guidance.summary, ...(guidance.steps ?? [])];
475
+ const suggestedValidation = guidance?.commands?.[0];
476
+ return {
477
+ ...(replacement === undefined ? {} : { candidateReplacement: replacement }),
478
+ ...(target === undefined ? {} : { expectedTarget: target }),
479
+ ...(diagnostic.fix?.fixture === undefined
480
+ ? {}
481
+ : { fixture: diagnostic.fix.fixture }),
482
+ judgment: options.judgment,
483
+ ...(matchedForm === undefined ? {} : { matchedForm }),
484
+ ...(preserveCautions === undefined ? {} : { preserveCautions }),
485
+ reason: options.reason,
486
+ signals: diagnosticSignals(diagnostic),
487
+ ...(span === undefined ? {} : { span }),
488
+ ...(suggestedValidation === undefined ? {} : { suggestedValidation }),
489
+ ...(symbol === undefined ? {} : { symbol }),
490
+ } satisfies RegradeReviewDetail;
491
+ };
492
+
493
+ const reviewDetailsFromDiagnostics = (
494
+ source: string,
495
+ diagnostics: readonly WardenDiagnostic[],
496
+ options: WardenReviewMappingOptions
497
+ ): readonly RegradeReviewDetail[] | undefined => {
498
+ const details = diagnostics.map((diagnostic) =>
499
+ reviewDetailFromDiagnostic(source, diagnostic, options)
500
+ );
501
+ return details.length === 0 ? undefined : details;
502
+ };
503
+
504
+ const wardenFilePath = (context: RegradeClassContext | undefined): string =>
505
+ context?.absolutePath ?? context?.path ?? '<regrade-source>';
506
+
507
+ const wardenScanPath = (context: RegradeClassContext | undefined): string =>
508
+ context?.path ?? context?.absolutePath ?? '<regrade-source>';
509
+
510
+ const applyWardenEdits = (
511
+ source: string,
512
+ edits: readonly WardenFixEdit[]
513
+ ): WardenEditApplication => {
514
+ const ordered = [...edits].toSorted((a, b) => a.start - b.start);
515
+ let previousEnd = 0;
516
+ for (const edit of ordered) {
517
+ if (
518
+ !Number.isInteger(edit.start) ||
519
+ !Number.isInteger(edit.end) ||
520
+ edit.start < 0 ||
521
+ edit.end < edit.start ||
522
+ edit.end > source.length
523
+ ) {
524
+ return { ok: false, reason: 'invalid-edit-span' };
525
+ }
526
+ if (edit.start < previousEnd) {
527
+ return { ok: false, reason: 'overlapping-edit-spans' };
528
+ }
529
+ previousEnd = edit.end;
530
+ }
531
+
532
+ let nextSource = source;
533
+ for (const edit of ordered.toReversed()) {
534
+ nextSource =
535
+ nextSource.slice(0, edit.start) +
536
+ edit.replacement +
537
+ nextSource.slice(edit.end);
538
+ }
539
+ return { nextSource, ok: true };
540
+ };
541
+
542
+ export const createWardenTermRewriteClass = (
543
+ rule: WardenRule
544
+ ): RegradeClass | null => {
545
+ const metadata = getWardenRuleMetadata(rule);
546
+ if (metadata?.fix?.class !== TERM_REWRITE_FIX_CLASS) {
547
+ return null;
548
+ }
549
+ const scanTargets = regradeScanTargetsFromWardenFix(metadata.fix.scanTargets);
550
+
551
+ return {
552
+ apply: (
553
+ source: string,
554
+ context?: RegradeClassContext
555
+ ): RegradeClassResult => {
556
+ if (!isWardenSourceScanTarget(wardenScanPath(context))) {
557
+ return {
558
+ kind: 'skipped',
559
+ notes: ['Skipped by Warden source scan-target filtering.'],
560
+ reason: 'warden-scan-target-filtered',
561
+ };
562
+ }
563
+
564
+ const diagnostics = rule
565
+ .check(source, wardenFilePath(context))
566
+ .filter(
567
+ (diagnostic) => diagnostic.fix?.class === TERM_REWRITE_FIX_CLASS
568
+ );
569
+
570
+ if (diagnostics.length === 0) {
571
+ return {
572
+ kind: 'no-op',
573
+ notes: [`No Warden ${TERM_REWRITE_FIX_CLASS} diagnostics found.`],
574
+ };
575
+ }
576
+
577
+ const reviewDiagnostics = diagnostics.filter(
578
+ (diagnostic) => diagnostic.fix?.safety !== 'safe'
579
+ );
580
+ if (reviewDiagnostics.length > 0) {
581
+ // The rule flagged the occurrence but marked it review: occurrence
582
+ // judgment is unresolved and needs a human or agent decision.
583
+ const reviewDetails = reviewDetailsFromDiagnostics(
584
+ source,
585
+ reviewDiagnostics,
586
+ {
587
+ judgment: 'unresolved',
588
+ reason: 'warden-review-required',
589
+ ...(metadata.guidance === undefined
590
+ ? {}
591
+ : { ruleGuidance: metadata.guidance }),
592
+ }
593
+ );
594
+ return {
595
+ kind: 'needs-review',
596
+ notes: reviewDiagnostics.map(diagnosticNote),
597
+ reason: 'warden-review-required',
598
+ ...(reviewDetails === undefined ? {} : { reviewDetails }),
599
+ };
600
+ }
601
+
602
+ const diagnosticsMissingEdits = diagnostics.filter(
603
+ (diagnostic) => (diagnostic.fix?.edits?.length ?? 0) === 0
604
+ );
605
+ if (diagnosticsMissingEdits.length > 0) {
606
+ // A safe fix without concrete edits cannot complete occurrence
607
+ // judgment on its own, so the verdict stays unresolved.
608
+ const reviewDetails = reviewDetailsFromDiagnostics(
609
+ source,
610
+ diagnosticsMissingEdits,
611
+ {
612
+ judgment: 'unresolved',
613
+ reason: 'warden-fix-missing-edits',
614
+ ...(metadata.guidance === undefined
615
+ ? {}
616
+ : { ruleGuidance: metadata.guidance }),
617
+ }
618
+ );
619
+ return {
620
+ kind: 'needs-review',
621
+ notes: diagnostics.map(diagnosticNote),
622
+ reason: 'warden-fix-missing-edits',
623
+ ...(reviewDetails === undefined ? {} : { reviewDetails }),
624
+ };
625
+ }
626
+
627
+ const edits = diagnostics.flatMap(
628
+ (diagnostic) => diagnostic.fix?.edits ?? []
629
+ );
630
+ const application = applyWardenEdits(source, edits);
631
+ if (!application.ok) {
632
+ // The rule completed judgment — it authored concrete edits — but this
633
+ // run could not apply them, so the verdict is a rewrite left undone.
634
+ const reviewDetails = reviewDetailsFromDiagnostics(
635
+ source,
636
+ diagnostics,
637
+ {
638
+ judgment: 'rewrite',
639
+ reason: 'warden-fix-invalid',
640
+ ...(metadata.guidance === undefined
641
+ ? {}
642
+ : { ruleGuidance: metadata.guidance }),
643
+ }
644
+ );
645
+ return {
646
+ kind: 'needs-review',
647
+ notes: [
648
+ ...diagnostics.map(diagnosticNote),
649
+ `Warden fix edits could not be applied: ${application.reason}.`,
650
+ ],
651
+ reason: 'warden-fix-invalid',
652
+ ...(reviewDetails === undefined ? {} : { reviewDetails }),
653
+ };
654
+ }
655
+
656
+ return {
657
+ kind: 'rewrite',
658
+ nextSource: application.nextSource,
659
+ notes: diagnostics.map(diagnosticNote),
660
+ };
661
+ },
662
+ describe: `${rule.description} (${metadata.fix.safety} ${metadata.fix.class})`,
663
+ id: `${metadata.fix.class}:${rule.name}`,
664
+ ...(scanTargets === undefined ? {} : { scanTargets }),
665
+ };
666
+ };
667
+
668
+ /**
669
+ * Resolve the selected classes, preserving the order of `classIds` when given.
670
+ * Unknown selected ids are returned so callers can report them.
671
+ */
672
+ export const selectRegradeClasses = (
673
+ classes: readonly RegradeClass[],
674
+ selection: RegradeSelection = {}
675
+ ): {
676
+ readonly selected: readonly RegradeClass[];
677
+ readonly unknownClassIds: readonly string[];
678
+ } => {
679
+ if (selection.classIds === undefined) {
680
+ return { selected: classes, unknownClassIds: [] };
681
+ }
682
+ const byId = new Map(classes.map((cls) => [cls.id, cls]));
683
+ const selected: RegradeClass[] = [];
684
+ const unknownClassIds: string[] = [];
685
+ for (const id of selection.classIds) {
686
+ const cls = byId.get(id);
687
+ if (cls === undefined) {
688
+ unknownClassIds.push(id);
689
+ } else {
690
+ selected.push(cls);
691
+ }
692
+ }
693
+ return { selected, unknownClassIds };
694
+ };
695
+
696
+ const uniqueSorted = (values: readonly string[]): readonly string[] =>
697
+ [...new Set(values)].toSorted((a, b) => a.localeCompare(b));
698
+
699
+ const intersectValues = (
700
+ left: readonly string[],
701
+ right: readonly string[]
702
+ ): readonly string[] => left.filter((value) => right.includes(value));
703
+
704
+ const deriveClassIgnoredDirectories = (
705
+ classes: readonly RegradeClass[]
706
+ ): readonly string[] | undefined => {
707
+ const explicitTargets = classes
708
+ .map((cls) => cls.scanTargets?.ignoredDirectories)
709
+ .filter((value): value is readonly string[] => value !== undefined);
710
+ if (explicitTargets.length === 0) {
711
+ return undefined;
712
+ }
713
+
714
+ let common = explicitTargets[0] ?? [];
715
+ for (const target of explicitTargets.slice(1)) {
716
+ common = intersectValues(common, target);
717
+ }
718
+ return uniqueSorted(common);
719
+ };
720
+
721
+ const deriveCollectionOptions = (
722
+ classes: readonly RegradeClass[],
723
+ collection: DownstreamCollectionOptions | undefined
724
+ ): DownstreamCollectionOptions => {
725
+ const allExtensions = classes.some(
726
+ (cls) => cls.scanTargets?.extensions?.length === 0
727
+ );
728
+ const targetExtensions = allExtensions
729
+ ? []
730
+ : uniqueSorted(
731
+ classes.length === 0
732
+ ? DEFAULT_SOURCE_EXTENSIONS
733
+ : classes.flatMap(
734
+ (cls) => cls.scanTargets?.extensions ?? DEFAULT_SOURCE_EXTENSIONS
735
+ )
736
+ );
737
+ return {
738
+ ...(collection?.exclude === undefined
739
+ ? {}
740
+ : { exclude: collection.exclude }),
741
+ ...(collection?.include === undefined
742
+ ? {}
743
+ : { include: collection.include }),
744
+ extensions: collection?.extensions ?? targetExtensions,
745
+ ignoredDirectories:
746
+ collection?.ignoredDirectories ??
747
+ deriveClassIgnoredDirectories(classes) ??
748
+ DEFAULT_IGNORED_DIRECTORIES,
749
+ };
750
+ };
751
+
752
+ /** Per-entry detail describing what happened to one path. */
753
+ export interface RegradeReportEntry {
754
+ /** Root-relative POSIX path. */
755
+ readonly path: string;
756
+ /** What happened to the entry. */
757
+ readonly outcome: 'needs-review' | 'no-op' | 'rewrite' | 'skip';
758
+ /** Class that produced a rewrite or review outcome. */
759
+ readonly classId?: string;
760
+ /** Reason for a skip or review outcome. */
761
+ readonly reason?: string;
762
+ /** Notes carried from the producing class. */
763
+ readonly notes?: readonly string[];
764
+ /** Structured review details carried from the producing class. */
765
+ readonly reviewDetails?: readonly RegradeReviewDetail[];
766
+ }
767
+
768
+ /** Coverage report for a regrade run. */
769
+ export interface RegradeReport {
770
+ /** Root the run scanned. */
771
+ readonly root: string;
772
+ /** Class ids that were executed. */
773
+ readonly selectedClassIds: readonly string[];
774
+ /** Selected class ids that did not resolve to a known class. */
775
+ readonly unknownClassIds: readonly string[];
776
+ /** Source files inspected. */
777
+ readonly scanned: number;
778
+ /**
779
+ * Root-relative source paths used to deduplicate composed scans.
780
+ *
781
+ * @internal
782
+ */
783
+ readonly scannedPaths?: readonly string[];
784
+ /** Files where a selected class produced a rewrite or review outcome. */
785
+ readonly matched: number;
786
+ /** Files with a rewrite outcome. */
787
+ readonly rewritten: number;
788
+ /** Files routed to review. */
789
+ readonly review: number;
790
+ /** Entries skipped (collection skips plus any run-level skips). */
791
+ readonly skipped: number;
792
+ /** Skipped entries grouped by reason. */
793
+ readonly skipsByReason: Readonly<Record<string, number>>;
794
+ /** Agent-facing inventory summary for the scan. */
795
+ readonly scan: RegradeScanSummary;
796
+ /** Per-entry detail, sorted by path. */
797
+ readonly entries: readonly RegradeReportEntry[];
798
+ /** Apply-mode summary; absent for dry-run report-only calls. */
799
+ readonly apply?: RegradeApplySummary;
800
+ /** Verified source evidence for the selected downstream Trails package. */
801
+ readonly packageSource?: RegradePackageSourceEvidence;
802
+ /** Vocabulary regrade run: plan, ledger, and completion report. */
803
+ readonly run?: VocabularyRegradeRun;
804
+ /** Saved active Regrade plan evidence for vocabulary regrades. */
805
+ readonly plan?: {
806
+ readonly expansionPending?: number;
807
+ readonly path: string;
808
+ readonly schemaVersion: number;
809
+ readonly status: 'active' | 'stale';
810
+ };
811
+ /** Saved applied Regrade history evidence for vocabulary regrades. */
812
+ readonly history?: {
813
+ readonly id?: string;
814
+ readonly path: string;
815
+ readonly provenance?: GovernedVocabularyHistoryProvenance;
816
+ readonly schemaVersion: number;
817
+ readonly status: 'applied' | 'checked' | 'replay';
818
+ };
819
+ /**
820
+ * @deprecated Persisted transition record evidence for vocabulary regrades.
821
+ * Use `plan` and `history` summaries in public surfaces.
822
+ */
823
+ readonly record?: {
824
+ readonly path: string;
825
+ readonly schemaVersion: number;
826
+ readonly status: 'candidate' | 'applied' | 'checked';
827
+ };
828
+ }
829
+
830
+ const withScannedPaths = (
831
+ report: RegradeReport,
832
+ paths: readonly string[]
833
+ ): RegradeReport => {
834
+ Object.defineProperty(report, 'scannedPaths', {
835
+ configurable: false,
836
+ enumerable: false,
837
+ value: Object.freeze([...paths]),
838
+ writable: false,
839
+ });
840
+ return report;
841
+ };
842
+
843
+ const cloneRegradeReport = (report: RegradeReport): RegradeReport => {
844
+ const clone = structuredClone(report);
845
+ return report.scannedPaths === undefined
846
+ ? clone
847
+ : withScannedPaths(clone, report.scannedPaths);
848
+ };
849
+
850
+ interface RegradeRewriteCandidate {
851
+ readonly absolutePath: string;
852
+ readonly classId: string;
853
+ readonly nextSource: string;
854
+ readonly path: string;
855
+ }
856
+
857
+ interface RegradeClassifiedFile {
858
+ readonly entry: RegradeReportEntry;
859
+ readonly rewrite?: RegradeRewriteCandidate;
860
+ }
861
+
862
+ const isIgnoredByClassDirectories = (
863
+ path: string,
864
+ ignoredDirectories: readonly string[] | undefined
865
+ ): boolean => {
866
+ if (ignoredDirectories === undefined || ignoredDirectories.length === 0) {
867
+ return false;
868
+ }
869
+ return path
870
+ .split('/')
871
+ .slice(0, -1)
872
+ .some((segment) => ignoredDirectories.includes(segment));
873
+ };
874
+
875
+ const classScanTargetSkip = (
876
+ cls: RegradeClass,
877
+ path: string,
878
+ collection: DownstreamCollectionOptions | undefined
879
+ ): RegradeClassResult | undefined => {
880
+ const ignoredDirectories =
881
+ collection?.ignoredDirectories ??
882
+ cls.scanTargets?.ignoredDirectories ??
883
+ DEFAULT_IGNORED_DIRECTORIES;
884
+ if (isIgnoredByClassDirectories(path, ignoredDirectories)) {
885
+ return {
886
+ kind: 'skipped',
887
+ notes: [`Skipped by ${cls.id} scan-target filtering.`],
888
+ reason: 'regrade-scan-target-filtered',
889
+ };
890
+ }
891
+ const effectiveScanTargets: ScanTargets | undefined =
892
+ cls.scanTargets?.extensions === undefined &&
893
+ collection?.extensions === undefined
894
+ ? {
895
+ ...cls.scanTargets,
896
+ extensions: DEFAULT_SOURCE_EXTENSIONS,
897
+ }
898
+ : cls.scanTargets;
899
+ if (
900
+ effectiveScanTargets === undefined ||
901
+ includedByPathScope(path, effectiveScanTargets)
902
+ ) {
903
+ return undefined;
904
+ }
905
+ return {
906
+ kind: 'skipped',
907
+ notes: [`Skipped by ${cls.id} scan-target filtering.`],
908
+ reason: 'regrade-scan-target-filtered',
909
+ };
910
+ };
911
+
912
+ const classifyFile = (
913
+ path: string,
914
+ source: string,
915
+ context: RegradeClassContext,
916
+ selected: readonly RegradeClass[],
917
+ collection?: DownstreamCollectionOptions
918
+ ): RegradeClassifiedFile => {
919
+ // Compose safe rewrites across selected classes in memory so one governed
920
+ // transition can move every compatible symbol in a file. Review still wins:
921
+ // if any class needs judgment, no partial rewrite is returned for that file.
922
+ let skipped:
923
+ | { readonly classId: string; readonly result: RegradeClassResult }
924
+ | undefined;
925
+ let inspected = false;
926
+ let currentSource = source;
927
+ const rewriteClassIds: string[] = [];
928
+ const rewriteNotes: string[] = [];
929
+ for (const cls of selected) {
930
+ const result =
931
+ classScanTargetSkip(cls, path, collection) ??
932
+ cls.apply(currentSource, context);
933
+ if (result.kind !== 'skipped') {
934
+ inspected = true;
935
+ }
936
+ if (result.kind === 'rewrite') {
937
+ if (typeof result.nextSource !== 'string') {
938
+ return {
939
+ entry: {
940
+ classId: cls.id,
941
+ notes: result.notes,
942
+ outcome: 'needs-review',
943
+ path,
944
+ reason: 'regrade-rewrite-missing-source',
945
+ },
946
+ };
947
+ }
948
+ currentSource = result.nextSource;
949
+ rewriteClassIds.push(cls.id);
950
+ rewriteNotes.push(...(result.notes ?? []));
951
+ continue;
952
+ }
953
+ if (result.kind === 'needs-review') {
954
+ const originalSourceResult =
955
+ currentSource === source ? result : cls.apply(source, context);
956
+ const reviewResult =
957
+ originalSourceResult.kind === 'needs-review'
958
+ ? originalSourceResult
959
+ : result;
960
+ const reviewDetails = reviewResult.reviewDetails?.map((detail) => ({
961
+ ...detail,
962
+ classId: detail.classId ?? cls.id,
963
+ }));
964
+ return {
965
+ entry: {
966
+ classId: cls.id,
967
+ notes: reviewResult.notes,
968
+ outcome: 'needs-review',
969
+ path,
970
+ reason: reviewResult.reason ?? 'needs-review',
971
+ ...(reviewDetails === undefined ? {} : { reviewDetails }),
972
+ },
973
+ };
974
+ }
975
+ if (result.kind === 'skipped' && skipped === undefined) {
976
+ skipped = { classId: cls.id, result };
977
+ }
978
+ }
979
+ if (rewriteClassIds.length > 0) {
980
+ const classId = rewriteClassIds.join(',');
981
+ const entry = {
982
+ classId,
983
+ notes: rewriteNotes,
984
+ outcome: 'rewrite',
985
+ path,
986
+ } satisfies RegradeReportEntry;
987
+ return {
988
+ entry,
989
+ ...(context.absolutePath === undefined
990
+ ? {}
991
+ : {
992
+ rewrite: {
993
+ absolutePath: context.absolutePath,
994
+ classId,
995
+ nextSource: currentSource,
996
+ path,
997
+ },
998
+ }),
999
+ };
1000
+ }
1001
+ if (!inspected && skipped !== undefined) {
1002
+ return {
1003
+ entry: {
1004
+ classId: skipped.classId,
1005
+ notes: skipped.result.notes,
1006
+ outcome: 'skip',
1007
+ path,
1008
+ reason: skipped.result.reason ?? 'skipped',
1009
+ },
1010
+ };
1011
+ }
1012
+ return { entry: { outcome: 'no-op', path } };
1013
+ };
1014
+
1015
+ interface RegradeEvaluation {
1016
+ readonly report: RegradeReport;
1017
+ readonly rewrites: readonly RegradeRewriteCandidate[];
1018
+ }
1019
+
1020
+ const includeEntryInReport = (
1021
+ entry: RegradeReportEntry,
1022
+ selection: RegradeReportEntrySelection
1023
+ ): boolean =>
1024
+ selection === 'all' ||
1025
+ entry.outcome === 'rewrite' ||
1026
+ entry.outcome === 'needs-review';
1027
+
1028
+ const skipsByReason = (
1029
+ entries: readonly RegradeReportEntry[]
1030
+ ): Readonly<Record<string, number>> => {
1031
+ const counts = new Map<string, number>();
1032
+ for (const entry of entries) {
1033
+ if (entry.outcome !== 'skip') {
1034
+ continue;
1035
+ }
1036
+ const reason = entry.reason ?? 'skipped';
1037
+ counts.set(reason, (counts.get(reason) ?? 0) + 1);
1038
+ }
1039
+ return Object.fromEntries(
1040
+ [...counts.entries()].toSorted(([left], [right]) =>
1041
+ left.localeCompare(right)
1042
+ )
1043
+ );
1044
+ };
1045
+
1046
+ /**
1047
+ * Build a coverage report from already-read source files. Pure: no filesystem
1048
+ * access, so coverage semantics are testable directly.
1049
+ */
1050
+ const buildRegradeEvaluation = (params: {
1051
+ readonly root: string;
1052
+ readonly files: readonly {
1053
+ readonly path: string;
1054
+ readonly source: string;
1055
+ readonly absolutePath?: string;
1056
+ readonly package?: RegradeClassContext['package'];
1057
+ }[];
1058
+ readonly skipped: readonly SkippedSource[];
1059
+ readonly classes: readonly RegradeClass[];
1060
+ readonly selection?: RegradeSelection;
1061
+ readonly collection?: DownstreamCollectionOptions;
1062
+ readonly includeEntries?: RegradeReportEntrySelection;
1063
+ }): RegradeEvaluation => {
1064
+ const entrySelection = params.includeEntries ?? 'actionable';
1065
+ const { selected, unknownClassIds } = selectRegradeClasses(
1066
+ params.classes,
1067
+ params.selection
1068
+ );
1069
+
1070
+ const classifiedFiles = params.files.map((file) =>
1071
+ classifyFile(
1072
+ file.path,
1073
+ file.source,
1074
+ {
1075
+ ...(file.absolutePath === undefined
1076
+ ? {}
1077
+ : { absolutePath: file.absolutePath }),
1078
+ ...(file.package === undefined ? {} : { package: file.package }),
1079
+ path: file.path,
1080
+ },
1081
+ selected,
1082
+ params.collection
1083
+ )
1084
+ );
1085
+ const fileEntries = classifiedFiles.map((file) => file.entry);
1086
+ const rewrites = classifiedFiles.flatMap((file) =>
1087
+ file.rewrite === undefined ? [] : [file.rewrite]
1088
+ );
1089
+ const skipEntries: RegradeReportEntry[] = params.skipped.map((entry) => ({
1090
+ outcome: 'skip',
1091
+ path: entry.path,
1092
+ reason: entry.reason,
1093
+ }));
1094
+
1095
+ const allEntries = [...fileEntries, ...skipEntries].toSorted((a, b) =>
1096
+ a.path.localeCompare(b.path)
1097
+ );
1098
+ const entries = allEntries.filter((entry) =>
1099
+ includeEntryInReport(entry, entrySelection)
1100
+ );
1101
+
1102
+ // Class-level skips (e.g. scan-target filtering) are accounted as skipped, not
1103
+ // as scanned/clean files.
1104
+ const scannedEntries = fileEntries.filter((e) => e.outcome !== 'skip');
1105
+ const fileSkipCount = fileEntries.length - scannedEntries.length;
1106
+ const rewritten = scannedEntries.filter(
1107
+ (e) => e.outcome === 'rewrite'
1108
+ ).length;
1109
+ const review = scannedEntries.filter(
1110
+ (e) => e.outcome === 'needs-review'
1111
+ ).length;
1112
+ const matchedPaths = scannedEntries
1113
+ .filter((e) => e.outcome === 'rewrite' || e.outcome === 'needs-review')
1114
+ .map((entry) => entry.path);
1115
+ const skipped = skipEntries.length + fileSkipCount;
1116
+ const skippedReasons = skipsByReason(allEntries);
1117
+
1118
+ return {
1119
+ report: withScannedPaths(
1120
+ {
1121
+ entries,
1122
+ matched: rewritten + review,
1123
+ review,
1124
+ rewritten,
1125
+ root: params.root,
1126
+ scan: buildRegradeScanSummary({
1127
+ matchedPaths,
1128
+ scanned: scannedEntries.length,
1129
+ skipped,
1130
+ skippedByReason: skippedReasons,
1131
+ }),
1132
+ scanned: scannedEntries.length,
1133
+ selectedClassIds: selected.map((cls) => cls.id),
1134
+ skipped,
1135
+ skipsByReason: skippedReasons,
1136
+ unknownClassIds,
1137
+ },
1138
+ scannedEntries.map((entry) => entry.path)
1139
+ ),
1140
+ rewrites,
1141
+ };
1142
+ };
1143
+
1144
+ export const buildRegradeReport = (params: {
1145
+ readonly root: string;
1146
+ readonly files: readonly {
1147
+ readonly path: string;
1148
+ readonly source: string;
1149
+ readonly absolutePath?: string;
1150
+ readonly package?: RegradeClassContext['package'];
1151
+ }[];
1152
+ readonly skipped: readonly SkippedSource[];
1153
+ readonly classes: readonly RegradeClass[];
1154
+ readonly selection?: RegradeSelection;
1155
+ readonly collection?: DownstreamCollectionOptions;
1156
+ readonly includeEntries?: RegradeReportEntrySelection;
1157
+ }): RegradeReport => buildRegradeEvaluation(params).report;
1158
+
1159
+ const applyRegradeEvaluation = (
1160
+ evaluation: RegradeEvaluation
1161
+ ): Result<RegradeApplySummary, InternalError> => {
1162
+ if (evaluation.report.unknownClassIds.length > 0) {
1163
+ return Result.ok({
1164
+ applied: 0,
1165
+ filesChanged: 0,
1166
+ review: evaluation.report.review,
1167
+ skipped: evaluation.report.skipped + evaluation.rewrites.length,
1168
+ unknown: evaluation.report.unknownClassIds.length,
1169
+ });
1170
+ }
1171
+
1172
+ const changedFiles = new Set<string>();
1173
+ let applied = 0;
1174
+ for (const rewrite of evaluation.rewrites) {
1175
+ try {
1176
+ writeFileSync(rewrite.absolutePath, rewrite.nextSource, 'utf8');
1177
+ } catch (error: unknown) {
1178
+ return Result.err(
1179
+ new InternalError(
1180
+ `Failed to apply regrade rewrite for "${rewrite.path}".`,
1181
+ {
1182
+ cause: error instanceof Error ? error : new Error(String(error)),
1183
+ context: {
1184
+ applied,
1185
+ classId: rewrite.classId,
1186
+ filesChanged: changedFiles.size,
1187
+ path: rewrite.path,
1188
+ },
1189
+ }
1190
+ )
1191
+ );
1192
+ }
1193
+ applied += 1;
1194
+ changedFiles.add(rewrite.path);
1195
+ }
1196
+
1197
+ return Result.ok({
1198
+ applied: evaluation.rewrites.length,
1199
+ filesChanged: changedFiles.size,
1200
+ review: evaluation.report.review,
1201
+ skipped: evaluation.report.skipped,
1202
+ unknown: 0,
1203
+ });
1204
+ };
1205
+
1206
+ const withApplySummary = (
1207
+ report: RegradeReport,
1208
+ apply: RegradeApplySummary
1209
+ ): RegradeReport => {
1210
+ const appliedReport: RegradeReport = {
1211
+ ...report,
1212
+ apply,
1213
+ };
1214
+
1215
+ return report.scannedPaths === undefined
1216
+ ? appliedReport
1217
+ : withScannedPaths(appliedReport, report.scannedPaths);
1218
+ };
1219
+
1220
+ const canReadDownstreamRoot = (root: string): boolean => {
1221
+ try {
1222
+ readdirSync(root, { withFileTypes: true });
1223
+ return true;
1224
+ } catch {
1225
+ return false;
1226
+ }
1227
+ };
1228
+
1229
+ const PACKAGE_DEPENDENCY_FIELDS = [
1230
+ 'dependencies',
1231
+ 'devDependencies',
1232
+ 'optionalDependencies',
1233
+ 'peerDependencies',
1234
+ ] as const;
1235
+
1236
+ const RUNTIME_PACKAGE_DEPENDENCY_FIELDS = [
1237
+ 'dependencies',
1238
+ 'optionalDependencies',
1239
+ 'peerDependencies',
1240
+ ] as const;
1241
+
1242
+ const dependencyNames = (
1243
+ manifest: Readonly<Record<string, unknown>>,
1244
+ fields: readonly (typeof PACKAGE_DEPENDENCY_FIELDS)[number][]
1245
+ ): readonly string[] =>
1246
+ fields.flatMap((field) => {
1247
+ const value = manifest[field];
1248
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
1249
+ ? Object.keys(value)
1250
+ : [];
1251
+ });
1252
+
1253
+ const packageContextFor = (
1254
+ root: string,
1255
+ absolutePath: string,
1256
+ cache: Map<string, RegradeClassContext['package'] | undefined>
1257
+ ): RegradeClassContext['package'] | undefined => {
1258
+ const absoluteRoot = resolve(root);
1259
+ let current = dirname(absolutePath);
1260
+ const visited: string[] = [];
1261
+ while (true) {
1262
+ if (cache.has(current)) {
1263
+ const cached = cache.get(current);
1264
+ for (const directory of visited) {
1265
+ cache.set(directory, cached);
1266
+ }
1267
+ return cached;
1268
+ }
1269
+ visited.push(current);
1270
+ const fromRoot = relative(absoluteRoot, current);
1271
+ if (fromRoot.startsWith('..') || resolve(current) !== current) {
1272
+ break;
1273
+ }
1274
+ const manifestPath = join(current, 'package.json');
1275
+ if (existsSync(manifestPath)) {
1276
+ const manifestRelativePath = relative(
1277
+ absoluteRoot,
1278
+ manifestPath
1279
+ ).replaceAll('\\', '/');
1280
+ try {
1281
+ const parsed = JSON.parse(
1282
+ readFileSync(manifestPath, 'utf8')
1283
+ ) as unknown;
1284
+ if (
1285
+ typeof parsed !== 'object' ||
1286
+ parsed === null ||
1287
+ Array.isArray(parsed)
1288
+ ) {
1289
+ const invalidContext = {
1290
+ dependencies: [],
1291
+ manifestState: 'invalid' as const,
1292
+ path: manifestRelativePath,
1293
+ };
1294
+ for (const directory of visited) {
1295
+ cache.set(directory, invalidContext);
1296
+ }
1297
+ return invalidContext;
1298
+ }
1299
+ const manifest = parsed as Record<string, unknown>;
1300
+ const dependencies = dependencyNames(
1301
+ manifest,
1302
+ PACKAGE_DEPENDENCY_FIELDS
1303
+ );
1304
+ const runtimeDependencies = dependencyNames(
1305
+ manifest,
1306
+ RUNTIME_PACKAGE_DEPENDENCY_FIELDS
1307
+ );
1308
+ const packageContext = {
1309
+ dependencies: [...new Set(dependencies)].toSorted(),
1310
+ manifestState: 'valid' as const,
1311
+ ...(typeof manifest['name'] === 'string'
1312
+ ? { name: manifest['name'] }
1313
+ : {}),
1314
+ path: manifestRelativePath,
1315
+ runtimeDependencies: [...new Set(runtimeDependencies)].toSorted(),
1316
+ };
1317
+ for (const directory of visited) {
1318
+ cache.set(directory, packageContext);
1319
+ }
1320
+ return packageContext;
1321
+ } catch {
1322
+ const invalidContext = {
1323
+ dependencies: [],
1324
+ manifestState: 'invalid' as const,
1325
+ path: manifestRelativePath,
1326
+ };
1327
+ for (const directory of visited) {
1328
+ cache.set(directory, invalidContext);
1329
+ }
1330
+ return invalidContext;
1331
+ }
1332
+ }
1333
+ if (current === absoluteRoot) {
1334
+ break;
1335
+ }
1336
+ current = dirname(current);
1337
+ }
1338
+ for (const directory of visited) {
1339
+ cache.set(directory, undefined);
1340
+ }
1341
+ return undefined;
1342
+ };
1343
+
1344
+ interface RegradeSourceState {
1345
+ readonly hash: string;
1346
+ readonly unreadable: readonly string[];
1347
+ }
1348
+
1349
+ interface CollectedRegradeEvaluation {
1350
+ readonly evaluation: RegradeEvaluation;
1351
+ readonly sourceState: RegradeSourceState;
1352
+ }
1353
+
1354
+ interface PrepareRegradeRunParams {
1355
+ readonly root: string;
1356
+ readonly classes: readonly RegradeClass[];
1357
+ readonly selection?: RegradeSelection;
1358
+ readonly collection?: DownstreamCollectionOptions;
1359
+ readonly includeEntries?: RegradeReportEntrySelection;
1360
+ }
1361
+
1362
+ const snapshotPrepareRegradeRunParams = (
1363
+ params: PrepareRegradeRunParams
1364
+ ): PrepareRegradeRunParams => ({
1365
+ classes: params.classes.map((regradeClass) => ({
1366
+ ...regradeClass,
1367
+ ...(regradeClass.scanTargets === undefined
1368
+ ? {}
1369
+ : {
1370
+ scanTargets: {
1371
+ ...regradeClass.scanTargets,
1372
+ ...(regradeClass.scanTargets.exclude === undefined
1373
+ ? {}
1374
+ : { exclude: [...regradeClass.scanTargets.exclude] }),
1375
+ ...(regradeClass.scanTargets.extensions === undefined
1376
+ ? {}
1377
+ : { extensions: [...regradeClass.scanTargets.extensions] }),
1378
+ ...(regradeClass.scanTargets.ignoredDirectories === undefined
1379
+ ? {}
1380
+ : {
1381
+ ignoredDirectories: [
1382
+ ...regradeClass.scanTargets.ignoredDirectories,
1383
+ ],
1384
+ }),
1385
+ },
1386
+ }),
1387
+ })),
1388
+ ...(params.collection === undefined
1389
+ ? {}
1390
+ : {
1391
+ collection: {
1392
+ ...params.collection,
1393
+ ...(params.collection.exclude === undefined
1394
+ ? {}
1395
+ : { exclude: [...params.collection.exclude] }),
1396
+ ...(params.collection.extensions === undefined
1397
+ ? {}
1398
+ : { extensions: [...params.collection.extensions] }),
1399
+ ...(params.collection.ignoredDirectories === undefined
1400
+ ? {}
1401
+ : {
1402
+ ignoredDirectories: [...params.collection.ignoredDirectories],
1403
+ }),
1404
+ ...(params.collection.include === undefined
1405
+ ? {}
1406
+ : { include: [...params.collection.include] }),
1407
+ },
1408
+ }),
1409
+ ...(params.includeEntries === undefined
1410
+ ? {}
1411
+ : { includeEntries: params.includeEntries }),
1412
+ root: params.root,
1413
+ ...(params.selection === undefined
1414
+ ? {}
1415
+ : {
1416
+ selection:
1417
+ params.selection.classIds === undefined
1418
+ ? {}
1419
+ : { classIds: [...params.selection.classIds] },
1420
+ }),
1421
+ });
1422
+
1423
+ const compareRegradeCodeUnits = (left: string, right: string): number => {
1424
+ if (left < right) {
1425
+ return -1;
1426
+ }
1427
+ if (left > right) {
1428
+ return 1;
1429
+ }
1430
+ return 0;
1431
+ };
1432
+
1433
+ const regradeSourceStateHash = (state: {
1434
+ readonly manifests: readonly {
1435
+ readonly bytes: string;
1436
+ readonly path: string;
1437
+ }[];
1438
+ readonly sources: readonly {
1439
+ readonly bytes: string;
1440
+ readonly path: string;
1441
+ }[];
1442
+ }): string => createHash('sha256').update(JSON.stringify(state)).digest('hex');
1443
+
1444
+ const collectRegradeEvaluation = (
1445
+ params: PrepareRegradeRunParams
1446
+ ): CollectedRegradeEvaluation | null => {
1447
+ const { selected, unknownClassIds } = selectRegradeClasses(
1448
+ params.classes,
1449
+ params.selection
1450
+ );
1451
+ if (selected.length === 0 && unknownClassIds.length > 0) {
1452
+ if (!canReadDownstreamRoot(params.root)) {
1453
+ return null;
1454
+ }
1455
+ return {
1456
+ evaluation: buildRegradeEvaluation({
1457
+ classes: params.classes,
1458
+ ...(params.collection === undefined
1459
+ ? {}
1460
+ : { collection: params.collection }),
1461
+ files: [],
1462
+ root: params.root,
1463
+ skipped: [],
1464
+ ...(params.includeEntries === undefined
1465
+ ? {}
1466
+ : { includeEntries: params.includeEntries }),
1467
+ ...(params.selection === undefined
1468
+ ? {}
1469
+ : { selection: params.selection }),
1470
+ }),
1471
+ sourceState: {
1472
+ hash: regradeSourceStateHash({ manifests: [], sources: [] }),
1473
+ unreadable: [],
1474
+ },
1475
+ };
1476
+ }
1477
+
1478
+ const collected = collectDownstreamSources(
1479
+ params.root,
1480
+ deriveCollectionOptions(selected, params.collection)
1481
+ );
1482
+ if (collected === null) {
1483
+ return null;
1484
+ }
1485
+
1486
+ const files: { absolutePath: string; path: string; source: string }[] = [];
1487
+ const skipped: SkippedSource[] = [...collected.skipped];
1488
+ const sourceBytes: { readonly bytes: string; readonly path: string }[] = [];
1489
+ const unreadable = new Set(
1490
+ collected.skipped
1491
+ .filter((entry) => entry.reason === 'unreadable-directory')
1492
+ .map((entry) => entry.path)
1493
+ );
1494
+ const manifestPaths = new Set<string>();
1495
+ const packageContextCache = new Map<
1496
+ string,
1497
+ RegradeClassContext['package'] | undefined
1498
+ >();
1499
+ for (const file of collected.files) {
1500
+ try {
1501
+ const bytes = readFileSync(file.absolutePath);
1502
+ const packageContext = packageContextFor(
1503
+ params.root,
1504
+ file.absolutePath,
1505
+ packageContextCache
1506
+ );
1507
+ if (packageContext !== undefined) {
1508
+ manifestPaths.add(packageContext.path);
1509
+ }
1510
+ files.push({
1511
+ absolutePath: file.absolutePath,
1512
+ ...(packageContext === undefined ? {} : { package: packageContext }),
1513
+ path: file.path,
1514
+ source: bytes.toString('utf8'),
1515
+ });
1516
+ sourceBytes.push({ bytes: bytes.toString('base64'), path: file.path });
1517
+ } catch {
1518
+ skipped.push({ path: file.path, reason: 'unreadable-file' });
1519
+ unreadable.add(file.path);
1520
+ }
1521
+ }
1522
+
1523
+ const manifestBytes = [...manifestPaths].toSorted().flatMap((path) => {
1524
+ try {
1525
+ return [
1526
+ {
1527
+ bytes: readFileSync(join(resolve(params.root), path)).toString(
1528
+ 'base64'
1529
+ ),
1530
+ path,
1531
+ },
1532
+ ];
1533
+ } catch {
1534
+ unreadable.add(path);
1535
+ return [];
1536
+ }
1537
+ });
1538
+
1539
+ return {
1540
+ evaluation: buildRegradeEvaluation({
1541
+ classes: params.classes,
1542
+ ...(params.collection === undefined
1543
+ ? {}
1544
+ : { collection: params.collection }),
1545
+ files,
1546
+ root: params.root,
1547
+ skipped,
1548
+ ...(params.includeEntries === undefined
1549
+ ? {}
1550
+ : { includeEntries: params.includeEntries }),
1551
+ ...(params.selection === undefined
1552
+ ? {}
1553
+ : { selection: params.selection }),
1554
+ }),
1555
+ sourceState: {
1556
+ hash: regradeSourceStateHash({
1557
+ manifests: manifestBytes,
1558
+ sources: sourceBytes.toSorted((left, right) =>
1559
+ compareRegradeCodeUnits(left.path, right.path)
1560
+ ),
1561
+ }),
1562
+ unreadable: [...unreadable].toSorted(),
1563
+ },
1564
+ };
1565
+ };
1566
+
1567
+ const collectRegradeSourceState = (
1568
+ params: PrepareRegradeRunParams
1569
+ ): RegradeSourceState | null => {
1570
+ const { selected, unknownClassIds } = selectRegradeClasses(
1571
+ params.classes,
1572
+ params.selection
1573
+ );
1574
+ if (selected.length === 0 && unknownClassIds.length > 0) {
1575
+ return canReadDownstreamRoot(params.root)
1576
+ ? {
1577
+ hash: regradeSourceStateHash({ manifests: [], sources: [] }),
1578
+ unreadable: [],
1579
+ }
1580
+ : null;
1581
+ }
1582
+ const collected = collectDownstreamSources(
1583
+ params.root,
1584
+ deriveCollectionOptions(selected, params.collection)
1585
+ );
1586
+ if (collected === null) {
1587
+ return null;
1588
+ }
1589
+ const sourceBytes: { readonly bytes: string; readonly path: string }[] = [];
1590
+ const unreadable = new Set(
1591
+ collected.skipped
1592
+ .filter((entry) => entry.reason === 'unreadable-directory')
1593
+ .map((entry) => entry.path)
1594
+ );
1595
+ const manifestPaths = new Set<string>();
1596
+ const packageContextCache = new Map<
1597
+ string,
1598
+ RegradeClassContext['package'] | undefined
1599
+ >();
1600
+ for (const file of collected.files) {
1601
+ try {
1602
+ const bytes = readFileSync(file.absolutePath);
1603
+ sourceBytes.push({ bytes: bytes.toString('base64'), path: file.path });
1604
+ const packageContext = packageContextFor(
1605
+ params.root,
1606
+ file.absolutePath,
1607
+ packageContextCache
1608
+ );
1609
+ if (packageContext !== undefined) {
1610
+ manifestPaths.add(packageContext.path);
1611
+ }
1612
+ } catch {
1613
+ unreadable.add(file.path);
1614
+ }
1615
+ }
1616
+ const manifestBytes = [...manifestPaths]
1617
+ .toSorted(compareRegradeCodeUnits)
1618
+ .flatMap((path) => {
1619
+ try {
1620
+ return [
1621
+ {
1622
+ bytes: readFileSync(join(resolve(params.root), path)).toString(
1623
+ 'base64'
1624
+ ),
1625
+ path,
1626
+ },
1627
+ ];
1628
+ } catch {
1629
+ unreadable.add(path);
1630
+ return [];
1631
+ }
1632
+ });
1633
+ return {
1634
+ hash: regradeSourceStateHash({
1635
+ manifests: manifestBytes,
1636
+ sources: sourceBytes.toSorted((left, right) =>
1637
+ compareRegradeCodeUnits(left.path, right.path)
1638
+ ),
1639
+ }),
1640
+ unreadable: [...unreadable].toSorted(compareRegradeCodeUnits),
1641
+ };
1642
+ };
1643
+
1644
+ interface PreparedRegradeRunState {
1645
+ readonly evaluation: RegradeEvaluation;
1646
+ readonly identity: PreparedRegradeRunIdentity;
1647
+ readonly params: PrepareRegradeRunParams;
1648
+ readonly report: RegradeReport;
1649
+ readonly sourceStateHash: string;
1650
+ }
1651
+
1652
+ const preparedRegradeRunStates = new WeakMap<
1653
+ PreparedRegradeRun,
1654
+ PreparedRegradeRunState
1655
+ >();
1656
+
1657
+ const validatePreparedIdentity = (
1658
+ expected: PreparedRegradeRunIdentity,
1659
+ actual: PreparedRegradeRunIdentity
1660
+ ): Result<void, ValidationError> => {
1661
+ for (const field of [
1662
+ 'planContentHash',
1663
+ 'policyHash',
1664
+ 'scopeHash',
1665
+ 'lockStateHash',
1666
+ 'toolVersion',
1667
+ ] as const) {
1668
+ if (expected[field] !== actual[field]) {
1669
+ return Result.err(
1670
+ new ValidationError(
1671
+ `Prepared Regrade identity field \`${field}\` is stale.`,
1672
+ {
1673
+ context: {
1674
+ actual: actual[field],
1675
+ expected: expected[field],
1676
+ field,
1677
+ },
1678
+ }
1679
+ )
1680
+ );
1681
+ }
1682
+ }
1683
+ return Result.ok();
1684
+ };
1685
+
1686
+ /**
1687
+ * Classify a class/symbol Regrade run once and retain it for checked apply.
1688
+ *
1689
+ * @example
1690
+ * ```ts
1691
+ * const prepared = prepareRegradeRun({ classes, identity, root });
1692
+ * if (prepared.isErr()) throw prepared.error;
1693
+ * ```
1694
+ */
1695
+ export const prepareRegradeRun = (
1696
+ params: PrepareRegradeRunParams & {
1697
+ readonly identity: PreparedRegradeRunIdentity;
1698
+ }
1699
+ ): Result<PreparedRegradeRun | null, ValidationError> => {
1700
+ const preparedParams = snapshotPrepareRegradeRunParams(params);
1701
+ const collected = collectRegradeEvaluation(preparedParams);
1702
+ if (collected === null) {
1703
+ return Result.ok(null);
1704
+ }
1705
+ if (collected.sourceState.unreadable.length > 0) {
1706
+ return Result.err(
1707
+ new ValidationError('Prepared Regrade sources must all be readable.', {
1708
+ context: { paths: collected.sourceState.unreadable },
1709
+ })
1710
+ );
1711
+ }
1712
+ const prepared: PreparedRegradeRun = {
1713
+ identity: { ...params.identity },
1714
+ report: cloneRegradeReport(collected.evaluation.report),
1715
+ sourceStateHash: collected.sourceState.hash,
1716
+ };
1717
+ preparedRegradeRunStates.set(prepared, {
1718
+ evaluation: collected.evaluation,
1719
+ identity: { ...params.identity },
1720
+ params: preparedParams,
1721
+ report: collected.evaluation.report,
1722
+ sourceStateHash: collected.sourceState.hash,
1723
+ });
1724
+ return Result.ok(prepared);
1725
+ };
1726
+
1727
+ /**
1728
+ * Validate that an in-memory class/symbol evaluation still matches its
1729
+ * authored identity and source bytes without applying it.
1730
+ *
1731
+ * @example
1732
+ * ```ts
1733
+ * const current = validatePreparedRegradeRun(prepared, identity);
1734
+ * if (current.isErr()) throw current.error;
1735
+ * ```
1736
+ */
1737
+ export const validatePreparedRegradeRun = (
1738
+ prepared: PreparedRegradeRun,
1739
+ identity: PreparedRegradeRunIdentity
1740
+ ): Result<void, ValidationError> => {
1741
+ const state = preparedRegradeRunStates.get(prepared);
1742
+ if (state === undefined) {
1743
+ return Result.err(
1744
+ new ValidationError(
1745
+ 'Prepared Regrade run is not the original in-memory evaluation.'
1746
+ )
1747
+ );
1748
+ }
1749
+ const identityValidation = validatePreparedIdentity(state.identity, identity);
1750
+ if (identityValidation.isErr()) {
1751
+ return identityValidation;
1752
+ }
1753
+ const current = collectRegradeSourceState(state.params);
1754
+ if (current === null) {
1755
+ return Result.err(
1756
+ new ValidationError('Prepared Regrade root is no longer readable.')
1757
+ );
1758
+ }
1759
+ if (current.unreadable.length > 0) {
1760
+ return Result.err(
1761
+ new ValidationError('Prepared Regrade sources are no longer readable.', {
1762
+ context: { paths: current.unreadable },
1763
+ })
1764
+ );
1765
+ }
1766
+ if (current.hash !== state.sourceStateHash) {
1767
+ return Result.err(
1768
+ new ValidationError('Prepared Regrade source state is stale.', {
1769
+ context: {
1770
+ actual: current.hash,
1771
+ expected: state.sourceStateHash,
1772
+ },
1773
+ })
1774
+ );
1775
+ }
1776
+ return Result.ok();
1777
+ };
1778
+
1779
+ /**
1780
+ * Apply an in-memory class/symbol evaluation after identity and source checks.
1781
+ *
1782
+ * @example
1783
+ * ```ts
1784
+ * const applied = applyPreparedRegradeRun(prepared, identity);
1785
+ * if (applied.isErr()) throw applied.error;
1786
+ * ```
1787
+ */
1788
+ export const applyPreparedRegradeRun = (
1789
+ prepared: PreparedRegradeRun,
1790
+ identity: PreparedRegradeRunIdentity
1791
+ ): Result<RegradeReport, InternalError | ValidationError> => {
1792
+ const state = preparedRegradeRunStates.get(prepared);
1793
+ const validation = validatePreparedRegradeRun(prepared, identity);
1794
+ if (validation.isErr()) {
1795
+ return validation;
1796
+ }
1797
+ if (state === undefined) {
1798
+ return Result.err(
1799
+ new ValidationError(
1800
+ 'Prepared Regrade run is not the original in-memory evaluation.'
1801
+ )
1802
+ );
1803
+ }
1804
+ const applyResult = applyRegradeEvaluation(state.evaluation);
1805
+ if (applyResult.isErr()) {
1806
+ return applyResult;
1807
+ }
1808
+ return Result.ok(withApplySummary(state.report, applyResult.value));
1809
+ };
1810
+
1811
+ /**
1812
+ * Run a regrade over an explicit downstream root.
1813
+ *
1814
+ * Dry-run is the default and only reports candidate rewrites. Explicit apply
1815
+ * mode writes safe rewrite outcomes with concrete `nextSource` payloads and
1816
+ * summarizes what was written or intentionally skipped.
1817
+ */
1818
+ export const runRegrade = (params: {
1819
+ readonly root: string;
1820
+ readonly classes: readonly RegradeClass[];
1821
+ readonly selection?: RegradeSelection;
1822
+ readonly collection?: DownstreamCollectionOptions;
1823
+ readonly apply?: boolean;
1824
+ readonly includeEntries?: RegradeReportEntrySelection;
1825
+ }): Result<RegradeReport | null, InternalError> => {
1826
+ const collected = collectRegradeEvaluation(params);
1827
+ if (collected === null) {
1828
+ return Result.ok(null);
1829
+ }
1830
+ const { evaluation } = collected;
1831
+
1832
+ if (params.apply !== true) {
1833
+ return Result.ok(evaluation.report);
1834
+ }
1835
+
1836
+ const applyResult = applyRegradeEvaluation(evaluation);
1837
+ if (applyResult.isErr()) {
1838
+ return applyResult;
1839
+ }
1840
+
1841
+ return Result.ok(withApplySummary(evaluation.report, applyResult.value));
1842
+ };
1843
+
1844
+ const regradeReportEntrySchema = z.object({
1845
+ classId: z.string().optional().describe('Class that produced the outcome'),
1846
+ notes: z.array(z.string()).optional().describe('Notes from the class'),
1847
+ outcome: z
1848
+ .enum(['needs-review', 'no-op', 'rewrite', 'skip'])
1849
+ .describe('What happened to the entry'),
1850
+ path: z.string().describe('Root-relative POSIX path'),
1851
+ reason: z.string().optional().describe('Reason for a skip or review outcome'),
1852
+ reviewDetails: z
1853
+ .array(
1854
+ z.object({
1855
+ candidateReplacement: z
1856
+ .string()
1857
+ .optional()
1858
+ .describe(
1859
+ 'Concrete replacement the class would apply if the occurrence were judged safe'
1860
+ ),
1861
+ classId: z
1862
+ .string()
1863
+ .optional()
1864
+ .describe('Class that produced the review detail'),
1865
+ context: z
1866
+ .string()
1867
+ .optional()
1868
+ .describe('Exact source-line context containing the occurrence'),
1869
+ expectedTarget: z
1870
+ .string()
1871
+ .optional()
1872
+ .describe('Expected target shape for the migration'),
1873
+ fixture: z
1874
+ .string()
1875
+ .optional()
1876
+ .describe('Fixture or example reference for the migration'),
1877
+ judgment: z
1878
+ .enum(['preserve', 'rewrite', 'unresolved'])
1879
+ .optional()
1880
+ .describe(
1881
+ 'Verdict state: unresolved = occurrence judgment is incomplete and needs a human or agent decision; preserve = completed verdict to keep the occurrence; rewrite = completed verdict that a rewrite is intended but this run could not apply it'
1882
+ ),
1883
+ matchedForm: z
1884
+ .string()
1885
+ .optional()
1886
+ .describe(
1887
+ 'Exact matched source text for the occurrence under review'
1888
+ ),
1889
+ nodeKind: z
1890
+ .string()
1891
+ .optional()
1892
+ .describe('AST node kind or source construct kind'),
1893
+ preserveCautions: z
1894
+ .array(z.string())
1895
+ .optional()
1896
+ .describe(
1897
+ 'Cautions explaining why a blind rewrite of this occurrence is unsafe'
1898
+ ),
1899
+ reason: z.string().describe('Machine-readable review reason'),
1900
+ signals: z
1901
+ .array(z.string())
1902
+ .optional()
1903
+ .describe(
1904
+ 'Machine-readable provenance tags for the producing rule or class'
1905
+ ),
1906
+ span: z
1907
+ .object({
1908
+ column: z.number().describe('One-based source column'),
1909
+ end: z.number().describe('Source end offset'),
1910
+ line: z.number().describe('One-based source line'),
1911
+ start: z.number().describe('Source start offset'),
1912
+ })
1913
+ .optional()
1914
+ .describe('Source span that needs review'),
1915
+ suggestedValidation: z
1916
+ .string()
1917
+ .optional()
1918
+ .describe('Suggested validation command after resolving review'),
1919
+ symbol: z.string().optional().describe('Symbol or term under review'),
1920
+ })
1921
+ )
1922
+ .optional()
1923
+ .describe('Structured review details from the producing class'),
1924
+ });
1925
+
1926
+ const regradeApplySummarySchema = z.object({
1927
+ applied: z.number().describe('Safe rewrite outcomes written to disk'),
1928
+ filesChanged: z.number().describe('Distinct files changed on disk'),
1929
+ review: z.number().describe('Files still requiring review'),
1930
+ skipped: z.number().describe('Rewrite candidates intentionally not written'),
1931
+ unknown: z.number().describe('Unknown selected class ids'),
1932
+ });
1933
+
1934
+ export const regradeReportOutput = z.object({
1935
+ apply: regradeApplySummarySchema
1936
+ .optional()
1937
+ .describe('Apply-mode summary; absent for dry-run report-only calls'),
1938
+ entries: z
1939
+ .array(regradeReportEntrySchema)
1940
+ .describe(
1941
+ 'Per-entry detail, sorted by path. Defaults to actionable rewrite/review entries.'
1942
+ ),
1943
+ history: z
1944
+ .object({
1945
+ id: z
1946
+ .string()
1947
+ .optional()
1948
+ .describe('Stable consolidated Regrade history identity'),
1949
+ path: z.string().describe('Root-relative applied history entry path'),
1950
+ provenance: governedVocabularyHistoryProvenanceSchema
1951
+ .optional()
1952
+ .describe('Governed transition evidence attached to the applied run'),
1953
+ schemaVersion: z.number().describe('Regrade history schema version'),
1954
+ status: z
1955
+ .enum(['applied', 'checked', 'replay'])
1956
+ .describe(
1957
+ 'How this command used the Regrade history file: applied = run appended, replay = identical re-run recognized and not duplicated, checked = consolidated history verified per-run'
1958
+ ),
1959
+ })
1960
+ .optional()
1961
+ .describe('Saved applied Regrade history evidence'),
1962
+ matched: z.number().describe('Files with a rewrite or review outcome'),
1963
+ packageSource: z
1964
+ .object({
1965
+ artifactSha256: z.string().regex(/^[a-f0-9]{64}$/u),
1966
+ contentSha256: z.string().regex(/^[a-f0-9]{64}$/u),
1967
+ declaredSpecifier: z.string(),
1968
+ kind: z.enum(['published', 'tarball']),
1969
+ name: z.string().regex(/^@ontrails\/[a-z0-9][a-z0-9._-]*$/u),
1970
+ resolvedPackagePath: z.string(),
1971
+ version: z.string(),
1972
+ })
1973
+ .optional()
1974
+ .describe(
1975
+ 'Verified source evidence for one selected downstream Trails package'
1976
+ ),
1977
+ plan: z
1978
+ .object({
1979
+ expansionPending: z
1980
+ .number()
1981
+ .optional()
1982
+ .describe('Pending staged expansion candidates on this plan'),
1983
+ path: z.string().describe('Root-relative Regrade plan path'),
1984
+ schemaVersion: z.number().describe('Regrade plan schema version'),
1985
+ status: z
1986
+ .enum(['active', 'stale'])
1987
+ .describe('Whether the saved plan still matches the source tree'),
1988
+ })
1989
+ .optional()
1990
+ .describe('Saved active Regrade plan evidence'),
1991
+ record: z
1992
+ .object({
1993
+ path: z.string().describe('Root-relative transition record path'),
1994
+ schemaVersion: z.number().describe('Transition record schema version'),
1995
+ status: z
1996
+ .enum(['candidate', 'applied', 'checked'])
1997
+ .describe('How this command used the transition record'),
1998
+ })
1999
+ .optional()
2000
+ .describe('Persisted transition record evidence'),
2001
+ review: z.number().describe('Files routed to review'),
2002
+ rewritten: z.number().describe('Files with a rewrite outcome'),
2003
+ root: z.string().describe('Root the run scanned'),
2004
+ run: vocabularyRegradeRunOutput
2005
+ .optional()
2006
+ .describe('Vocabulary regrade run: plan, ledger, and completion report'),
2007
+ scan: regradeScanSummaryOutput.describe(
2008
+ 'Agent-facing inventory summary for the scan'
2009
+ ),
2010
+ scanned: z.number().describe('Source files inspected'),
2011
+ selectedClassIds: z.array(z.string()).describe('Class ids executed'),
2012
+ skipped: z.number().describe('Entries skipped'),
2013
+ skipsByReason: z
2014
+ .record(z.string(), z.number())
2015
+ .describe('Skipped entries grouped by reason'),
2016
+ unknownClassIds: z
2017
+ .array(z.string())
2018
+ .describe('Selected ids that did not resolve to a class'),
2019
+ });
2020
+
2021
+ /**
2022
+ * Built-in regrade classes available to the report trail.
2023
+ *
2024
+ * Warden owns term detection and fix metadata. Regrade derives Warden rules
2025
+ * that advertise `term-rewrite` capability into reportable classes and then
2026
+ * owns application/reporting of the resulting rewrite or review outcomes.
2027
+ */
2028
+ export const wardenTermRewriteClasses: readonly RegradeClass[] = Object.freeze(
2029
+ [...wardenRules.values()].flatMap((rule) => {
2030
+ const cls = createWardenTermRewriteClass(rule);
2031
+ return cls === null ? [] : [cls];
2032
+ })
2033
+ );
2034
+
2035
+ const duplicateClassDiagnostics = (
2036
+ root: string,
2037
+ classes: readonly RegradeClass[]
2038
+ ): readonly WardenDiagnostic[] => {
2039
+ const seen = new Set<string>();
2040
+ const diagnostics: WardenDiagnostic[] = [];
2041
+ for (const cls of classes) {
2042
+ if (!seen.has(cls.id)) {
2043
+ seen.add(cls.id);
2044
+ continue;
2045
+ }
2046
+ diagnostics.push({
2047
+ filePath: root,
2048
+ line: 1,
2049
+ message: `Duplicate Regrade class id "${cls.id}" from Warden term-rewrite rules.`,
2050
+ rule: 'regrade-warden-term-rewrite-classes',
2051
+ severity: 'error',
2052
+ });
2053
+ }
2054
+ return diagnostics;
2055
+ };
2056
+
2057
+ /**
2058
+ * Load built-in and project-local Warden term-rewrite rules as Regrade classes.
2059
+ *
2060
+ * Built-ins are always available. When `root` is provided, committed
2061
+ * project-local Warden rules under `.trails/rules.ts` or direct
2062
+ * `.trails/rules/*.ts` modules are loaded and any term-rewrite-capable source
2063
+ * rules join the class set.
2064
+ */
2065
+ export const loadWardenTermRewriteClasses = async (
2066
+ root?: string
2067
+ ): Promise<RegradeWardenClassSet> => {
2068
+ if (root === undefined) {
2069
+ return { classes: wardenTermRewriteClasses, diagnostics: [] };
2070
+ }
2071
+
2072
+ const projectRules = await loadProjectWardenRules(root);
2073
+ const projectClasses = projectRules.sourceRules.flatMap((rule) => {
2074
+ const cls = createWardenTermRewriteClass(rule);
2075
+ return cls === null ? [] : [cls];
2076
+ });
2077
+ const classes = [...wardenTermRewriteClasses, ...projectClasses];
2078
+ return {
2079
+ classes,
2080
+ diagnostics: [
2081
+ ...projectRules.diagnostics,
2082
+ ...duplicateClassDiagnostics(root, classes),
2083
+ ],
2084
+ };
2085
+ };