@ontrails/regrade 1.0.0-beta.30 → 1.0.0-beta.39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,12 +1,20 @@
1
- import { InternalError, Result } from '@ontrails/core';
1
+ import {
2
+ InternalError,
3
+ Result,
4
+ escapeRegExp,
5
+ includedByPathScope,
6
+ } from '@ontrails/core';
7
+ import type { ScanTargets } from '@ontrails/core';
2
8
  import type {
3
9
  WardenDiagnostic,
4
10
  WardenFixEdit,
11
+ WardenGuidance,
5
12
  WardenRule,
6
13
  } from '@ontrails/warden';
7
14
  import {
8
15
  getWardenRuleMetadata,
9
16
  isWardenSourceScanTarget,
17
+ loadProjectWardenRules,
10
18
  wardenRules,
11
19
  } from '@ontrails/warden';
12
20
  import { readFileSync, readdirSync, writeFileSync } from 'node:fs';
@@ -18,6 +26,13 @@ import {
18
26
  collectDownstreamSources,
19
27
  } from './collect.js';
20
28
  import type { DownstreamCollectionOptions, SkippedSource } from './collect.js';
29
+ import {
30
+ buildRegradeScanSummary,
31
+ regradeScanSummaryOutput,
32
+ } from './scan-summary.js';
33
+ import type { RegradeScanSummary } from './scan-summary.js';
34
+ import type { VocabularyRegradeRun } from './vocabulary.js';
35
+ import { vocabularyRegradeRunOutput } from './vocabulary.js';
21
36
 
22
37
  /**
23
38
  * Regrade-class selection and coverage reporting (TRL-845).
@@ -67,12 +82,14 @@ export interface RegradeClassContext {
67
82
  }
68
83
 
69
84
  /** 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. */
85
+ export type RegradeScanTargets = ScanTargets & {
86
+ /**
87
+ * @deprecated Use collection-level `exclude` globs. Preserved so existing
88
+ * Regrade classes can explicitly opt into directories the default collector
89
+ * prunes, such as `dist`, while migrating to PathScope.
90
+ */
74
91
  readonly ignoredDirectories?: readonly string[];
75
- }
92
+ };
76
93
 
77
94
  /** One named, contract-aware transform. */
