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

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.
@@ -8,6 +8,7 @@ import type { ScanTargets } from '@ontrails/core';
8
8
  import type {
9
9
  WardenDiagnostic,
10
10
  WardenFixEdit,
11
+ WardenGuidance,
11
12
  WardenRule,
12
13
  } from '@ontrails/warden';
13
14
  import {
@@ -16,7 +17,8 @@ import {
16
17
  loadProjectWardenRules,
17
18
  wardenRules,
18
19
  } from '@ontrails/warden';
19
- import { readFileSync, readdirSync, writeFileSync } from 'node:fs';
20
+ import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
21
+ import { dirname, join, relative, resolve } from 'node:path';
20
22
  import { z } from 'zod';
21
23
 
22
24
  import {
@@ -78,6 +80,16 @@ export interface RegradeClassContext {
78
80
  readonly path: string;
79
81
  /** Absolute path on disk, when the caller has one. */
80
82
  readonly absolutePath?: string;
83
+ /** Nearest owning package facts, when filesystem collection found a manifest. */
84
+ readonly package?: {
85
+ readonly dependencies: readonly string[];
86
+ /** Runtime-visible dependency declarations (dependencies, optional, peer). */
87
+ readonly runtimeDependencies?: readonly string[];
88
+ readonly manifestState?: 'invalid' | 'valid';
89
+ readonly name?: string;
90
+ /** Root-relative POSIX manifest path. */
91
+ readonly path: string;
92
+ };
81
93
  }
82
94
 
83
95
  /** Files a regrade class knows how to inspect. */
@@ -143,18 +155,39 @@ export interface RegradeReviewSpan {
143
155
  readonly start: number;
144
156
  }
145
157
 
158
+ /**
159
+ * Verdict state for a review detail.
160
+ *
161
+ * - `unresolved`: the class could not complete occurrence judgment; a human or
162
+ * agent decision is still needed.
163
+ * - `preserve`: a completed verdict to keep the occurrence as-is.
164
+ * - `rewrite`: a completed verdict that a rewrite is intended but this run
165
+ * could not apply it (for example invalid or missing edits).
166
+ */
167
+ export type RegradeReviewJudgment = 'preserve' | 'rewrite' | 'unresolved';
168
+
146
169
  /** Structured detail explaining why a source match needs review. */
147
170
  export interface RegradeReviewDetail {
171
+ /** Concrete replacement the class would apply if the occurrence were judged safe. */
172
+ readonly candidateReplacement?: string;
148
173
  /** Class that produced the review detail, injected by report building. */
149
174
  readonly classId?: string;
150
175
  /** Expected target shape when the class can describe one. */
151
176
  readonly expectedTarget?: string;
152
177
  /** Fixture or example reference that illustrates the expected migration. */
153
178
  readonly fixture?: string;
179
+ /** Whether occurrence judgment is unresolved or a preserve/rewrite verdict completed. */
180
+ readonly judgment?: RegradeReviewJudgment;
181
+ /** Exact matched source text for the occurrence under review. */
182
+ readonly matchedForm?: string;
154
183
  /** AST node kind or source construct kind. */
155
184
  readonly nodeKind?: string;
185
+ /** Cautions explaining why a blind rewrite of this occurrence is unsafe. */
186
+ readonly preserveCautions?: readonly string[];
156
187
  /** Machine-readable reason for review. */
157
188
  readonly reason: string;
189
+ /** Machine-readable provenance tags for the producing rule or class. */
190
+ readonly signals?: readonly string[];
158
191
  /** Source span and line/column for the review-required match. */
159
192
  readonly span?: RegradeReviewSpan;
160
193
  /** Suggested validation command after the review is resolved. */
@@ -317,7 +350,9 @@ const diagnosticSpan = (
317
350
  return spanForSymbolOnDiagnosticLine(source, diagnostic.line, symbol);
318
351
  };
319
352
 
320
- const expectedTarget = (diagnostic: WardenDiagnostic): string | undefined => {
353
+ const diagnosticCandidateReplacement = (
354
+ diagnostic: WardenDiagnostic
355
+ ): string | undefined => {
321
356
  const replacements = new Set(
322
357
  (diagnostic.fix?.edits ?? []).map((edit) => edit.replacement)
323
358
  );
@@ -325,32 +360,98 @@ const expectedTarget = (diagnostic: WardenDiagnostic): string | undefined => {
325
360
  return undefined;
326
361
  }
327
362
  const [replacement] = replacements;
363
+ return replacement;
364
+ };
365
+
366
+ const expectedTarget = (diagnostic: WardenDiagnostic): string | undefined => {
367
+ const replacement = diagnosticCandidateReplacement(diagnostic);
328
368
  return replacement === undefined
329
369
  ? undefined
330
370
  : `Replace with "${replacement}".`;
331
371
  };
332
372
 
373
+ const isValidEditSpan = (
374
+ source: string,
375
+ edit: WardenFixEdit | undefined
376
+ ): edit is WardenFixEdit =>
377
+ edit !== undefined &&
378
+ Number.isInteger(edit.start) &&
379
+ Number.isInteger(edit.end) &&
380
+ edit.start >= 0 &&
381
+ edit.end >= edit.start &&
382
+ edit.end <= source.length;
383
+
384
+ const diagnosticMatchedForm = (
385
+ source: string,
386
+ diagnostic: WardenDiagnostic,
387
+ symbol: string | undefined
388
+ ): string | undefined => {
389
+ const [edit] = diagnostic.fix?.edits ?? [];
390
+ if (isValidEditSpan(source, edit)) {
391
+ return source.slice(edit.start, edit.end);
392
+ }
393
+ return symbol;
394
+ };
395
+
396
+ const diagnosticSignals = (diagnostic: WardenDiagnostic): readonly string[] => [
397
+ `warden:${diagnostic.rule}`,
398
+ ...(diagnostic.code === undefined
399
+ ? []
400
+ : [`${diagnostic.rule}:${diagnostic.code}`]),
401
+ ];
402
+
403
+ interface WardenReviewMappingOptions {
404
+ /** Verdict state for this review path. */
405
+ readonly judgment: RegradeReviewJudgment;
406
+ /** Machine-readable review reason. */
407
+ readonly reason: string;
408
+ /** Rule-level guidance used when a finding carries none of its own. */
409
+ readonly ruleGuidance?: WardenGuidance;
410
+ }
411
+
412
+ const reviewDetailFromDiagnostic = (
413
+ source: string,
414
+ diagnostic: WardenDiagnostic,
415
+ options: WardenReviewMappingOptions
416
+ ): RegradeReviewDetail => {
417
+ const symbol =
418
+ firstQuotedValue(diagnostic.fix?.reason) ??
419
+ firstQuotedValue(diagnostic.message);
420
+ const span = diagnosticSpan(source, diagnostic, symbol);
421
+ const target = expectedTarget(diagnostic);
422
+ const replacement = diagnosticCandidateReplacement(diagnostic);
423
+ const matchedForm = diagnosticMatchedForm(source, diagnostic, symbol);
424
+ const guidance = diagnostic.guidance ?? options.ruleGuidance;
425
+ const preserveCautions =
426
+ guidance === undefined
427
+ ? undefined
428
+ : [guidance.summary, ...(guidance.steps ?? [])];
429
+ const suggestedValidation = guidance?.commands?.[0];
430
+ return {
431
+ ...(replacement === undefined ? {} : { candidateReplacement: replacement }),
432
+ ...(target === undefined ? {} : { expectedTarget: target }),
433
+ ...(diagnostic.fix?.fixture === undefined
434
+ ? {}
435
+ : { fixture: diagnostic.fix.fixture }),
436
+ judgment: options.judgment,
437
+ ...(matchedForm === undefined ? {} : { matchedForm }),
438
+ ...(preserveCautions === undefined ? {} : { preserveCautions }),
439
+ reason: options.reason,
440
+ signals: diagnosticSignals(diagnostic),
441
+ ...(span === undefined ? {} : { span }),
442
+ ...(suggestedValidation === undefined ? {} : { suggestedValidation }),
443
+ ...(symbol === undefined ? {} : { symbol }),
444
+ } satisfies RegradeReviewDetail;
445
+ };
446
+
333
447
  const reviewDetailsFromDiagnostics = (
334
448
  source: string,
335
449
  diagnostics: readonly WardenDiagnostic[],
336
- reason: string
450
+ options: WardenReviewMappingOptions
337
451
  ): readonly RegradeReviewDetail[] | undefined => {
338
- const details = diagnostics.map((diagnostic) => {
339
- const symbol =
340
- firstQuotedValue(diagnostic.fix?.reason) ??
341
- firstQuotedValue(diagnostic.message);
342
- const span = diagnosticSpan(source, diagnostic, symbol);
343
- const target = expectedTarget(diagnostic);
344
- return {
345
- ...(target === undefined ? {} : { expectedTarget: target }),
346
- ...(diagnostic.fix?.fixture === undefined
347
- ? {}
348
- : { fixture: diagnostic.fix.fixture }),
349
- reason,
350
- ...(span === undefined ? {} : { span }),
351
- ...(symbol === undefined ? {} : { symbol }),
352
- } satisfies RegradeReviewDetail;
353
- });
452
+ const details = diagnostics.map((diagnostic) =>
453
+ reviewDetailFromDiagnostic(source, diagnostic, options)
454
+ );
354
455
  return details.length === 0 ? undefined : details;
355
456
  };
356
457
 
@@ -431,10 +532,18 @@ export const createWardenTermRewriteClass = (
431
532
  (diagnostic) => diagnostic.fix?.safety !== 'safe'
432
533
  );
433
534
  if (reviewDiagnostics.length > 0) {
535
+ // The rule flagged the occurrence but marked it review: occurrence
536
+ // judgment is unresolved and needs a human or agent decision.
434
537
  const reviewDetails = reviewDetailsFromDiagnostics(
435
538
  source,
436
539
  reviewDiagnostics,
437
- 'warden-review-required'
540
+ {
541
+ judgment: 'unresolved',
542
+ reason: 'warden-review-required',
543
+ ...(metadata.guidance === undefined
544
+ ? {}
545
+ : { ruleGuidance: metadata.guidance }),
546
+ }
438
547
  );
439
548
  return {
440
549
  kind: 'needs-review',
@@ -448,10 +557,18 @@ export const createWardenTermRewriteClass = (
448
557
  (diagnostic) => (diagnostic.fix?.edits?.length ?? 0) === 0
449
558
  );
450
559
  if (diagnosticsMissingEdits.length > 0) {
560
+ // A safe fix without concrete edits cannot complete occurrence
561
+ // judgment on its own, so the verdict stays unresolved.
451
562
  const reviewDetails = reviewDetailsFromDiagnostics(
452
563
  source,
453
564
  diagnosticsMissingEdits,
454
- 'warden-fix-missing-edits'
565
+ {
566
+ judgment: 'unresolved',
567
+ reason: 'warden-fix-missing-edits',
568
+ ...(metadata.guidance === undefined
569
+ ? {}
570
+ : { ruleGuidance: metadata.guidance }),
571
+ }
455
572
  );
456
573
  return {
457
574
  kind: 'needs-review',
@@ -466,10 +583,18 @@ export const createWardenTermRewriteClass = (
466
583
  );
467
584
  const application = applyWardenEdits(source, edits);
468
585
  if (!application.ok) {
586
+ // The rule completed judgment — it authored concrete edits — but this
587
+ // run could not apply them, so the verdict is a rewrite left undone.
469
588
  const reviewDetails = reviewDetailsFromDiagnostics(
470
589
  source,
471
590
  diagnostics,
472
- 'warden-fix-invalid'
591
+ {
592
+ judgment: 'rewrite',
593
+ reason: 'warden-fix-invalid',
594
+ ...(metadata.guidance === undefined
595
+ ? {}
596
+ : { ruleGuidance: metadata.guidance }),
597
+ }
473
598
  );
474
599
  return {
475
600
  kind: 'needs-review',
@@ -567,6 +692,9 @@ const deriveCollectionOptions = (
567
692
  ...(collection?.exclude === undefined
568
693
  ? {}
569
694
  : { exclude: collection.exclude }),
695
+ ...(collection?.include === undefined
696
+ ? {}
697
+ : { include: collection.include }),
570
698
  extensions: collection?.extensions ?? targetExtensions,
571
699
  ignoredDirectories:
572
700
  collection?.ignoredDirectories ??
@@ -619,6 +747,28 @@ export interface RegradeReport {
619
747
  readonly apply?: RegradeApplySummary;
620
748
  /** Vocabulary regrade run: plan, ledger, and completion report. */
621
749
  readonly run?: VocabularyRegradeRun;
750
+ /** Saved active Regrade plan evidence for vocabulary regrades. */
751
+ readonly plan?: {
752
+ readonly expansionPending?: number;
753
+ readonly path: string;
754
+ readonly schemaVersion: number;
755
+ readonly status: 'active' | 'stale';
756
+ };
757
+ /** Saved applied Regrade history evidence for vocabulary regrades. */
758
+ readonly history?: {
759
+ readonly path: string;
760
+ readonly schemaVersion: number;
761
+ readonly status: 'applied' | 'checked' | 'replay';
762
+ };
763
+ /**
764
+ * @deprecated Persisted transition record evidence for vocabulary regrades.
765
+ * Use `plan` and `history` summaries in public surfaces.
766
+ */
767
+ readonly record?: {
768
+ readonly path: string;
769
+ readonly schemaVersion: number;
770
+ readonly status: 'candidate' | 'applied' | 'checked';
771
+ };
622
772
  }
623
773
 
624
774
  interface RegradeRewriteCandidate {
@@ -690,16 +840,20 @@ const classifyFile = (
690
840
  selected: readonly RegradeClass[],
691
841
  collection?: DownstreamCollectionOptions
692
842
  ): RegradeClassifiedFile => {
693
- // First selected class that matches (rewrite or review) wins, mirroring the
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.
843
+ // Compose safe rewrites across selected classes in memory so one governed
844
+ // transition can move every compatible symbol in a file. Review still wins:
845
+ // if any class needs judgment, no partial rewrite is returned for that file.
696
846
  let skipped:
697
847
  | { readonly classId: string; readonly result: RegradeClassResult }
698
848
  | undefined;
699
849
  let inspected = false;
850
+ let currentSource = source;
851
+ const rewriteClassIds: string[] = [];
852
+ const rewriteNotes: string[] = [];
700
853
  for (const cls of selected) {
701
854
  const result =
702
- classScanTargetSkip(cls, path, collection) ?? cls.apply(source, context);
855
+ classScanTargetSkip(cls, path, collection) ??
856
+ cls.apply(currentSource, context);
703
857
  if (result.kind !== 'skipped') {
704
858
  inspected = true;
705
859
  }
@@ -715,38 +869,29 @@ const classifyFile = (
715
869
  },
716
870
  };
717
871
  }
718
- const entry = {
719
- classId: cls.id,
720
- notes: result.notes,
721
- outcome: 'rewrite',
722
- path,
723
- } satisfies RegradeReportEntry;
724
- return {
725
- entry,
726
- ...(context.absolutePath === undefined
727
- ? {}
728
- : {
729
- rewrite: {
730
- absolutePath: context.absolutePath,
731
- classId: cls.id,
732
- nextSource: result.nextSource,
733
- path,
734
- },
735
- }),
736
- };
872
+ currentSource = result.nextSource;
873
+ rewriteClassIds.push(cls.id);
874
+ rewriteNotes.push(...(result.notes ?? []));
875
+ continue;
737
876
  }
738
877
  if (result.kind === 'needs-review') {
739
- const reviewDetails = result.reviewDetails?.map((detail) => ({
878
+ const originalSourceResult =
879
+ currentSource === source ? result : cls.apply(source, context);
880
+ const reviewResult =
881
+ originalSourceResult.kind === 'needs-review'
882
+ ? originalSourceResult
883
+ : result;
884
+ const reviewDetails = reviewResult.reviewDetails?.map((detail) => ({
740
885
  ...detail,
741
886
  classId: detail.classId ?? cls.id,
742
887
  }));
743
888
  return {
744
889
  entry: {
745
890
  classId: cls.id,
746
- notes: result.notes,
891
+ notes: reviewResult.notes,
747
892
  outcome: 'needs-review',
748
893
  path,
749
- reason: result.reason ?? 'needs-review',
894
+ reason: reviewResult.reason ?? 'needs-review',
750
895
  ...(reviewDetails === undefined ? {} : { reviewDetails }),
751
896
  },
752
897
  };
@@ -755,6 +900,28 @@ const classifyFile = (
755
900
  skipped = { classId: cls.id, result };
756
901
  }
757
902
  }
903
+ if (rewriteClassIds.length > 0) {
904
+ const classId = rewriteClassIds.join(',');
905
+ const entry = {
906
+ classId,
907
+ notes: rewriteNotes,
908
+ outcome: 'rewrite',
909
+ path,
910
+ } satisfies RegradeReportEntry;
911
+ return {
912
+ entry,
913
+ ...(context.absolutePath === undefined
914
+ ? {}
915
+ : {
916
+ rewrite: {
917
+ absolutePath: context.absolutePath,
918
+ classId,
919
+ nextSource: currentSource,
920
+ path,
921
+ },
922
+ }),
923
+ };
924
+ }
758
925
  if (!inspected && skipped !== undefined) {
759
926
  return {
760
927
  entry: {
@@ -810,6 +977,7 @@ const buildRegradeEvaluation = (params: {
810
977
  readonly path: string;
811
978
  readonly source: string;
812
979
  readonly absolutePath?: string;
980
+ readonly package?: RegradeClassContext['package'];
813
981
  }[];
814
982
  readonly skipped: readonly SkippedSource[];
815
983
  readonly classes: readonly RegradeClass[];
@@ -831,6 +999,7 @@ const buildRegradeEvaluation = (params: {
831
999
  ...(file.absolutePath === undefined
832
1000
  ? {}
833
1001
  : { absolutePath: file.absolutePath }),
1002
+ ...(file.package === undefined ? {} : { package: file.package }),
834
1003
  path: file.path,
835
1004
  },
836
1005
  selected,
@@ -899,6 +1068,7 @@ export const buildRegradeReport = (params: {
899
1068
  readonly path: string;
900
1069
  readonly source: string;
901
1070
  readonly absolutePath?: string;
1071
+ readonly package?: RegradeClassContext['package'];
902
1072
  }[];
903
1073
  readonly skipped: readonly SkippedSource[];
904
1074
  readonly classes: readonly RegradeClass[];
@@ -971,6 +1141,121 @@ const canReadDownstreamRoot = (root: string): boolean => {
971
1141
  }
972
1142
  };
973
1143
 
1144
+ const PACKAGE_DEPENDENCY_FIELDS = [
1145
+ 'dependencies',
1146
+ 'devDependencies',
1147
+ 'optionalDependencies',
1148
+ 'peerDependencies',
1149
+ ] as const;
1150
+
1151
+ const RUNTIME_PACKAGE_DEPENDENCY_FIELDS = [
1152
+ 'dependencies',
1153
+ 'optionalDependencies',
1154
+ 'peerDependencies',
1155
+ ] as const;
1156
+
1157
+ const dependencyNames = (
1158
+ manifest: Readonly<Record<string, unknown>>,
1159
+ fields: readonly (typeof PACKAGE_DEPENDENCY_FIELDS)[number][]
1160
+ ): readonly string[] =>
1161
+ fields.flatMap((field) => {
1162
+ const value = manifest[field];
1163
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
1164
+ ? Object.keys(value)
1165
+ : [];
1166
+ });
1167
+
1168
+ const packageContextFor = (
1169
+ root: string,
1170
+ absolutePath: string,
1171
+ cache: Map<string, RegradeClassContext['package'] | undefined>
1172
+ ): RegradeClassContext['package'] | undefined => {
1173
+ const absoluteRoot = resolve(root);
1174
+ let current = dirname(absolutePath);
1175
+ const visited: string[] = [];
1176
+ while (true) {
1177
+ if (cache.has(current)) {
1178
+ const cached = cache.get(current);
1179
+ for (const directory of visited) {
1180
+ cache.set(directory, cached);
1181
+ }
1182
+ return cached;
1183
+ }
1184
+ visited.push(current);
1185
+ const fromRoot = relative(absoluteRoot, current);
1186
+ if (fromRoot.startsWith('..') || resolve(current) !== current) {
1187
+ break;
1188
+ }
1189
+ const manifestPath = join(current, 'package.json');
1190
+ if (existsSync(manifestPath)) {
1191
+ const manifestRelativePath = relative(
1192
+ absoluteRoot,
1193
+ manifestPath
1194
+ ).replaceAll('\\', '/');
1195
+ try {
1196
+ const parsed = JSON.parse(
1197
+ readFileSync(manifestPath, 'utf8')
1198
+ ) as unknown;
1199
+ if (
1200
+ typeof parsed !== 'object' ||
1201
+ parsed === null ||
1202
+ Array.isArray(parsed)
1203
+ ) {
1204
+ const invalidContext = {
1205
+ dependencies: [],
1206
+ manifestState: 'invalid' as const,
1207
+ path: manifestRelativePath,
1208
+ };
1209
+ for (const directory of visited) {
1210
+ cache.set(directory, invalidContext);
1211
+ }
1212
+ return invalidContext;
1213
+ }
1214
+ const manifest = parsed as Record<string, unknown>;
1215
+ const dependencies = dependencyNames(
1216
+ manifest,
1217
+ PACKAGE_DEPENDENCY_FIELDS
1218
+ );
1219
+ const runtimeDependencies = dependencyNames(
1220
+ manifest,
1221
+ RUNTIME_PACKAGE_DEPENDENCY_FIELDS
1222
+ );
1223
+ const packageContext = {
1224
+ dependencies: [...new Set(dependencies)].toSorted(),
1225
+ manifestState: 'valid' as const,
1226
+ ...(typeof manifest['name'] === 'string'
1227
+ ? { name: manifest['name'] }
1228
+ : {}),
1229
+ path: manifestRelativePath,
1230
+ runtimeDependencies: [...new Set(runtimeDependencies)].toSorted(),
1231
+ };
1232
+ for (const directory of visited) {
1233
+ cache.set(directory, packageContext);
1234
+ }
1235
+ return packageContext;
1236
+ } catch {
1237
+ const invalidContext = {
1238
+ dependencies: [],
1239
+ manifestState: 'invalid' as const,
1240
+ path: manifestRelativePath,
1241
+ };
1242
+ for (const directory of visited) {
1243
+ cache.set(directory, invalidContext);
1244
+ }
1245
+ return invalidContext;
1246
+ }
1247
+ }
1248
+ if (current === absoluteRoot) {
1249
+ break;
1250
+ }
1251
+ current = dirname(current);
1252
+ }
1253
+ for (const directory of visited) {
1254
+ cache.set(directory, undefined);
1255
+ }
1256
+ return undefined;
1257
+ };
1258
+
974
1259
  const runRegradeEvaluation = (params: {
975
1260
  readonly root: string;
976
1261
  readonly classes: readonly RegradeClass[];
@@ -1013,10 +1298,20 @@ const runRegradeEvaluation = (params: {
1013
1298
 
1014
1299
  const files: { absolutePath: string; path: string; source: string }[] = [];
1015
1300
  const skipped: SkippedSource[] = [...collected.skipped];
1301
+ const packageContextCache = new Map<
1302
+ string,
1303
+ RegradeClassContext['package'] | undefined
1304
+ >();
1016
1305
  for (const file of collected.files) {
1017
1306
  try {
1307
+ const packageContext = packageContextFor(
1308
+ params.root,
1309
+ file.absolutePath,
1310
+ packageContextCache
1311
+ );
1018
1312
  files.push({
1019
1313
  absolutePath: file.absolutePath,
1314
+ ...(packageContext === undefined ? {} : { package: packageContext }),
1020
1315
  path: file.path,
1021
1316
  source: readFileSync(file.absolutePath, 'utf8'),
1022
1317
  });
@@ -1083,6 +1378,12 @@ const regradeReportEntrySchema = z.object({
1083
1378
  reviewDetails: z
1084
1379
  .array(
1085
1380
  z.object({
1381
+ candidateReplacement: z
1382
+ .string()
1383
+ .optional()
1384
+ .describe(
1385
+ 'Concrete replacement the class would apply if the occurrence were judged safe'
1386
+ ),
1086
1387
  classId: z
1087
1388
  .string()
1088
1389
  .optional()
@@ -1095,11 +1396,35 @@ const regradeReportEntrySchema = z.object({
1095
1396
  .string()
1096
1397
  .optional()
1097
1398
  .describe('Fixture or example reference for the migration'),
1399
+ judgment: z
1400
+ .enum(['preserve', 'rewrite', 'unresolved'])
1401
+ .optional()
1402
+ .describe(
1403
+ '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'
1404
+ ),
1405
+ matchedForm: z
1406
+ .string()
1407
+ .optional()
1408
+ .describe(
1409
+ 'Exact matched source text for the occurrence under review'
1410
+ ),
1098
1411
  nodeKind: z
1099
1412
  .string()
1100
1413
  .optional()
1101
1414
  .describe('AST node kind or source construct kind'),
1415
+ preserveCautions: z
1416
+ .array(z.string())
1417
+ .optional()
1418
+ .describe(
1419
+ 'Cautions explaining why a blind rewrite of this occurrence is unsafe'
1420
+ ),
1102
1421
  reason: z.string().describe('Machine-readable review reason'),
1422
+ signals: z
1423
+ .array(z.string())
1424
+ .optional()
1425
+ .describe(
1426
+ 'Machine-readable provenance tags for the producing rule or class'
1427
+ ),
1103
1428
  span: z
1104
1429
  .object({
1105
1430
  column: z.number().describe('One-based source column'),
@@ -1137,7 +1462,43 @@ export const regradeReportOutput = z.object({
1137
1462
  .describe(
1138
1463
  'Per-entry detail, sorted by path. Defaults to actionable rewrite/review entries.'
1139
1464
  ),
1465
+ history: z
1466
+ .object({
1467
+ path: z.string().describe('Root-relative applied history entry path'),
1468
+ schemaVersion: z.number().describe('Regrade history schema version'),
1469
+ status: z
1470
+ .enum(['applied', 'checked', 'replay'])
1471
+ .describe(
1472
+ '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'
1473
+ ),
1474
+ })
1475
+ .optional()
1476
+ .describe('Saved applied Regrade history evidence'),
1140
1477
  matched: z.number().describe('Files with a rewrite or review outcome'),
1478
+ plan: z
1479
+ .object({
1480
+ expansionPending: z
1481
+ .number()
1482
+ .optional()
1483
+ .describe('Pending staged expansion candidates on this plan'),
1484
+ path: z.string().describe('Root-relative Regrade plan path'),
1485
+ schemaVersion: z.number().describe('Regrade plan schema version'),
1486
+ status: z
1487
+ .enum(['active', 'stale'])
1488
+ .describe('Whether the saved plan still matches the source tree'),
1489
+ })
1490
+ .optional()
1491
+ .describe('Saved active Regrade plan evidence'),
1492
+ record: z
1493
+ .object({
1494
+ path: z.string().describe('Root-relative transition record path'),
1495
+ schemaVersion: z.number().describe('Transition record schema version'),
1496
+ status: z
1497
+ .enum(['candidate', 'applied', 'checked'])
1498
+ .describe('How this command used the transition record'),
1499
+ })
1500
+ .optional()
1501
+ .describe('Persisted transition record evidence'),
1141
1502
  review: z.number().describe('Files routed to review'),
1142
1503
  rewritten: z.number().describe('Files with a rewrite outcome'),
1143
1504
  root: z.string().describe('Root the run scanned'),