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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,50 @@
1
1
  # @ontrails/regrade
2
2
 
3
+ ## 1.0.0-beta.32
4
+
5
+ ### Patch Changes
6
+
7
+ - f3c4fef: Export a shared `escapeRegExp` helper from core and migrate first-party callers off local copies.
8
+ - fe72b84: Fold remaining Regrade and Warden scan-target surfaces onto the shared path-scope vocabulary.
9
+ - Updated dependencies [3e5c0fc]
10
+ - Updated dependencies [f3c4fef]
11
+ - Updated dependencies [cb0a9d8]
12
+ - Updated dependencies [21c6dda]
13
+ - Updated dependencies [fe72b84]
14
+ - @ontrails/core@1.0.0-beta.32
15
+ - @ontrails/warden@1.0.0-beta.32
16
+
17
+ ## 1.0.0-beta.31
18
+
19
+ ### Patch Changes
20
+
21
+ - e2f3d23: Default Regrade reports to actionable entries, add skip counts grouped by
22
+ reason, and expose an `includeEntries` option for full report inventories.
23
+ - 9be2b7e: Load project-local Warden term-rewrite rules from the Regrade root so repo-owned
24
+ migration classes can run through `trails regrade`.
25
+ - 47f782c: Add occurrence-level vocabulary regrade reports with plan, ledger,
26
+ and completion-gate facts. The Trails `regrade` operator command now supports
27
+ positional `<from> <to>` regrade runs and exposes the same capability through
28
+ the curated MCP surface.
29
+ - ee9f3ae: Let Warden fix capabilities declare downstream scan targets and have Regrade
30
+ honor those targets for Warden-backed term-rewrite classes.
31
+
32
+ Dogfood the first safe facet-to-trailhead prose rewrite through project-local
33
+ Warden rules and Regrade.
34
+
35
+ - 982a4d7: Add Regrade path-scope exclusion globs for vocabulary runs and expose them
36
+ through the `trails regrade` CLI/MCP contract.
37
+ - 1540233: Add Regrade scan inventory summaries that group matched files by extension and
38
+ top-level path, with occurrence counts for vocabulary regrade reports.
39
+ - a079073: Rename Regrade path-scope scan controls from `ignore` to `exclude` across CLI, MCP, and project config.
40
+ - Updated dependencies [ee9f3ae]
41
+ - Updated dependencies [a0126d9]
42
+ - Updated dependencies [4cd5d4e]
43
+ - Updated dependencies [6a26a08]
44
+ - Updated dependencies [38907cc]
45
+ - @ontrails/warden@1.0.0-beta.31
46
+ - @ontrails/core@1.0.0-beta.31
47
+
3
48
  ## 1.0.0-beta.30
4
49
 
5
50
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ontrails/regrade",
3
- "version": "1.0.0-beta.30",
3
+ "version": "1.0.0-beta.32",
4
4
  "description": "Downstream migration reporting and safe rewrite helpers for Trails.",
5
5
  "files": [
6
6
  "src/**/*.ts",
@@ -22,12 +22,12 @@
22
22
  "clean": "rm -rf dist *.tsbuildinfo"
23
23
  },
24
24
  "dependencies": {
25
- "@ontrails/core": "^1.0.0-beta.30",
26
- "@ontrails/warden": "^1.0.0-beta.30",
25
+ "@ontrails/core": "^1.0.0-beta.32",
26
+ "@ontrails/warden": "^1.0.0-beta.32",
27
27
  "zod": "^4.3.5"
28
28
  },
29
29
  "devDependencies": {
30
- "@ontrails/testing": "^1.0.0-beta.30",
31
- "@ontrails/topographer": "^1.0.0-beta.30"
30
+ "@ontrails/testing": "^1.0.0-beta.32",
31
+ "@ontrails/topographer": "^1.0.0-beta.32"
32
32
  }
33
33
  }