78
95
  export interface RegradeClass {
@@ -89,12 +106,22 @@ export interface RegradeClass {
89
106
  readonly scanTargets?: RegradeScanTargets;
90
107
  }
91
108
 
109
+ export interface RegradeWardenClassSet {
110
+ /** Built-in and project-local Warden term-rewrite classes. */
111
+ readonly classes: readonly RegradeClass[];
112
+ /** Diagnostics raised while loading project-local rules. */
113
+ readonly diagnostics: readonly WardenDiagnostic[];
114
+ }
115
+
92
116
  /** Which regrade classes a run should execute. */
93
117
  export interface RegradeSelection {
94
118
  /** Class ids to run. Omit to run every provided class. */
95
119
  readonly classIds?: readonly string[];
96
120
  }
97
121
 
122
+ /** Which report entries should be returned. Counts always cover the full run. */
123
+ export type RegradeReportEntrySelection = 'actionable' | 'all';
124
+
98
125
  /** Optional write summary for an apply-mode regrade run. */
99
126
  export interface RegradeApplySummary {
100
127
  /** Safe rewrite outcomes written to disk. */
@@ -117,18 +144,39 @@ export interface RegradeReviewSpan {
117
144
  readonly start: number;
118
145
  }
119
146
 
147
+ /**
148
+ * Verdict state for a review detail.
149
+ *
150
+ * - `unresolved`: the class could not complete occurrence judgment; a human or
151
+ * agent decision is still needed.
152
+ * - `preserve`: a completed verdict to keep the occurrence as-is.
153
+ * - `rewrite`: a completed verdict that a rewrite is intended but this run
154
+ * could not apply it (for example invalid or missing edits).
155
+ */
156
+ export type RegradeReviewJudgment = 'preserve' | 'rewrite' | 'unresolved';
157
+
120
158
  /** Structured detail explaining why a source match needs review. */
121
159
  export interface RegradeReviewDetail {
160
+ /** Concrete replacement the class would apply if the occurrence were judged safe. */
161
+ readonly candidateReplacement?: string;
122
162
  /** Class that produced the review detail, injected by report building. */
123
163
  readonly classId?: string;
124
164
  /** Expected target shape when the class can describe one. */
125
165
  readonly expectedTarget?: string;
126
166
  /** Fixture or example reference that illustrates the expected migration. */
127
167
  readonly fixture?: string;
168
+ /** Whether occurrence judgment is unresolved or a preserve/rewrite verdict completed. */
169
+ readonly judgment?: RegradeReviewJudgment;
170
+ /** Exact matched source text for the occurrence under review. */
171
+ readonly matchedForm?: string;
128
172
  /** AST node kind or source construct kind. */
129
173
  readonly nodeKind?: string;
174
+ /** Cautions explaining why a blind rewrite of this occurrence is unsafe. */
175
+ readonly preserveCautions?: readonly string[];
130
176
  /** Machine-readable reason for review. */
131
177
  readonly reason: string;
178
+ /** Machine-readable provenance tags for the producing rule or class. */
179
+ readonly signals?: readonly string[];
132
180
  /** Source span and line/column for the review-required match. */
133
181
  readonly span?: RegradeReviewSpan;
134
182
  /** Suggested validation command after the review is resolved. */
@@ -137,9 +185,6 @@ export interface RegradeReviewDetail {
137
185
  readonly symbol?: string;
138
186
  }
139
187
 
140
- const escapeRegExp = (value: string): string =>
141
- value.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&');
142
-
143
188
  /**
144
189
  * Build a whole-word term-rewrite class.
145
190
  *
@@ -198,6 +243,27 @@ type WardenEditApplication =
198
243
  | { readonly ok: true; readonly nextSource: string }
199
244
  | { readonly ok: false; readonly reason: string };
200
245
 
246
+ const regradeScanTargetsFromWardenFix = (
247
+ scanTargets: NonNullable<
248
+ NonNullable<ReturnType<typeof getWardenRuleMetadata>>['fix']
249
+ >['scanTargets']
250
+ ): RegradeScanTargets | undefined => {
251
+ if (scanTargets === undefined) {
252
+ return undefined;
253
+ }
254
+ return {
255
+ ...(scanTargets.exclude === undefined
256
+ ? {}
257
+ : { exclude: scanTargets.exclude }),
258
+ ...(scanTargets.extensions === undefined
259
+ ? {}
260
+ : { extensions: scanTargets.extensions }),
261
+ ...(scanTargets.ignoredDirectories === undefined
262
+ ? {}
263
+ : { ignoredDirectories: scanTargets.ignoredDirectories }),
264
+ };
265
+ };
266
+
201
267
  const diagnosticNote = (diagnostic: WardenDiagnostic): string => {
202
268
  const reason = diagnostic.fix?.reason ?? diagnostic.message;
203
269
  return `${diagnostic.rule}:${diagnostic.line}: ${reason}`;
@@ -273,7 +339,9 @@ const diagnosticSpan = (
273
339
  return spanForSymbolOnDiagnosticLine(source, diagnostic.line, symbol);
274
340
  };
275
341
 
276
- const expectedTarget = (diagnostic: WardenDiagnostic): string | undefined => {
342
+ const diagnosticCandidateReplacement = (
343
+ diagnostic: WardenDiagnostic
344
+ ): string | undefined => {
277
345
  const replacements = new Set(
278
346
  (diagnostic.fix?.edits ?? []).map((edit) => edit.replacement)
279
347
  );
@@ -281,32 +349,98 @@ const expectedTarget = (diagnostic: WardenDiagnostic): string | undefined => {
281
349
  return undefined;
282
350
  }
283
351
  const [replacement] = replacements;
352
+ return replacement;
353
+ };
354
+
355
+ const expectedTarget = (diagnostic: WardenDiagnostic): string | undefined => {
356
+ const replacement = diagnosticCandidateReplacement(diagnostic);
284
357
  return replacement === undefined
285
358
  ? undefined
286
359
  : `Replace with "${replacement}".`;
287
360
  };
288
361
 
362
+ const isValidEditSpan = (
363
+ source: string,
364
+ edit: WardenFixEdit | undefined
365
+ ): edit is WardenFixEdit =>
366
+ edit !== undefined &&
367
+ Number.isInteger(edit.start) &&
368
+ Number.isInteger(edit.end) &&
369
+ edit.start >= 0 &&
370
+ edit.end >= edit.start &&
371
+ edit.end <= source.length;
372
+
373
+ const diagnosticMatchedForm = (
374
+ source: string,
375
+ diagnostic: WardenDiagnostic,
376
+ symbol: string | undefined
377
+ ): string | undefined => {
378
+ const [edit] = diagnostic.fix?.edits ?? [];
379
+ if (isValidEditSpan(source, edit)) {
380
+ return source.slice(edit.start, edit.end);
381
+ }
382
+ return symbol;
383
+ };
384
+
385
+ const diagnosticSignals = (diagnostic: WardenDiagnostic): readonly string[] => [
386
+ `warden:${diagnostic.rule}`,
387
+ ...(diagnostic.code === undefined
388
+ ? []
389
+ : [`${diagnostic.rule}:${diagnostic.code}`]),
390
+ ];
391
+
392
+ interface WardenReviewMappingOptions {
393
+ /** Verdict state for this review path. */
394
+ readonly judgment: RegradeReviewJudgment;
395
+ /** Machine-readable review reason. */
396
+ readonly reason: string;
397
+ /** Rule-level guidance used when a finding carries none of its own. */
398
+ readonly ruleGuidance?: WardenGuidance;
399
+ }
400
+
401
+ const reviewDetailFromDiagnostic = (
402
+ source: string,
403
+ diagnostic: WardenDiagnostic,
404
+ options: WardenReviewMappingOptions
405
+ ): RegradeReviewDetail => {
406
+ const symbol =
407
+ firstQuotedValue(diagnostic.fix?.reason) ??
408
+ firstQuotedValue(diagnostic.message);
409
+ const span = diagnosticSpan(source, diagnostic, symbol);
410
+ const target = expectedTarget(diagnostic);
411
+ const replacement = diagnosticCandidateReplacement(diagnostic);
412
+ const matchedForm = diagnosticMatchedForm(source, diagnostic, symbol);
413
+ const guidance = diagnostic.guidance ?? options.ruleGuidance;
414
+ const preserveCautions =
415
+ guidance === undefined
416
+ ? undefined
417
+ : [guidance.summary, ...(guidance.steps ?? [])];
418
+ const suggestedValidation = guidance?.commands?.[0];
419
+ return {
420
+ ...(replacement === undefined ? {} : { candidateReplacement: replacement }),
421
+ ...(target === undefined ? {} : { expectedTarget: target }),
422
+ ...(diagnostic.fix?.fixture === undefined
423
+ ? {}
424
+ : { fixture: diagnostic.fix.fixture }),
425
+ judgment: options.judgment,
426
+ ...(matchedForm === undefined ? {} : { matchedForm }),
427
+ ...(preserveCautions === undefined ? {} : { preserveCautions }),
428
+ reason: options.reason,
429
+ signals: diagnosticSignals(diagnostic),
430
+ ...(span === undefined ? {} : { span }),
431
+ ...(suggestedValidation === undefined ? {} : { suggestedValidation }),
432
+ ...(symbol === undefined ? {} : { symbol }),
433
+ } satisfies RegradeReviewDetail;
434
+ };
435
+
289
436
  const reviewDetailsFromDiagnostics = (
290
437
  source: string,
291
438
  diagnostics: readonly WardenDiagnostic[],
292
- reason: string
439
+ options: WardenReviewMappingOptions
293
440
  ): 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
- });
441
+ const details = diagnostics.map((diagnostic) =>
442
+ reviewDetailFromDiagnostic(source, diagnostic, options)
443
+ );
310
444
  return details.length === 0 ? undefined : details;
311
445
  };
312
446
 
@@ -351,10 +485,11 @@ const applyWardenEdits = (
351
485
  export const createWardenTermRewriteClass = (
352
486
  rule: WardenRule
353
487
  ): RegradeClass | null => {
354
- const metadata = getWardenRuleMetadata(rule.name);
488
+ const metadata = getWardenRuleMetadata(rule);
355
489
  if (metadata?.fix?.class !== TERM_REWRITE_FIX_CLASS) {
356
490
  return null;
357
491
  }
492
+ const scanTargets = regradeScanTargetsFromWardenFix(metadata.fix.scanTargets);
358
493
 
359
494
  return {
360
495
  apply: (
@@ -386,10 +521,18 @@ export const createWardenTermRewriteClass = (
386
521
  (diagnostic) => diagnostic.fix?.safety !== 'safe'
387
522
  );
388
523
  if (reviewDiagnostics.length > 0) {
524
+ // The rule flagged the occurrence but marked it review: occurrence
525
+ // judgment is unresolved and needs a human or agent decision.
389
526
  const reviewDetails = reviewDetailsFromDiagnostics(
390
527
  source,
391
528
  reviewDiagnostics,
392
- 'warden-review-required'
529
+ {
530
+ judgment: 'unresolved',
531
+ reason: 'warden-review-required',
532
+ ...(metadata.guidance === undefined
533
+ ? {}
534
+ : { ruleGuidance: metadata.guidance }),
535
+ }
393
536
  );
394
537
  return {
395
538
  kind: 'needs-review',
@@ -403,10 +546,18 @@ export const createWardenTermRewriteClass = (
403
546
  (diagnostic) => (diagnostic.fix?.edits?.length ?? 0) === 0
404
547
  );
405
548
  if (diagnosticsMissingEdits.length > 0) {
549
+ // A safe fix without concrete edits cannot complete occurrence
550
+ // judgment on its own, so the verdict stays unresolved.
406
551
  const reviewDetails = reviewDetailsFromDiagnostics(
407
552
  source,
408
553
  diagnosticsMissingEdits,
409
- 'warden-fix-missing-edits'
554
+ {
555
+ judgment: 'unresolved',
556
+ reason: 'warden-fix-missing-edits',
557
+ ...(metadata.guidance === undefined
558
+ ? {}
559
+ : { ruleGuidance: metadata.guidance }),
560
+ }
410
561
  );
411
562
  return {
412
563
  kind: 'needs-review',
@@ -421,10 +572,18 @@ export const createWardenTermRewriteClass = (
421
572
  );
422
573
  const application = applyWardenEdits(source, edits);
423
574
  if (!application.ok) {
575
+ // The rule completed judgment — it authored concrete edits — but this
576
+ // run could not apply them, so the verdict is a rewrite left undone.
424
577
  const reviewDetails = reviewDetailsFromDiagnostics(
425
578
  source,
426
579
  diagnostics,
427
- 'warden-fix-invalid'
580
+ {
581
+ judgment: 'rewrite',
582
+ reason: 'warden-fix-invalid',
583
+ ...(metadata.guidance === undefined
584
+ ? {}
585
+ : { ruleGuidance: metadata.guidance }),
586
+ }
428
587
  );
429
588
  return {
430
589
  kind: 'needs-review',
@@ -445,6 +604,7 @@ export const createWardenTermRewriteClass = (
445
604
  },
446
605
  describe: `${rule.description} (${metadata.fix.safety} ${metadata.fix.class})`,
447
606
  id: `${metadata.fix.class}:${rule.name}`,
607
+ ...(scanTargets === undefined ? {} : { scanTargets }),
448
608
  };
449
609
  };
450
610
 
@@ -479,29 +639,56 @@ export const selectRegradeClasses = (
479
639
  const uniqueSorted = (values: readonly string[]): readonly string[] =>
480
640
  [...new Set(values)].toSorted((a, b) => a.localeCompare(b));
481
641
 
642
+ const intersectValues = (
643
+ left: readonly string[],
644
+ right: readonly string[]
645
+ ): readonly string[] => left.filter((value) => right.includes(value));
646
+
647
+ const deriveClassIgnoredDirectories = (
648
+ classes: readonly RegradeClass[]
649
+ ): readonly string[] | undefined => {
650
+ const explicitTargets = classes
651
+ .map((cls) => cls.scanTargets?.ignoredDirectories)
652
+ .filter((value): value is readonly string[] => value !== undefined);
653
+ if (explicitTargets.length === 0) {
654
+ return undefined;
655
+ }
656
+
657
+ let common = explicitTargets[0] ?? [];
658
+ for (const target of explicitTargets.slice(1)) {
659
+ common = intersectValues(common, target);
660
+ }
661
+ return uniqueSorted(common);
662
+ };
663
+
482
664
  const deriveCollectionOptions = (
483
665
  classes: readonly RegradeClass[],
484
666
  collection: DownstreamCollectionOptions | undefined
485
667
  ): 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
- )
668
+ const allExtensions = classes.some(
669
+ (cls) => cls.scanTargets?.extensions?.length === 0
500
670
  );
501
-
671
+ const targetExtensions = allExtensions
672
+ ? []
673
+ : uniqueSorted(
674
+ classes.length === 0
675
+ ? DEFAULT_SOURCE_EXTENSIONS
676
+ : classes.flatMap(
677
+ (cls) => cls.scanTargets?.extensions ?? DEFAULT_SOURCE_EXTENSIONS
678
+ )
679
+ );
502
680
  return {
681
+ ...(collection?.exclude === undefined
682
+ ? {}
683
+ : { exclude: collection.exclude }),
684
+ ...(collection?.include === undefined
685
+ ? {}
686
+ : { include: collection.include }),
503
687
  extensions: collection?.extensions ?? targetExtensions,
504
- ignoredDirectories: collection?.ignoredDirectories ?? ignoredDirectories,
688
+ ignoredDirectories:
689
+ collection?.ignoredDirectories ??
690
+ deriveClassIgnoredDirectories(classes) ??
691
+ DEFAULT_IGNORED_DIRECTORIES,
505
692
  };
506
693
  };
507
694
 
@@ -539,10 +726,38 @@ export interface RegradeReport {
539
726
  readonly review: number;
540
727
  /** Entries skipped (collection skips plus any run-level skips). */
541
728
  readonly skipped: number;
729
+ /** Skipped entries grouped by reason. */
730
+ readonly skipsByReason: Readonly<Record<string, number>>;
731
+ /** Agent-facing inventory summary for the scan. */
732
+ readonly scan: RegradeScanSummary;
542
733
  /** Per-entry detail, sorted by path. */
543
734
  readonly entries: readonly RegradeReportEntry[];
544
735
  /** Apply-mode summary; absent for dry-run report-only calls. */
545
736
  readonly apply?: RegradeApplySummary;
737
+ /** Vocabulary regrade run: plan, ledger, and completion report. */
738
+ readonly run?: VocabularyRegradeRun;
739
+ /** Saved active Regrade plan evidence for vocabulary regrades. */
740
+ readonly plan?: {
741
+ readonly expansionPending?: number;
742
+ readonly path: string;
743
+ readonly schemaVersion: number;
744
+ readonly status: 'active' | 'stale';
745
+ };
746
+ /** Saved applied Regrade history evidence for vocabulary regrades. */
747
+ readonly history?: {
748
+ readonly path: string;
749
+ readonly schemaVersion: number;
750
+ readonly status: 'applied' | 'checked' | 'replay';
751
+ };
752
+ /**
753
+ * @deprecated Persisted transition record evidence for vocabulary regrades.
754
+ * Use `plan` and `history` summaries in public surfaces.
755
+ */
756
+ readonly record?: {
757
+ readonly path: string;
758
+ readonly schemaVersion: number;
759
+ readonly status: 'candidate' | 'applied' | 'checked';
760
+ };
546
761
  }
547
762
 
548
763
  interface RegradeRewriteCandidate {
@@ -557,20 +772,80 @@ interface RegradeClassifiedFile {
557
772
  readonly rewrite?: RegradeRewriteCandidate;
558
773
  }
559
774
 
775
+ const isIgnoredByClassDirectories = (
776
+ path: string,
777
+ ignoredDirectories: readonly string[] | undefined
778
+ ): boolean => {
779
+ if (ignoredDirectories === undefined || ignoredDirectories.length === 0) {
780
+ return false;
781
+ }
782
+ return path
783
+ .split('/')
784
+ .slice(0, -1)
785
+ .some((segment) => ignoredDirectories.includes(segment));
786
+ };
787
+
788
+ const classScanTargetSkip = (
789
+ cls: RegradeClass,
790
+ path: string,
791
+ collection: DownstreamCollectionOptions | undefined
792
+ ): RegradeClassResult | undefined => {
793
+ const ignoredDirectories =
794
+ collection?.ignoredDirectories ??
795
+ cls.scanTargets?.ignoredDirectories ??
796
+ DEFAULT_IGNORED_DIRECTORIES;
797
+ if (isIgnoredByClassDirectories(path, ignoredDirectories)) {
798
+ return {
799
+ kind: 'skipped',
800
+ notes: [`Skipped by ${cls.id} scan-target filtering.`],
801
+ reason: 'regrade-scan-target-filtered',
802
+ };
803
+ }
804
+ const effectiveScanTargets: ScanTargets | undefined =
805
+ cls.scanTargets?.extensions === undefined &&
806
+ collection?.extensions === undefined
807
+ ? {
808
+ ...cls.scanTargets,
809
+ extensions: DEFAULT_SOURCE_EXTENSIONS,
810
+ }
811
+ : cls.scanTargets;
812
+ if (
813
+ effectiveScanTargets === undefined ||
814
+ includedByPathScope(path, effectiveScanTargets)
815
+ ) {
816
+ return undefined;
817
+ }
818
+ return {
819
+ kind: 'skipped',
820
+ notes: [`Skipped by ${cls.id} scan-target filtering.`],
821
+ reason: 'regrade-scan-target-filtered',
822
+ };
823
+ };
824
+
560
825
  const classifyFile = (
561
826
  path: string,
562
827
  source: string,
563
828
  context: RegradeClassContext,
564
- selected: readonly RegradeClass[]
829
+ selected: readonly RegradeClass[],
830
+ collection?: DownstreamCollectionOptions
565
831
  ): 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.
832
+ // Compose safe rewrites across selected classes in memory so one governed
833
+ // transition can move every compatible symbol in a file. Review still wins:
834
+ // if any class needs judgment, no partial rewrite is returned for that file.
569
835
  let skipped:
570
836
  | { readonly classId: string; readonly result: RegradeClassResult }
571
837
  | undefined;
838
+ let inspected = false;
839
+ let currentSource = source;
840
+ const rewriteClassIds: string[] = [];
841
+ const rewriteNotes: string[] = [];
572
842
  for (const cls of selected) {
573
- const result = cls.apply(source, context);
843
+ const result =
844
+ classScanTargetSkip(cls, path, collection) ??
845
+ cls.apply(currentSource, context);
846
+ if (result.kind !== 'skipped') {
847
+ inspected = true;
848
+ }
574
849
  if (result.kind === 'rewrite') {
575
850
  if (typeof result.nextSource !== 'string') {
576
851
  return {
@@ -583,38 +858,29 @@ const classifyFile = (
583
858
  },
584
859
  };
585
860
  }
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
- };
861
+ currentSource = result.nextSource;
862
+ rewriteClassIds.push(cls.id);
863
+ rewriteNotes.push(...(result.notes ?? []));
864
+ continue;
605
865
  }
606
866
  if (result.kind === 'needs-review') {
607
- const reviewDetails = result.reviewDetails?.map((detail) => ({
867
+ const originalSourceResult =
868
+ currentSource === source ? result : cls.apply(source, context);
869
+ const reviewResult =
870
+ originalSourceResult.kind === 'needs-review'
871
+ ? originalSourceResult
872
+ : result;
873
+ const reviewDetails = reviewResult.reviewDetails?.map((detail) => ({
608
874
  ...detail,
609
875
  classId: detail.classId ?? cls.id,
610
876
  }));
611
877
  return {
612
878
  entry: {
613
879
  classId: cls.id,
614
- notes: result.notes,
880
+ notes: reviewResult.notes,
615
881
  outcome: 'needs-review',
616
882
  path,
617
- reason: result.reason ?? 'needs-review',
883
+ reason: reviewResult.reason ?? 'needs-review',
618
884
  ...(reviewDetails === undefined ? {} : { reviewDetails }),
619
885
  },
620
886
  };
@@ -623,7 +889,29 @@ const classifyFile = (
623
889
  skipped = { classId: cls.id, result };
624
890
  }
625
891
  }
626
- if (skipped !== undefined) {
892
+ if (rewriteClassIds.length > 0) {
893
+ const classId = rewriteClassIds.join(',');
894
+ const entry = {
895
+ classId,
896
+ notes: rewriteNotes,
897
+ outcome: 'rewrite',
898
+ path,
899
+ } satisfies RegradeReportEntry;
900
+ return {
901
+ entry,
902
+ ...(context.absolutePath === undefined
903
+ ? {}
904
+ : {
905
+ rewrite: {
906
+ absolutePath: context.absolutePath,
907
+ classId,
908
+ nextSource: currentSource,
909
+ path,
910
+ },
911
+ }),
912
+ };
913
+ }
914
+ if (!inspected && skipped !== undefined) {
627
915
  return {
628
916
  entry: {
629
917
  classId: skipped.classId,
@@ -642,6 +930,32 @@ interface RegradeEvaluation {
642
930
  readonly rewrites: readonly RegradeRewriteCandidate[];
643
931
  }
644
932
 
933
+ const includeEntryInReport = (
934
+ entry: RegradeReportEntry,
935
+ selection: RegradeReportEntrySelection
936
+ ): boolean =>
937
+ selection === 'all' ||
938
+ entry.outcome === 'rewrite' ||
939
+ entry.outcome === 'needs-review';
940
+
941
+ const skipsByReason = (
942
+ entries: readonly RegradeReportEntry[]
943
+ ): Readonly<Record<string, number>> => {
944
+ const counts = new Map<string, number>();
945
+ for (const entry of entries) {
946
+ if (entry.outcome !== 'skip') {
947
+ continue;
948
+ }
949
+ const reason = entry.reason ?? 'skipped';
950
+ counts.set(reason, (counts.get(reason) ?? 0) + 1);
951
+ }
952
+ return Object.fromEntries(
953
+ [...counts.entries()].toSorted(([left], [right]) =>
954
+ left.localeCompare(right)
955
+ )
956
+ );
957
+ };
958
+
645
959
  /**
646
960
  * Build a coverage report from already-read source files. Pure: no filesystem
647
961
  * access, so coverage semantics are testable directly.
@@ -656,7 +970,10 @@ const buildRegradeEvaluation = (params: {
656
970
  readonly skipped: readonly SkippedSource[];
657
971
  readonly classes: readonly RegradeClass[];
658
972
  readonly selection?: RegradeSelection;
973
+ readonly collection?: DownstreamCollectionOptions;
974
+ readonly includeEntries?: RegradeReportEntrySelection;
659
975
  }): RegradeEvaluation => {
976
+ const entrySelection = params.includeEntries ?? 'actionable';
660
977
  const { selected, unknownClassIds } = selectRegradeClasses(
661
978
  params.classes,
662
979
  params.selection
@@ -672,7 +989,8 @@ const buildRegradeEvaluation = (params: {
672
989
  : { absolutePath: file.absolutePath }),
673
990
  path: file.path,
674
991
  },
675
- selected
992
+ selected,
993
+ params.collection
676
994
  )
677
995
  );
678
996
  const fileEntries = classifiedFiles.map((file) => file.entry);
@@ -685,9 +1003,12 @@ const buildRegradeEvaluation = (params: {
685
1003
  reason: entry.reason,
686
1004
  }));
687
1005
 
688
- const entries = [...fileEntries, ...skipEntries].toSorted((a, b) =>
1006
+ const allEntries = [...fileEntries, ...skipEntries].toSorted((a, b) =>
689
1007
  a.path.localeCompare(b.path)
690
1008
  );
1009
+ const entries = allEntries.filter((entry) =>
1010
+ includeEntryInReport(entry, entrySelection)
1011
+ );
691
1012
 
692
1013
  // Class-level skips (e.g. scan-target filtering) are accounted as skipped, not
693
1014
  // as scanned/clean files.
@@ -699,6 +1020,11 @@ const buildRegradeEvaluation = (params: {
699
1020
  const review = scannedEntries.filter(
700
1021
  (e) => e.outcome === 'needs-review'
701
1022
  ).length;
1023
+ const matchedPaths = scannedEntries
1024
+ .filter((e) => e.outcome === 'rewrite' || e.outcome === 'needs-review')
1025
+ .map((entry) => entry.path);
1026
+ const skipped = skipEntries.length + fileSkipCount;
1027
+ const skippedReasons = skipsByReason(allEntries);
702
1028
 
703
1029
  return {
704
1030
  report: {
@@ -707,9 +1033,16 @@ const buildRegradeEvaluation = (params: {
707
1033
  review,
708
1034
  rewritten,
709
1035
  root: params.root,
1036
+ scan: buildRegradeScanSummary({
1037
+ matchedPaths,
1038
+ scanned: scannedEntries.length,
1039
+ skipped,
1040
+ skippedByReason: skippedReasons,
1041
+ }),
710
1042
  scanned: scannedEntries.length,
711
1043
  selectedClassIds: selected.map((cls) => cls.id),
712
- skipped: skipEntries.length + fileSkipCount,
1044
+ skipped,
1045
+ skipsByReason: skippedReasons,
713
1046
  unknownClassIds,
714
1047
  },
715
1048
  rewrites,
@@ -726,6 +1059,8 @@ export const buildRegradeReport = (params: {
726
1059
  readonly skipped: readonly SkippedSource[];
727
1060
  readonly classes: readonly RegradeClass[];
728
1061
  readonly selection?: RegradeSelection;
1062
+ readonly collection?: DownstreamCollectionOptions;
1063
+ readonly includeEntries?: RegradeReportEntrySelection;
729
1064
  }): RegradeReport => buildRegradeEvaluation(params).report;
730
1065
 
731
1066
  const applyRegradeEvaluation = (
@@ -797,6 +1132,7 @@ const runRegradeEvaluation = (params: {
797
1132
  readonly classes: readonly RegradeClass[];
798
1133
  readonly selection?: RegradeSelection;
799
1134
  readonly collection?: DownstreamCollectionOptions;
1135
+ readonly includeEntries?: RegradeReportEntrySelection;
800
1136
  }): RegradeEvaluation | null => {
801
1137
  const { selected, unknownClassIds } = selectRegradeClasses(
802
1138
  params.classes,
@@ -808,9 +1144,15 @@ const runRegradeEvaluation = (params: {
808
1144
  }
809
1145
  return buildRegradeEvaluation({
810
1146
  classes: params.classes,
1147
+ ...(params.collection === undefined
1148
+ ? {}
1149
+ : { collection: params.collection }),
811
1150
  files: [],
812
1151
  root: params.root,
813
1152
  skipped: [],
1153
+ ...(params.includeEntries === undefined
1154
+ ? {}
1155
+ : { includeEntries: params.includeEntries }),
814
1156
  ...(params.selection === undefined
815
1157
  ? {}
816
1158
  : { selection: params.selection }),
@@ -841,9 +1183,15 @@ const runRegradeEvaluation = (params: {
841
1183
 
842
1184
  return buildRegradeEvaluation({
843
1185
  classes: params.classes,
1186
+ ...(params.collection === undefined
1187
+ ? {}
1188
+ : { collection: params.collection }),
844
1189
  files,
845
1190
  root: params.root,
846
1191
  skipped,
1192
+ ...(params.includeEntries === undefined
1193
+ ? {}
1194
+ : { includeEntries: params.includeEntries }),
847
1195
  ...(params.selection === undefined ? {} : { selection: params.selection }),
848
1196
  });
849
1197
  };
@@ -861,6 +1209,7 @@ export const runRegrade = (params: {
861
1209
  readonly selection?: RegradeSelection;
862
1210
  readonly collection?: DownstreamCollectionOptions;
863
1211
  readonly apply?: boolean;
1212
+ readonly includeEntries?: RegradeReportEntrySelection;
864
1213
  }): Result<RegradeReport | null, InternalError> => {
865
1214
  const evaluation = runRegradeEvaluation(params);
866
1215
  if (evaluation === null) {
@@ -890,6 +1239,12 @@ const regradeReportEntrySchema = z.object({
890
1239
  reviewDetails: z
891
1240
  .array(
892
1241
  z.object({
1242
+ candidateReplacement: z
1243
+ .string()
1244
+ .optional()
1245
+ .describe(
1246
+ 'Concrete replacement the class would apply if the occurrence were judged safe'
1247
+ ),
893
1248
  classId: z
894
1249
  .string()
895
1250
  .optional()
@@ -902,11 +1257,35 @@ const regradeReportEntrySchema = z.object({
902
1257
  .string()
903
1258
  .optional()
904
1259
  .describe('Fixture or example reference for the migration'),
1260
+ judgment: z
1261
+ .enum(['preserve', 'rewrite', 'unresolved'])
1262
+ .optional()
1263
+ .describe(
1264
+ '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'
1265
+ ),
1266
+ matchedForm: z
1267
+ .string()
1268
+ .optional()
1269
+ .describe(
1270
+ 'Exact matched source text for the occurrence under review'
1271
+ ),
905
1272
  nodeKind: z
906
1273
  .string()
907
1274
  .optional()
908
1275
  .describe('AST node kind or source construct kind'),
1276
+ preserveCautions: z
1277
+ .array(z.string())
1278
+ .optional()
1279
+ .describe(
1280
+ 'Cautions explaining why a blind rewrite of this occurrence is unsafe'
1281
+ ),
909
1282
  reason: z.string().describe('Machine-readable review reason'),
1283
+ signals: z
1284
+ .array(z.string())
1285
+ .optional()
1286
+ .describe(
1287
+ 'Machine-readable provenance tags for the producing rule or class'
1288
+ ),
910
1289
  span: z
911
1290
  .object({
912
1291
  column: z.number().describe('One-based source column'),
@@ -941,14 +1320,61 @@ export const regradeReportOutput = z.object({
941
1320
  .describe('Apply-mode summary; absent for dry-run report-only calls'),
942
1321
  entries: z
943
1322
  .array(regradeReportEntrySchema)
944
- .describe('Per-entry detail, sorted by path'),
1323
+ .describe(
1324
+ 'Per-entry detail, sorted by path. Defaults to actionable rewrite/review entries.'
1325
+ ),
1326
+ history: z
1327
+ .object({
1328
+ path: z.string().describe('Root-relative applied history entry path'),
1329
+ schemaVersion: z.number().describe('Regrade history schema version'),
1330
+ status: z
1331
+ .enum(['applied', 'checked', 'replay'])
1332
+ .describe(
1333
+ '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'
1334
+ ),
1335
+ })
1336
+ .optional()
1337
+ .describe('Saved applied Regrade history evidence'),
945
1338
  matched: z.number().describe('Files with a rewrite or review outcome'),
1339
+ plan: z
1340
+ .object({
1341
+ expansionPending: z
1342
+ .number()
1343
+ .optional()
1344
+ .describe('Pending staged expansion candidates on this plan'),
1345
+ path: z.string().describe('Root-relative Regrade plan path'),
1346
+ schemaVersion: z.number().describe('Regrade plan schema version'),
1347
+ status: z
1348
+ .enum(['active', 'stale'])
1349
+ .describe('Whether the saved plan still matches the source tree'),
1350
+ })
1351
+ .optional()
1352
+ .describe('Saved active Regrade plan evidence'),
1353
+ record: z
1354
+ .object({
1355
+ path: z.string().describe('Root-relative transition record path'),
1356
+ schemaVersion: z.number().describe('Transition record schema version'),
1357
+ status: z
1358
+ .enum(['candidate', 'applied', 'checked'])
1359
+ .describe('How this command used the transition record'),
1360
+ })
1361
+ .optional()
1362
+ .describe('Persisted transition record evidence'),
946
1363
  review: z.number().describe('Files routed to review'),
947
1364
  rewritten: z.number().describe('Files with a rewrite outcome'),
948
1365
  root: z.string().describe('Root the run scanned'),
1366
+ run: vocabularyRegradeRunOutput
1367
+ .optional()
1368
+ .describe('Vocabulary regrade run: plan, ledger, and completion report'),
1369
+ scan: regradeScanSummaryOutput.describe(
1370
+ 'Agent-facing inventory summary for the scan'
1371
+ ),
949
1372
  scanned: z.number().describe('Source files inspected'),
950
1373
  selectedClassIds: z.array(z.string()).describe('Class ids executed'),
951
1374
  skipped: z.number().describe('Entries skipped'),
1375
+ skipsByReason: z
1376
+ .record(z.string(), z.number())
1377
+ .describe('Skipped entries grouped by reason'),
952
1378
  unknownClassIds: z
953
1379
  .array(z.string())
954
1380
  .describe('Selected ids that did not resolve to a class'),
@@ -967,3 +1393,55 @@ export const wardenTermRewriteClasses: readonly RegradeClass[] = Object.freeze(
967
1393
  return cls === null ? [] : [cls];
968
1394
  })
969
1395
  );
1396
+
1397
+ const duplicateClassDiagnostics = (
1398
+ root: string,
1399
+ classes: readonly RegradeClass[]
1400
+ ): readonly WardenDiagnostic[] => {
1401
+ const seen = new Set<string>();
1402
+ const diagnostics: WardenDiagnostic[] = [];
1403
+ for (const cls of classes) {
1404
+ if (!seen.has(cls.id)) {
1405
+ seen.add(cls.id);
1406
+ continue;
1407
+ }
1408
+ diagnostics.push({
1409
+ filePath: root,
1410
+ line: 1,
1411
+ message: `Duplicate Regrade class id "${cls.id}" from Warden term-rewrite rules.`,
1412
+ rule: 'regrade-warden-term-rewrite-classes',
1413
+ severity: 'error',
1414
+ });
1415
+ }
1416
+ return diagnostics;
1417
+ };
1418
+
1419
+ /**
1420
+ * Load built-in and project-local Warden term-rewrite rules as Regrade classes.
1421
+ *
1422
+ * Built-ins are always available. When `root` is provided, committed
1423
+ * project-local Warden rules under `.trails/rules.ts` or direct
1424
+ * `.trails/rules/*.ts` modules are loaded and any term-rewrite-capable source
1425
+ * rules join the class set.
1426
+ */
1427
+ export const loadWardenTermRewriteClasses = async (
1428
+ root?: string
1429
+ ): Promise<RegradeWardenClassSet> => {
1430
+ if (root === undefined) {
1431
+ return { classes: wardenTermRewriteClasses, diagnostics: [] };
1432
+ }
1433
+
1434
+ const projectRules = await loadProjectWardenRules(root);
1435
+ const projectClasses = projectRules.sourceRules.flatMap((rule) => {
1436
+ const cls = createWardenTermRewriteClass(rule);
1437
+ return cls === null ? [] : [cls];
1438
+ });
1439
+ const classes = [...wardenTermRewriteClasses, ...projectClasses];
1440
+ return {
1441
+ classes,
1442
+ diagnostics: [
1443
+ ...projectRules.diagnostics,
1444
+ ...duplicateClassDiagnostics(root, classes),
1445
+ ],
1446
+ };
1447
+ };