@@ -1,4 +1,9 @@
1
- import { NotFoundError, Result, trail } from '@ontrails/core';
1
+ import {
2
+ NotFoundError,
3
+ Result,
4
+ matchesAnyPathGlob,
5
+ trail,
6
+ } from '@ontrails/core';
2
7
  import { readdirSync } from 'node:fs';
3
8
  import { join, posix, relative, resolve, sep } from 'node:path';
4
9
  import { z } from 'zod';
@@ -66,6 +71,8 @@ export interface DownstreamCollectionOptions {
66
71
  readonly ignoredDirectories?: readonly string[];
67
72
  /** Source extensions to collect. Defaults to {@link DEFAULT_SOURCE_EXTENSIONS}. */
68
73
  readonly extensions?: readonly string[];
74
+ /** Root-relative path globs to skip before collection. */
75
+ readonly exclude?: readonly string[];
69
76
  }
70
77
 
71
78
  /** Outcome of classifying a single directory entry. */
@@ -79,6 +86,16 @@ const extensionOf = (name: string): string => {
79
86
  return dot <= 0 ? '' : name.slice(dot);
80
87
  };
81
88
 
89
+ const normalizeExtension = (extension: string): string =>
90
+ extension === '' || extension.startsWith('.') ? extension : `.${extension}`;
91
+
92
+ const collectionExtensions = (
93
+ extensions: readonly string[] | undefined
94
+ ): readonly string[] =>
95
+ extensions === undefined
96
+ ? DEFAULT_SOURCE_EXTENSIONS
97
+ : extensions.map(normalizeExtension);
98
+
82
99
  /**
83
100
  * Decide what to do with a single directory entry. Pure: no filesystem access,
84
101
  * so collection policy can be tested directly with synthetic entries.
@@ -90,7 +107,7 @@ export const classifyDownstreamEntry = (
90
107
  ): DownstreamEntryClassification => {
91
108
  const ignoredDirectories =
92
109
  options.ignoredDirectories ?? DEFAULT_IGNORED_DIRECTORIES;
93
- const extensions = options.extensions ?? DEFAULT_SOURCE_EXTENSIONS;
110
+ const extensions = collectionExtensions(options.extensions);
94
111
 
95
112
  if (kind === 'directory') {
96
113
  return ignoredDirectories.includes(name)
@@ -100,7 +117,7 @@ export const classifyDownstreamEntry = (
100
117
  if (kind === 'other') {
101
118
  return { action: 'skip', reason: 'unsupported-entry' };
102
119
  }
103
- return extensions.includes(extensionOf(name))
120
+ return extensions.length === 0 || extensions.includes(extensionOf(name))
104
121
  ? { action: 'collect' }
105
122
  : { action: 'skip', reason: 'unsupported-extension' };
106
123
  };
@@ -177,6 +194,10 @@ export const collectDownstreamSources = (
177
194
  for (const entry of read.entries) {
178
195
  const absolutePath = join(current, entry.name);
179
196
  const path = toPosixRelative(absoluteRoot, absolutePath);
197
+ if (matchesAnyPathGlob(path, options.exclude)) {
198
+ skipped.push({ path, reason: 'ignored-glob' });
199
+ continue;
200
+ }
180
201
  const classification = classifyDownstreamEntry(
181
202
  entry.name,
182
203
  entry.kind,
@@ -198,6 +219,10 @@ export const collectDownstreamSources = (
198
219
  };
199
220
 
200
221
  export const collectDownstreamSourcesInput = z.object({
222
+ exclude: z
223
+ .array(z.string())
224
+ .optional()
225
+ .describe('Root-relative path globs to skip before collection'),
201
226
  extensions: z
202
227
  .array(z.string())
203
228
  .optional()
@@ -247,6 +272,7 @@ export const collectDownstreamSourcesTrail = trail(
247
272
  ...(input.extensions === undefined
248
273
  ? {}
249
274
  : { extensions: input.extensions }),
275
+ ...(input.exclude === undefined ? {} : { exclude: input.exclude }),
250
276
  ...(input.ignoredDirectories === undefined
251
277
  ? {}
252
278
  : { ignoredDirectories: input.ignoredDirectories }),
@@ -1,4 +1,10 @@
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,
@@ -7,6 +13,7 @@ import type {
7
13
  import {
8
14
  getWardenRuleMetadata,
9
15
  isWardenSourceScanTarget,
16
+ loadProjectWardenRules,
10
17
  wardenRules,
11
18
  } from '@ontrails/warden';
12
19
  import { readFileSync, readdirSync, writeFileSync } from 'node:fs';
@@ -18,6 +25,13 @@ import {
18
25
  collectDownstreamSources,
19
26
  } from './collect.js';
20
27
  import type { DownstreamCollectionOptions, SkippedSource } from './collect.js';
28
+ import {
29
+ buildRegradeScanSummary,
30
+ regradeScanSummaryOutput,
31
+ } from './scan-summary.js';
32
+ import type { RegradeScanSummary } from './scan-summary.js';
33
+ import type { VocabularyRegradeRun } from './vocabulary.js';
34
+ import { vocabularyRegradeRunOutput } from './vocabulary.js';
21
35
 
22
36
  /**
23
37
  * Regrade-class selection and coverage reporting (TRL-845).
@@ -67,12 +81,14 @@ export interface RegradeClassContext {
67
81
  }
68
82
 
69
83
  /** 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. */
84
+ export type RegradeScanTargets = ScanTargets & {
85
+ /**
86
+ * @deprecated Use collection-level `exclude` globs. Preserved so existing
87
+ * Regrade classes can explicitly opt into directories the default collector
88
+ * prunes, such as `dist`, while migrating to PathScope.
89
+ */
74
90
  readonly ignoredDirectories?: readonly string[];
75
- }
91
+ };
76
92
 
77
93
  /** One named, contract-aware transform. */
78
94
  export interface RegradeClass {
@@ -89,12 +105,22 @@ export interface RegradeClass {
89
105
  readonly scanTargets?: RegradeScanTargets;
90
106
  }
91
107
 
108
+ export interface RegradeWardenClassSet {
109
+ /** Built-in and project-local Warden term-rewrite classes. */
110
+ readonly classes: readonly RegradeClass[];
111
+ /** Diagnostics raised while loading project-local rules. */
112
+ readonly diagnostics: readonly WardenDiagnostic[];
113
+ }
114
+
92
115
  /** Which regrade classes a run should execute. */
93
116
  export interface RegradeSelection {
94
117
  /** Class ids to run. Omit to run every provided class. */
95
118
  readonly classIds?: readonly string[];
96
119
  }
97
120
 
121
+ /** Which report entries should be returned. Counts always cover the full run. */
122
+ export type RegradeReportEntrySelection = 'actionable' | 'all';
123
+
98
124
  /** Optional write summary for an apply-mode regrade run. */
99
125
  export interface RegradeApplySummary {
100
126
  /** Safe rewrite outcomes written to disk. */
@@ -137,9 +163,6 @@ export interface RegradeReviewDetail {
137
163
  readonly symbol?: string;
138
164
  }
139
165
 
140
- const escapeRegExp = (value: string): string =>
141
- value.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&');
142
-
143
166
  /**
144
167
  * Build a whole-word term-rewrite class.
145
168
  *
@@ -198,6 +221,27 @@ type WardenEditApplication =
198
221
  | { readonly ok: true; readonly nextSource: string }
199
222
  | { readonly ok: false; readonly reason: string };
200
223
 
224
+ const regradeScanTargetsFromWardenFix = (
225
+ scanTargets: NonNullable<
226
+ NonNullable<ReturnType<typeof getWardenRuleMetadata>>['fix']
227
+ >['scanTargets']
228
+ ): RegradeScanTargets | undefined => {
229
+ if (scanTargets === undefined) {
230
+ return undefined;
231
+ }
232
+ return {
233
+ ...(scanTargets.exclude === undefined
234
+ ? {}
235
+ : { exclude: scanTargets.exclude }),
236
+ ...(scanTargets.extensions === undefined
237
+ ? {}
238
+ : { extensions: scanTargets.extensions }),
239
+ ...(scanTargets.ignoredDirectories === undefined
240
+ ? {}
241
+ : { ignoredDirectories: scanTargets.ignoredDirectories }),
242
+ };
243
+ };
244
+
201
245
  const diagnosticNote = (diagnostic: WardenDiagnostic): string => {
202
246
  const reason = diagnostic.fix?.reason ?? diagnostic.message;
203
247
  return `${diagnostic.rule}:${diagnostic.line}: ${reason}`;
@@ -351,10 +395,11 @@ const applyWardenEdits = (
351
395
  export const createWardenTermRewriteClass = (
352
396
  rule: WardenRule
353
397
  ): RegradeClass | null => {
354
- const metadata = getWardenRuleMetadata(rule.name);
398
+ const metadata = getWardenRuleMetadata(rule);
355
399
  if (metadata?.fix?.class !== TERM_REWRITE_FIX_CLASS) {
356
400
  return null;
357
401
  }
402
+ const scanTargets = regradeScanTargetsFromWardenFix(metadata.fix.scanTargets);
358
403
 
359
404
  return {
360
405
  apply: (
@@ -445,6 +490,7 @@ export const createWardenTermRewriteClass = (
445
490
  },
446
491
  describe: `${rule.description} (${metadata.fix.safety} ${metadata.fix.class})`,
447
492
  id: `${metadata.fix.class}:${rule.name}`,
493
+ ...(scanTargets === undefined ? {} : { scanTargets }),
448
494
  };
449
495
  };
450
496
 
@@ -479,29 +525,53 @@ export const selectRegradeClasses = (
479
525
  const uniqueSorted = (values: readonly string[]): readonly string[] =>
480
526
  [...new Set(values)].toSorted((a, b) => a.localeCompare(b));
481
527
 
528
+ const intersectValues = (
529
+ left: readonly string[],
530
+ right: readonly string[]
531
+ ): readonly string[] => left.filter((value) => right.includes(value));
532
+
533
+ const deriveClassIgnoredDirectories = (
534
+ classes: readonly RegradeClass[]
535
+ ): readonly string[] | undefined => {
536
+ const explicitTargets = classes
537
+ .map((cls) => cls.scanTargets?.ignoredDirectories)
538
+ .filter((value): value is readonly string[] => value !== undefined);
539
+ if (explicitTargets.length === 0) {
540
+ return undefined;
541
+ }
542
+
543
+ let common = explicitTargets[0] ?? [];
544
+ for (const target of explicitTargets.slice(1)) {
545
+ common = intersectValues(common, target);
546
+ }
547
+ return uniqueSorted(common);
548
+ };
549
+
482
550
  const deriveCollectionOptions = (
483
551
  classes: readonly RegradeClass[],
484
552
  collection: DownstreamCollectionOptions | undefined
485
553
  ): 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
- )
554
+ const allExtensions = classes.some(
555
+ (cls) => cls.scanTargets?.extensions?.length === 0
492
556
  );
493
- const ignoredDirectories = uniqueSorted(
494
- classes.length === 0
495
- ? DEFAULT_IGNORED_DIRECTORIES
496
- : classes.flatMap(
497
- (cls) =>
498
- cls.scanTargets?.ignoredDirectories ?? DEFAULT_IGNORED_DIRECTORIES
499
- )
500
- );
501
-
557
+ const targetExtensions = allExtensions
558
+ ? []
559
+ : uniqueSorted(
560
+ classes.length === 0
561
+ ? DEFAULT_SOURCE_EXTENSIONS
562
+ : classes.flatMap(
563
+ (cls) => cls.scanTargets?.extensions ?? DEFAULT_SOURCE_EXTENSIONS
564
+ )
565
+ );
502
566
  return {
567
+ ...(collection?.exclude === undefined
568
+ ? {}
569
+ : { exclude: collection.exclude }),
503
570
  extensions: collection?.extensions ?? targetExtensions,
504
- ignoredDirectories: collection?.ignoredDirectories ?? ignoredDirectories,
571
+ ignoredDirectories:
572
+ collection?.ignoredDirectories ??
573
+ deriveClassIgnoredDirectories(classes) ??
574
+ DEFAULT_IGNORED_DIRECTORIES,
505
575
  };
506
576
  };
507
577
 
@@ -539,10 +609,16 @@ export interface RegradeReport {
539
609
  readonly review: number;
540
610
  /** Entries skipped (collection skips plus any run-level skips). */
541
611
  readonly skipped: number;
612
+ /** Skipped entries grouped by reason. */
613
+ readonly skipsByReason: Readonly<Record<string, number>>;
614
+ /** Agent-facing inventory summary for the scan. */
615
+ readonly scan: RegradeScanSummary;
542
616
  /** Per-entry detail, sorted by path. */
543
617
  readonly entries: readonly RegradeReportEntry[];
544
618
  /** Apply-mode summary; absent for dry-run report-only calls. */
545
619
  readonly apply?: RegradeApplySummary;
620
+ /** Vocabulary regrade run: plan, ledger, and completion report. */
621
+ readonly run?: VocabularyRegradeRun;
546
622
  }
547
623
 
548
624
  interface RegradeRewriteCandidate {
@@ -557,20 +633,76 @@ interface RegradeClassifiedFile {
557
633
  readonly rewrite?: RegradeRewriteCandidate;
558
634
  }
559
635
 
636
+ const isIgnoredByClassDirectories = (
637
+ path: string,
638
+ ignoredDirectories: readonly string[] | undefined
639
+ ): boolean => {
640
+ if (ignoredDirectories === undefined || ignoredDirectories.length === 0) {
641
+ return false;
642
+ }
643
+ return path
644
+ .split('/')
645
+ .slice(0, -1)
646
+ .some((segment) => ignoredDirectories.includes(segment));
647
+ };
648
+
649
+ const classScanTargetSkip = (
650
+ cls: RegradeClass,
651
+ path: string,
652
+ collection: DownstreamCollectionOptions | undefined
653
+ ): RegradeClassResult | undefined => {
654
+ const ignoredDirectories =
655
+ collection?.ignoredDirectories ??
656
+ cls.scanTargets?.ignoredDirectories ??
657
+ DEFAULT_IGNORED_DIRECTORIES;
658
+ if (isIgnoredByClassDirectories(path, ignoredDirectories)) {
659
+ return {
660
+ kind: 'skipped',
661
+ notes: [`Skipped by ${cls.id} scan-target filtering.`],
662
+ reason: 'regrade-scan-target-filtered',
663
+ };
664
+ }
665
+ const effectiveScanTargets: ScanTargets | undefined =
666
+ cls.scanTargets?.extensions === undefined &&
667
+ collection?.extensions === undefined
668
+ ? {
669
+ ...cls.scanTargets,
670
+ extensions: DEFAULT_SOURCE_EXTENSIONS,
671
+ }
672
+ : cls.scanTargets;
673
+ if (
674
+ effectiveScanTargets === undefined ||
675
+ includedByPathScope(path, effectiveScanTargets)
676
+ ) {
677
+ return undefined;
678
+ }
679
+ return {
680
+ kind: 'skipped',
681
+ notes: [`Skipped by ${cls.id} scan-target filtering.`],
682
+ reason: 'regrade-scan-target-filtered',
683
+ };
684
+ };
685
+
560
686
  const classifyFile = (
561
687
  path: string,
562
688
  source: string,
563
689
  context: RegradeClassContext,
564
- selected: readonly RegradeClass[]
690
+ selected: readonly RegradeClass[],
691
+ collection?: DownstreamCollectionOptions
565
692
  ): RegradeClassifiedFile => {
566
693
  // 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.
694
+ // "run one class" emphasis. Scan-target skips only own the file when no
695
+ // selected class inspects it; a later no-op still counts as a clean scan.
569
696
  let skipped:
570
697
  | { readonly classId: string; readonly result: RegradeClassResult }
571
698
  | undefined;
699
+ let inspected = false;
572
700
  for (const cls of selected) {
573
- const result = cls.apply(source, context);
701
+ const result =
702
+ classScanTargetSkip(cls, path, collection) ?? cls.apply(source, context);
703
+ if (result.kind !== 'skipped') {
704
+ inspected = true;
705
+ }
574
706
  if (result.kind === 'rewrite') {
575
707
  if (typeof result.nextSource !== 'string') {
576
708
  return {
@@ -623,7 +755,7 @@ const classifyFile = (
623
755
  skipped = { classId: cls.id, result };
624
756
  }
625
757
  }
626
- if (skipped !== undefined) {
758
+ if (!inspected && skipped !== undefined) {
627
759
  return {
628
760
  entry: {
629
761
  classId: skipped.classId,
@@ -642,6 +774,32 @@ interface RegradeEvaluation {
642
774
  readonly rewrites: readonly RegradeRewriteCandidate[];
643
775
  }
644
776
 
777
+ const includeEntryInReport = (
778
+ entry: RegradeReportEntry,
779
+ selection: RegradeReportEntrySelection
780
+ ): boolean =>
781
+ selection === 'all' ||
782
+ entry.outcome === 'rewrite' ||
783
+ entry.outcome === 'needs-review';
784
+
785
+ const skipsByReason = (
786
+ entries: readonly RegradeReportEntry[]
787
+ ): Readonly<Record<string, number>> => {
788
+ const counts = new Map<string, number>();
789
+ for (const entry of entries) {
790
+ if (entry.outcome !== 'skip') {
791
+ continue;
792
+ }
793
+ const reason = entry.reason ?? 'skipped';
794
+ counts.set(reason, (counts.get(reason) ?? 0) + 1);
795
+ }
796
+ return Object.fromEntries(
797
+ [...counts.entries()].toSorted(([left], [right]) =>
798
+ left.localeCompare(right)
799
+ )
800
+ );
801
+ };
802
+
645
803
  /**
646
804
  * Build a coverage report from already-read source files. Pure: no filesystem
647
805
  * access, so coverage semantics are testable directly.
@@ -656,7 +814,10 @@ const buildRegradeEvaluation = (params: {
656
814
  readonly skipped: readonly SkippedSource[];
657
815
  readonly classes: readonly RegradeClass[];
658
816
  readonly selection?: RegradeSelection;
817
+ readonly collection?: DownstreamCollectionOptions;
818
+ readonly includeEntries?: RegradeReportEntrySelection;
659
819
  }): RegradeEvaluation => {
820
+ const entrySelection = params.includeEntries ?? 'actionable';
660
821
  const { selected, unknownClassIds } = selectRegradeClasses(
661
822
  params.classes,
662
823
  params.selection
@@ -672,7 +833,8 @@ const buildRegradeEvaluation = (params: {
672
833
  : { absolutePath: file.absolutePath }),
673
834
  path: file.path,
674
835
  },
675
- selected
836
+ selected,
837
+ params.collection
676
838
  )
677
839
  );
678
840
  const fileEntries = classifiedFiles.map((file) => file.entry);
@@ -685,9 +847,12 @@ const buildRegradeEvaluation = (params: {
685
847
  reason: entry.reason,
686
848
  }));
687
849
 
688
- const entries = [...fileEntries, ...skipEntries].toSorted((a, b) =>
850
+ const allEntries = [...fileEntries, ...skipEntries].toSorted((a, b) =>
689
851
  a.path.localeCompare(b.path)
690
852
  );
853
+ const entries = allEntries.filter((entry) =>
854
+ includeEntryInReport(entry, entrySelection)
855
+ );
691
856
 
692
857
  // Class-level skips (e.g. scan-target filtering) are accounted as skipped, not
693
858
  // as scanned/clean files.
@@ -699,6 +864,11 @@ const buildRegradeEvaluation = (params: {
699
864
  const review = scannedEntries.filter(
700
865
  (e) => e.outcome === 'needs-review'
701
866
  ).length;
867
+ const matchedPaths = scannedEntries
868
+ .filter((e) => e.outcome === 'rewrite' || e.outcome === 'needs-review')
869
+ .map((entry) => entry.path);
870
+ const skipped = skipEntries.length + fileSkipCount;
871
+ const skippedReasons = skipsByReason(allEntries);
702
872
 
703
873
  return {
704
874
  report: {
@@ -707,9 +877,16 @@ const buildRegradeEvaluation = (params: {
707
877
  review,
708
878
  rewritten,
709
879
  root: params.root,
880
+ scan: buildRegradeScanSummary({
881
+ matchedPaths,
882
+ scanned: scannedEntries.length,
883
+ skipped,
884
+ skippedByReason: skippedReasons,
885
+ }),
710
886
  scanned: scannedEntries.length,
711
887
  selectedClassIds: selected.map((cls) => cls.id),
712
- skipped: skipEntries.length + fileSkipCount,
888
+ skipped,
889
+ skipsByReason: skippedReasons,
713
890
  unknownClassIds,
714
891
  },
715
892
  rewrites,
@@ -726,6 +903,8 @@ export const buildRegradeReport = (params: {
726
903
  readonly skipped: readonly SkippedSource[];
727
904
  readonly classes: readonly RegradeClass[];
728
905
  readonly selection?: RegradeSelection;
906
+ readonly collection?: DownstreamCollectionOptions;
907
+ readonly includeEntries?: RegradeReportEntrySelection;
729
908
  }): RegradeReport => buildRegradeEvaluation(params).report;
730
909
 
731
910
  const applyRegradeEvaluation = (
@@ -797,6 +976,7 @@ const runRegradeEvaluation = (params: {
797
976
  readonly classes: readonly RegradeClass[];
798
977
  readonly selection?: RegradeSelection;
799
978
  readonly collection?: DownstreamCollectionOptions;
979
+ readonly includeEntries?: RegradeReportEntrySelection;
800
980
  }): RegradeEvaluation | null => {
801
981
  const { selected, unknownClassIds } = selectRegradeClasses(
802
982
  params.classes,
@@ -808,9 +988,15 @@ const runRegradeEvaluation = (params: {
808
988
  }
809
989
  return buildRegradeEvaluation({
810
990
  classes: params.classes,
991
+ ...(params.collection === undefined
992
+ ? {}
993
+ : { collection: params.collection }),
811
994
  files: [],
812
995
  root: params.root,
813
996
  skipped: [],
997
+ ...(params.includeEntries === undefined
998
+ ? {}
999
+ : { includeEntries: params.includeEntries }),
814
1000
  ...(params.selection === undefined
815
1001
  ? {}
816
1002
  : { selection: params.selection }),
@@ -841,9 +1027,15 @@ const runRegradeEvaluation = (params: {
841
1027
 
842
1028
  return buildRegradeEvaluation({
843
1029
  classes: params.classes,
1030
+ ...(params.collection === undefined
1031
+ ? {}
1032
+ : { collection: params.collection }),
844
1033
  files,
845
1034
  root: params.root,
846
1035
  skipped,
1036
+ ...(params.includeEntries === undefined
1037
+ ? {}
1038
+ : { includeEntries: params.includeEntries }),
847
1039
  ...(params.selection === undefined ? {} : { selection: params.selection }),
848
1040
  });
849
1041
  };
@@ -861,6 +1053,7 @@ export const runRegrade = (params: {
861
1053
  readonly selection?: RegradeSelection;
862
1054
  readonly collection?: DownstreamCollectionOptions;
863
1055
  readonly apply?: boolean;
1056
+ readonly includeEntries?: RegradeReportEntrySelection;
864
1057
  }): Result<RegradeReport | null, InternalError> => {
865
1058
  const evaluation = runRegradeEvaluation(params);
866
1059
  if (evaluation === null) {
@@ -941,14 +1134,25 @@ export const regradeReportOutput = z.object({
941
1134
  .describe('Apply-mode summary; absent for dry-run report-only calls'),
942
1135
  entries: z
943
1136
  .array(regradeReportEntrySchema)
944
- .describe('Per-entry detail, sorted by path'),
1137
+ .describe(
1138
+ 'Per-entry detail, sorted by path. Defaults to actionable rewrite/review entries.'
1139
+ ),
945
1140
  matched: z.number().describe('Files with a rewrite or review outcome'),
946
1141
  review: z.number().describe('Files routed to review'),
947
1142
  rewritten: z.number().describe('Files with a rewrite outcome'),
948
1143
  root: z.string().describe('Root the run scanned'),
1144
+ run: vocabularyRegradeRunOutput
1145
+ .optional()
1146
+ .describe('Vocabulary regrade run: plan, ledger, and completion report'),
1147
+ scan: regradeScanSummaryOutput.describe(
1148
+ 'Agent-facing inventory summary for the scan'
1149
+ ),
949
1150
  scanned: z.number().describe('Source files inspected'),
950
1151
  selectedClassIds: z.array(z.string()).describe('Class ids executed'),
951
1152
  skipped: z.number().describe('Entries skipped'),
1153
+ skipsByReason: z
1154
+ .record(z.string(), z.number())
1155
+ .describe('Skipped entries grouped by reason'),
952
1156
  unknownClassIds: z
953
1157
  .array(z.string())
954
1158
  .describe('Selected ids that did not resolve to a class'),
@@ -967,3 +1171,55 @@ export const wardenTermRewriteClasses: readonly RegradeClass[] = Object.freeze(
967
1171
  return cls === null ? [] : [cls];
968
1172
  })
969
1173
  );
1174
+
1175
+ const duplicateClassDiagnostics = (
1176
+ root: string,
1177
+ classes: readonly RegradeClass[]
1178
+ ): readonly WardenDiagnostic[] => {
1179
+ const seen = new Set<string>();
1180
+ const diagnostics: WardenDiagnostic[] = [];
1181
+ for (const cls of classes) {
1182
+ if (!seen.has(cls.id)) {
1183
+ seen.add(cls.id);
1184
+ continue;
1185
+ }
1186
+ diagnostics.push({
1187
+ filePath: root,
1188
+ line: 1,
1189
+ message: `Duplicate Regrade class id "${cls.id}" from Warden term-rewrite rules.`,
1190
+ rule: 'regrade-warden-term-rewrite-classes',
1191
+ severity: 'error',
1192
+ });
1193
+ }
1194
+ return diagnostics;
1195
+ };
1196
+
1197
+ /**
1198
+ * Load built-in and project-local Warden term-rewrite rules as Regrade classes.
1199
+ *
1200
+ * Built-ins are always available. When `root` is provided, committed
1201
+ * project-local Warden rules under `.trails/rules.ts` or direct
1202
+ * `.trails/rules/*.ts` modules are loaded and any term-rewrite-capable source
1203
+ * rules join the class set.
1204
+ */
1205
+ export const loadWardenTermRewriteClasses = async (
1206
+ root?: string
1207
+ ): Promise<RegradeWardenClassSet> => {
1208
+ if (root === undefined) {
1209
+ return { classes: wardenTermRewriteClasses, diagnostics: [] };
1210
+ }
1211
+
1212
+ const projectRules = await loadProjectWardenRules(root);
1213
+ const projectClasses = projectRules.sourceRules.flatMap((rule) => {
1214
+ const cls = createWardenTermRewriteClass(rule);
1215
+ return cls === null ? [] : [cls];
1216
+ });
1217
+ const classes = [...wardenTermRewriteClasses, ...projectClasses];
1218
+ return {
1219
+ classes,
1220
+ diagnostics: [
1221
+ ...projectRules.diagnostics,
1222
+ ...duplicateClassDiagnostics(root, classes),
1223
+ ],
1224
+ };
1225
+ };