@ontrails/trails 1.0.0-beta.45 → 1.0.0-beta.46

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.
@@ -14,14 +14,19 @@ import {
14
14
  } from '@ontrails/core';
15
15
  import type { PathScope, Result as TrailsResult } from '@ontrails/core';
16
16
  import {
17
+ applyPreparedRegradeRun,
18
+ applyPreparedVocabularyRegradeRun,
17
19
  createGovernedAstIdentifierRenameClasses,
18
20
  loadWardenRegradeClasses,
21
+ prepareRegradeRun,
22
+ prepareVocabularyRegradeRun,
19
23
  readVocabularyTransitionRecord,
20
24
  regradeReportOutput,
21
25
  runFileRenameRegrade,
22
26
  runRegrade,
23
27
  runVocabularyRegrade,
24
28
  transitionRecordReportWithSummary,
29
+ validatePreparedRegradeRun,
25
30
  vocabularyRegradeTransitionForInput,
26
31
  vocabularyDispositionValues,
27
32
  vocabularyRegradePlanSchema,
@@ -30,6 +35,9 @@ import {
30
35
  } from '@ontrails/regrade';
31
36
  import type {
32
37
  FileRenameRegradeRun,
38
+ PreparedRegradeRun,
39
+ PreparedRegradeRunIdentity,
40
+ PreparedVocabularyRegradeRun,
33
41
  RegradeApplySummary,
34
42
  RegradeReport,
35
43
  RegradeReportEntry,
@@ -46,7 +54,6 @@ import {
46
54
  mkdirSync,
47
55
  readdirSync,
48
56
  readFileSync,
49
- rmSync,
50
57
  writeFileSync,
51
58
  } from 'node:fs';
52
59
  import type { Dirent } from 'node:fs';
@@ -60,8 +67,12 @@ import {
60
67
  } from '../regrade/audit.js';
61
68
  import { loadRegradeConfig } from '../regrade/config.js';
62
69
  import {
63
- REGRADE_HISTORY_SCHEMA_VERSION,
70
+ RegradeLifecycleTracker,
71
+ regradeLifecycleSchema,
72
+ } from '../regrade/lifecycle.js';
73
+ import {
64
74
  appendRegradeHistoryRun,
75
+ consumeActiveRegradePlanAfterHistoryWrite,
65
76
  readRegradeHistoryArtifact,
66
77
  regradeHistoryPathForPlan,
67
78
  resolveRegradeHistoryPath,
@@ -70,7 +81,18 @@ import {
70
81
  } from '../regrade/history.js';
71
82
  import type { RegradeHistorySummary } from '../regrade/history.js';
72
83
  import { deriveLiveApiPreserveInventory } from '../regrade/live-api-preserve.js';
84
+ import {
85
+ captureRegradeChangedFilesBefore,
86
+ completeRegradeChangedFiles,
87
+ resolveRegradeSourceRevision,
88
+ validateRegradeReceiptPlan,
89
+ } from '../regrade/receipt-history.js';
90
+ import type { RegradeChangedFileEvidence } from '../regrade/receipt-history.js';
73
91
  import { deriveRegradePlanDerivation } from '../regrade/plan-derivation.js';
92
+ import {
93
+ preparedRegradeRunIdentity,
94
+ validatePreparedRegradePlanArtifact,
95
+ } from '../regrade/prepared-run.js';
74
96
  import {
75
97
  regradeApplyErrorAfterRollback,
76
98
  snapshotRegradeSources,
@@ -239,7 +261,19 @@ const regradePlansOutputSchema = z.object({
239
261
  plans: z.array(regradePlanSummarySchema),
240
262
  });
241
263
 
242
- const regradeCheckOutputSchema = regradeReportOutput.extend({
264
+ const regradeLifecycleReportOutputSchema = regradeReportOutput.extend({
265
+ lifecycle: regradeLifecycleSchema.describe(
266
+ 'Observed phases and wall-clock timings for this lifecycle command'
267
+ ),
268
+ });
269
+
270
+ const regradePlanCommandOutputSchema = regradePlanArtifactSchema.extend({
271
+ lifecycle: regradeLifecycleSchema.describe(
272
+ 'Observed phases and wall-clock timings for this lifecycle command'
273
+ ),
274
+ });
275
+
276
+ const regradeCheckOutputSchema = regradeLifecycleReportOutputSchema.extend({
243
277
  check: z
244
278
  .object({
245
279
  plan: z
@@ -875,6 +909,19 @@ const mergeTransitionRunReportWithSymbol = (
875
909
  };
876
910
  };
877
911
 
912
+ const withScannedPaths = (
913
+ report: RegradeReport,
914
+ paths: readonly string[]
915
+ ): RegradeReport => {
916
+ Object.defineProperty(report, 'scannedPaths', {
917
+ configurable: false,
918
+ enumerable: false,
919
+ value: Object.freeze([...paths]),
920
+ writable: false,
921
+ });
922
+ return report;
923
+ };
924
+
878
925
  const mergeRegradeReports = (
879
926
  vocabularyReport: RegradeReport,
880
927
  symbolReport: RegradeReport
@@ -908,7 +955,15 @@ const mergeRegradeReports = (
908
955
  symbolReport.skipsByReason
909
956
  );
910
957
  const apply = mergeApplySummary(vocabularyReport.apply, symbolReport.apply);
911
- const scanned = vocabularyReport.scanned + symbolReport.scanned;
958
+ const scannedPaths = uniqueSorted([
959
+ ...(vocabularyReport.scannedPaths ?? []),
960
+ ...(symbolReport.scannedPaths ?? []),
961
+ ]);
962
+ const scanned =
963
+ vocabularyReport.scannedPaths === undefined ||
964
+ symbolReport.scannedPaths === undefined
965
+ ? vocabularyReport.scanned + symbolReport.scanned
966
+ : scannedPaths.length;
912
967
  const run =
913
968
  vocabularyReport.run === undefined
914
969
  ? undefined
@@ -920,36 +975,39 @@ const mergeRegradeReports = (
920
975
  ),
921
976
  };
922
977
 
923
- return {
924
- ...vocabularyReport,
925
- ...(apply === undefined ? {} : { apply }),
926
- entries,
927
- matched,
928
- review,
929
- rewritten,
930
- scan: {
931
- byDirectory: mergedDirectoryBuckets(matchedPaths, occurrencePaths),
932
- byExtension: mergedExtensionBuckets(matchedPaths, occurrencePaths),
933
- files: {
934
- matched: new Set(matchedPaths).size,
935
- scanned,
936
- skipped: Math.max(vocabularyReport.skipped, symbolReport.skipped),
978
+ return withScannedPaths(
979
+ {
980
+ ...vocabularyReport,
981
+ ...(apply === undefined ? {} : { apply }),
982
+ entries,
983
+ matched,
984
+ review,
985
+ rewritten,
986
+ scan: {
987
+ byDirectory: mergedDirectoryBuckets(matchedPaths, occurrencePaths),
988
+ byExtension: mergedExtensionBuckets(matchedPaths, occurrencePaths),
989
+ files: {
990
+ matched: new Set(matchedPaths).size,
991
+ scanned,
992
+ skipped: Math.max(vocabularyReport.skipped, symbolReport.skipped),
993
+ },
994
+ skippedByReason,
937
995
  },
938
- skippedByReason,
996
+ ...(run === undefined ? {} : { run }),
997
+ scanned,
998
+ selectedClassIds: uniqueSorted([
999
+ ...vocabularyReport.selectedClassIds,
1000
+ ...symbolReport.selectedClassIds,
1001
+ ]),
1002
+ skipped: Math.max(vocabularyReport.skipped, symbolReport.skipped),
1003
+ skipsByReason: skippedByReason,
1004
+ unknownClassIds: uniqueSorted([
1005
+ ...vocabularyReport.unknownClassIds,
1006
+ ...symbolReport.unknownClassIds,
1007
+ ]),
939
1008
  },
940
- ...(run === undefined ? {} : { run }),
941
- scanned,
942
- selectedClassIds: uniqueSorted([
943
- ...vocabularyReport.selectedClassIds,
944
- ...symbolReport.selectedClassIds,
945
- ]),
946
- skipped: Math.max(vocabularyReport.skipped, symbolReport.skipped),
947
- skipsByReason: skippedByReason,
948
- unknownClassIds: uniqueSorted([
949
- ...vocabularyReport.unknownClassIds,
950
- ...symbolReport.unknownClassIds,
951
- ]),
952
- };
1009
+ scannedPaths
1010
+ );
953
1011
  };
954
1012
 
955
1013
  const vocabularySymbolCollection = (
@@ -1046,41 +1104,62 @@ const withoutVocabularySourceFilterSkips = (
1046
1104
  if (report === null || rejected === 0) {
1047
1105
  return report;
1048
1106
  }
1049
- return {
1050
- ...report,
1051
- ...(report.apply === undefined
1052
- ? {}
1053
- : {
1054
- apply: {
1055
- ...report.apply,
1056
- skipped: Math.max(0, report.apply.skipped - rejected),
1057
- },
1058
- }),
1059
- entries: report.entries.filter(
1060
- (entry) => entry.reason !== 'not-selected-source'
1061
- ),
1062
- scan: {
1063
- ...report.scan,
1064
- files: {
1065
- ...report.scan.files,
1066
- skipped: Math.max(0, report.scan.files.skipped - rejected),
1067
- },
1068
- skippedByReason: withoutNotSelectedSourceCount(
1069
- report.scan.skippedByReason
1107
+ return withScannedPaths(
1108
+ {
1109
+ ...report,
1110
+ ...(report.apply === undefined
1111
+ ? {}
1112
+ : {
1113
+ apply: {
1114
+ ...report.apply,
1115
+ skipped: Math.max(0, report.apply.skipped - rejected),
1116
+ },
1117
+ }),
1118
+ entries: report.entries.filter(
1119
+ (entry) => entry.reason !== 'not-selected-source'
1070
1120
  ),
1121
+ scan: {
1122
+ ...report.scan,
1123
+ files: {
1124
+ ...report.scan.files,
1125
+ skipped: Math.max(0, report.scan.files.skipped - rejected),
1126
+ },
1127
+ skippedByReason: withoutNotSelectedSourceCount(
1128
+ report.scan.skippedByReason
1129
+ ),
1130
+ },
1131
+ skipped: Math.max(0, report.skipped - rejected),
1132
+ skipsByReason: withoutNotSelectedSourceCount(report.skipsByReason),
1071
1133
  },
1072
- skipped: Math.max(0, report.skipped - rejected),
1073
- skipsByReason: withoutNotSelectedSourceCount(report.skipsByReason),
1074
- };
1134
+ report.scannedPaths ?? []
1135
+ );
1075
1136
  };
1076
1137
 
1077
1138
  const vocabularyEvidenceSource = (
1078
1139
  path: string,
1079
- scope: VocabularyRegradePlan['scope'] | undefined
1140
+ scope: VocabularyRegradePlan['scope'] | undefined,
1141
+ includeCodeComments = false
1080
1142
  ): boolean =>
1081
1143
  vocabularyProseExtensions.includes(extname(path)) ||
1144
+ (includeCodeComments && symbolSourceExtensions.includes(extname(path))) ||
1082
1145
  symbolOccurrenceIsPolicyClassified(scope, path);
1083
1146
 
1147
+ const classifiedCommentInventoryApplies = (
1148
+ plan: VocabularyRegradePlan
1149
+ ): boolean =>
1150
+ vocabularyRegradeTransitionForInput(plan.from, plan.to)?.target.kind ===
1151
+ 'classified';
1152
+
1153
+ const vocabularyEvidenceSourceKind = (
1154
+ path: string,
1155
+ plan: VocabularyRegradePlan
1156
+ ): 'all' | 'comments' =>
1157
+ classifiedCommentInventoryApplies(plan) &&
1158
+ symbolSourceExtensions.includes(extname(path)) &&
1159
+ !symbolOccurrenceIsPolicyClassified(plan.scope, path)
1160
+ ? 'comments'
1161
+ : 'all';
1162
+
1084
1163
  const vocabularyProseEngineApplies = (
1085
1164
  scope: VocabularyRegradePlan['scope'] | undefined
1086
1165
  ): boolean =>
@@ -1215,8 +1294,12 @@ const buildVocabularyPlan = (
1215
1294
  const overrides = mergeVocabularyOverrides(registryPlan, input);
1216
1295
  const preserveRules = mergeVocabularyPreserveRules(registryPlan, preserve);
1217
1296
  const scope = mergeVocabularyScope(registryPlan?.scope, configScope, input);
1218
- const fileRenames =
1219
- input.fileRenames ?? registryFileRenamesForRoot(registryPlan, rootDir);
1297
+ const fileRenames = (
1298
+ input.fileRenames ?? registryFileRenamesForRoot(registryPlan, rootDir)
1299
+ )?.map((rename) => ({
1300
+ from: posix.normalize(rename.from.replaceAll('\\', '/')),
1301
+ to: posix.normalize(rename.to.replaceAll('\\', '/')),
1302
+ }));
1220
1303
 
1221
1304
  return Result.ok({
1222
1305
  ...(registryPlan?.caseSensitive === undefined
@@ -1312,41 +1395,40 @@ const reportWithVocabularyTransitionRun = (params: {
1312
1395
  report: transitionRunReportForRegradeReport(params.report),
1313
1396
  };
1314
1397
 
1315
- return {
1316
- ...params.report,
1317
- run: {
1318
- ...run,
1319
- plan: params.plan,
1320
- ...(preserveInventory === undefined ? {} : { preserveInventory }),
1321
- report:
1322
- params.report.run === undefined
1323
- ? transitionRunReportForRegradeReport(params.report)
1324
- : params.report.run.report,
1398
+ return withScannedPaths(
1399
+ {
1400
+ ...params.report,
1401
+ run: {
1402
+ ...run,
1403
+ plan: params.plan,
1404
+ ...(preserveInventory === undefined ? {} : { preserveInventory }),
1405
+ report:
1406
+ params.report.run === undefined
1407
+ ? transitionRunReportForRegradeReport(params.report)
1408
+ : params.report.run.report,
1409
+ },
1325
1410
  },
1326
- };
1411
+ params.report.scannedPaths ?? []
1412
+ );
1327
1413
  };
1328
1414
 
1329
- const runGovernedSymbolRegrade = (params: {
1330
- readonly apply: boolean;
1331
- readonly includeEntries: RegradeInput['includeEntries'];
1415
+ const governedSymbolRegradeConfiguration = (params: {
1332
1416
  readonly plan: VocabularyRegradePlan;
1333
1417
  readonly preserveInventory: readonly VocabularyPreserveInventoryEntry[];
1334
- readonly rootDir: string;
1335
- }): TrailsResult<RegradeReport | null, Error> => {
1418
+ }) => {
1336
1419
  const transition = vocabularyRegradeTransitionForInput(
1337
1420
  params.plan.from,
1338
1421
  params.plan.to
1339
1422
  );
1340
1423
  if (transition === undefined) {
1341
- return Result.ok(null);
1424
+ return null;
1342
1425
  }
1343
1426
 
1344
1427
  const symbolCollection = vocabularySymbolCollection(params.plan.scope);
1345
1428
  if (symbolCollection === null) {
1346
- return Result.ok(null);
1429
+ return null;
1347
1430
  }
1348
- return runRegrade({
1349
- apply: params.apply,
1431
+ return {
1350
1432
  classes: createGovernedAstIdentifierRenameClasses(
1351
1433
  {
1352
1434
  ...transition,
@@ -1375,6 +1457,23 @@ const runGovernedSymbolRegrade = (params: {
1375
1457
  }
1376
1458
  ),
1377
1459
  ...(symbolCollection === undefined ? {} : { collection: symbolCollection }),
1460
+ };
1461
+ };
1462
+
1463
+ const runGovernedSymbolRegrade = (params: {
1464
+ readonly apply: boolean;
1465
+ readonly includeEntries: RegradeInput['includeEntries'];
1466
+ readonly plan: VocabularyRegradePlan;
1467
+ readonly preserveInventory: readonly VocabularyPreserveInventoryEntry[];
1468
+ readonly rootDir: string;
1469
+ }): TrailsResult<RegradeReport | null, Error> => {
1470
+ const configuration = governedSymbolRegradeConfiguration(params);
1471
+ if (configuration === null) {
1472
+ return Result.ok(null);
1473
+ }
1474
+ return runRegrade({
1475
+ ...configuration,
1476
+ apply: params.apply,
1378
1477
  includeEntries: params.includeEntries,
1379
1478
  root: params.rootDir,
1380
1479
  });
@@ -1571,11 +1670,104 @@ const buildRegradePlanArtifact = (params: {
1571
1670
  };
1572
1671
  };
1573
1672
 
1673
+ const normalizeAuthoredPlanPath = (path: string): string =>
1674
+ posix.normalize(path.replaceAll('\\', '/'));
1675
+
1676
+ const normalizeRegradePlanBodyPaths = (
1677
+ plan: RegradePlanBody
1678
+ ): RegradePlanBody => {
1679
+ const scope =
1680
+ plan.scope === undefined
1681
+ ? undefined
1682
+ : {
1683
+ ...plan.scope,
1684
+ ...(plan.scope.exclude === undefined
1685
+ ? {}
1686
+ : {
1687
+ exclude: plan.scope.exclude.map(normalizeAuthoredPlanPath),
1688
+ }),
1689
+ ...(plan.scope.include === undefined
1690
+ ? {}
1691
+ : {
1692
+ include: plan.scope.include.map(normalizeAuthoredPlanPath),
1693
+ }),
1694
+ };
1695
+ if (plan.kind === 'class') {
1696
+ return {
1697
+ ...plan,
1698
+ ...(scope === undefined ? {} : { scope }),
1699
+ } as RegradePlanBody;
1700
+ }
1701
+ return {
1702
+ ...plan,
1703
+ ...(plan.fileRenames === undefined
1704
+ ? {}
1705
+ : {
1706
+ fileRenames: plan.fileRenames.map((rename) => ({
1707
+ ...rename,
1708
+ from: normalizeAuthoredPlanPath(rename.from),
1709
+ to: normalizeAuthoredPlanPath(rename.to),
1710
+ })),
1711
+ }),
1712
+ ...(plan.preserve === undefined
1713
+ ? {}
1714
+ : {
1715
+ preserve: plan.preserve.map((preserve) => ({
1716
+ ...preserve,
1717
+ ...(preserve.paths === undefined
1718
+ ? {}
1719
+ : {
1720
+ paths: preserve.paths.map(normalizeAuthoredPlanPath),
1721
+ }),
1722
+ })),
1723
+ }),
1724
+ ...(scope === undefined
1725
+ ? {}
1726
+ : {
1727
+ scope: {
1728
+ ...scope,
1729
+ ...(plan.scope?.ignoredDirectories === undefined
1730
+ ? {}
1731
+ : {
1732
+ ignoredDirectories: plan.scope.ignoredDirectories.map(
1733
+ normalizeAuthoredPlanPath
1734
+ ),
1735
+ }),
1736
+ ...(plan.scope?.policyClassified === undefined
1737
+ ? {}
1738
+ : {
1739
+ policyClassified: plan.scope.policyClassified.map(
1740
+ (policy) => ({
1741
+ ...policy,
1742
+ paths: policy.paths.map(normalizeAuthoredPlanPath),
1743
+ })
1744
+ ),
1745
+ }),
1746
+ ...(plan.scope?.teachingSurfaces === undefined
1747
+ ? {}
1748
+ : {
1749
+ teachingSurfaces: plan.scope.teachingSurfaces.map(
1750
+ normalizeAuthoredPlanPath
1751
+ ),
1752
+ }),
1753
+ },
1754
+ }),
1755
+ } as RegradePlanBody;
1756
+ };
1757
+
1758
+ const normalizeRegradePlanArtifactPaths = (
1759
+ artifact: RegradePlanArtifact
1760
+ ): RegradePlanArtifact => ({
1761
+ ...artifact,
1762
+ plan: normalizeRegradePlanBodyPaths(artifact.plan),
1763
+ });
1764
+
1574
1765
  const writeRegradePlanArtifact = (
1575
1766
  rootDir: string,
1576
1767
  artifact: RegradePlanArtifact
1577
1768
  ): TrailsResult<RegradePlanArtifact, InternalError | ValidationError> => {
1578
- const parsed = regradePlanArtifactSchema.safeParse(artifact);
1769
+ const normalizedArtifact = normalizeRegradePlanArtifactPaths(artifact);
1770
+ const parsed = regradePlanArtifactSchema.safeParse(normalizedArtifact);
1579
1771
  if (!parsed.success) {
1580
1772
  return Result.err(
1581
1773
  new ValidationError('Invalid Regrade plan artifact.', {
@@ -1601,7 +1793,9 @@ const writeRegradePlanArtifact = (
1601
1793
  const validateRegradePlanArtifact = (
1602
1794
  artifact: RegradePlanArtifact
1603
1795
  ): TrailsResult<RegradePlanArtifact, ValidationError> => {
1604
- const parsed = regradePlanArtifactSchema.safeParse(artifact);
1796
+ const parsed = regradePlanArtifactSchema.safeParse(
1797
+ normalizeRegradePlanArtifactPaths(artifact)
1798
+ );
1605
1799
  if (!parsed.success) {
1606
1800
  return Result.err(
1607
1801
  new ValidationError('Invalid Regrade plan artifact.', {
@@ -1637,7 +1831,11 @@ const readRegradePlanArtifact = (
1637
1831
  })
1638
1832
  );
1639
1833
  }
1640
- return Result.ok(parsed.data as unknown as RegradePlanArtifact);
1834
+ return Result.ok(
1835
+ normalizeRegradePlanArtifactPaths(
1836
+ parsed.data as unknown as RegradePlanArtifact
1837
+ )
1838
+ );
1641
1839
  };
1642
1840
 
1643
1841
  /**
@@ -1897,7 +2095,7 @@ const withFileRenameEvidence = (params: {
1897
2095
  ]);
1898
2096
  const filePolicyCount = params.run.policyOccurrencePaths.length;
1899
2097
  const fileInScopeCount = params.run.occurrencePaths.length - filePolicyCount;
1900
- const projectedFileInScopeCount =
2098
+ const derivedFileInScopeCount =
1901
2099
  params.run.report.rewritten + params.run.report.review;
1902
2100
  return {
1903
2101
  ...params.report,
@@ -1914,7 +2112,7 @@ const withFileRenameEvidence = (params: {
1914
2112
  scopeTiers: {
1915
2113
  'in-scope':
1916
2114
  params.report.run.report.scopeTiers['in-scope'] -
1917
- projectedFileInScopeCount +
2115
+ derivedFileInScopeCount +
1918
2116
  fileInScopeCount,
1919
2117
  'policy-classified':
1920
2118
  params.report.run.report.scopeTiers['policy-classified'] +
@@ -2016,22 +2214,88 @@ const runPlanFileRenames = (
2016
2214
  vocabularyPlan: plan,
2017
2215
  });
2018
2216
 
2019
- const runResolvedVocabularyPlan = (params: {
2217
+ interface PreparedVocabularyPlanRun {
2218
+ readonly fileRenamePreflight: FileRenameRegradeRun | null;
2219
+ readonly identity: PreparedRegradeRunIdentity;
2220
+ readonly prose: PreparedVocabularyRegradeRun | null;
2221
+ readonly proseReport: RegradeReport | null;
2222
+ readonly preserveInventory: readonly VocabularyPreserveInventoryEntry[];
2223
+ readonly symbol: PreparedRegradeRun | null;
2224
+ }
2225
+
2226
+ const validatePreparedPlanIdentity = (
2227
+ expected: PreparedRegradeRunIdentity,
2228
+ actual: PreparedRegradeRunIdentity
2229
+ ): TrailsResult<void, ValidationError> => {
2230
+ for (const field of [
2231
+ 'planContentHash',
2232
+ 'policyHash',
2233
+ 'scopeHash',
2234
+ 'lockStateHash',
2235
+ 'toolVersion',
2236
+ ] as const) {
2237
+ if (expected[field] !== actual[field]) {
2238
+ return Result.err(
2239
+ new ValidationError(
2240
+ `Prepared Regrade identity field \`${field}\` is stale.`,
2241
+ {
2242
+ context: {
2243
+ actual: actual[field],
2244
+ expected: expected[field],
2245
+ field,
2246
+ },
2247
+ }
2248
+ )
2249
+ );
2250
+ }
2251
+ }
2252
+ return Result.ok();
2253
+ };
2254
+
2255
+ interface ResolvedVocabularyPlanParams {
2020
2256
  readonly apply: boolean;
2257
+ readonly currentIdentity?: PreparedRegradeRunIdentity | undefined;
2021
2258
  readonly includeEntries: RegradeInput['includeEntries'];
2022
2259
  readonly plan: VocabularyRegradePlan;
2260
+ readonly prepareIdentity?: PreparedRegradeRunIdentity | undefined;
2261
+ readonly prepared?: PreparedVocabularyPlanRun | undefined;
2262
+ readonly preparedResult?:
2263
+ | { value?: PreparedVocabularyPlanRun | undefined }
2264
+ | undefined;
2023
2265
  readonly preserveInventory: readonly VocabularyPreserveInventoryEntry[];
2024
2266
  readonly rootDir: string;
2025
- }): TrailsResult<RegradeReport, Error> => {
2026
- const fileRenamePreflight = runPlanFileRenames(params.plan, {
2027
- apply: false,
2028
- includeEntries: params.includeEntries,
2029
- rootDir: params.rootDir,
2030
- });
2267
+ }
2268
+
2269
+ interface VocabularyPlanExecution {
2270
+ readonly fileRenamePreflight: FileRenameRegradeRun | null;
2271
+ readonly prosePrepared: PreparedVocabularyRegradeRun | null;
2272
+ readonly prosePreviewReport: RegradeReport | null;
2273
+ readonly runProse: (
2274
+ apply: boolean
2275
+ ) => TrailsResult<RegradeReport | null, Error>;
2276
+ readonly runSymbols: (
2277
+ apply: boolean
2278
+ ) => TrailsResult<RegradeReport | null, Error>;
2279
+ readonly symbolPrepared: PreparedRegradeRun | null;
2280
+ readonly symbolPreviewReport: RegradeReport | null;
2281
+ }
2282
+
2283
+ const vocabularyPlanExecution = (
2284
+ params: ResolvedVocabularyPlanParams
2285
+ ): TrailsResult<VocabularyPlanExecution, Error> => {
2286
+ const fileRenamePreflight =
2287
+ params.prepared === undefined
2288
+ ? runPlanFileRenames(params.plan, {
2289
+ apply: false,
2290
+ includeEntries: params.includeEntries,
2291
+ rootDir: params.rootDir,
2292
+ })
2293
+ : Result.ok(params.prepared.fileRenamePreflight);
2031
2294
  if (fileRenamePreflight.isErr()) {
2032
2295
  return fileRenamePreflight;
2033
2296
  }
2034
2297
  const evidencePlan = vocabularyEvidencePlan(params.plan);
2298
+ const includeCodeComments = classifiedCommentInventoryApplies(params.plan);
2035
2299
  const runProse = (
2036
2300
  apply: boolean
2037
2301
  ): TrailsResult<RegradeReport | null, Error> =>
@@ -2046,7 +2310,13 @@ const runResolvedVocabularyPlan = (params: {
2046
2310
  : { preserveInventory: params.preserveInventory }),
2047
2311
  root: params.rootDir,
2048
2312
  sourceFilter: (path) =>
2049
- vocabularyEvidenceSource(path, evidencePlan.scope),
2313
+ vocabularyEvidenceSource(
2314
+ path,
2315
+ evidencePlan.scope,
2316
+ includeCodeComments
2317
+ ),
2318
+ sourceKindForPath: (path) =>
2319
+ vocabularyEvidenceSourceKind(path, evidencePlan),
2050
2320
  });
2051
2321
  const runSymbols = (
2052
2322
  apply: boolean
@@ -2058,47 +2328,244 @@ const runResolvedVocabularyPlan = (params: {
2058
2328
  preserveInventory: params.preserveInventory,
2059
2329
  rootDir: params.rootDir,
2060
2330
  });
2061
- const prosePreview = runProse(false);
2331
+ const prosePrepared =
2332
+ params.prepareIdentity === undefined || evidencePlan === null
2333
+ ? Result.ok(null)
2334
+ : prepareVocabularyRegradeRun({
2335
+ identity: params.prepareIdentity,
2336
+ includeEntries: params.includeEntries,
2337
+ plan: evidencePlan,
2338
+ ...(params.preserveInventory.length === 0
2339
+ ? {}
2340
+ : { preserveInventory: params.preserveInventory }),
2341
+ root: params.rootDir,
2342
+ sourceFilter: (path) =>
2343
+ vocabularyEvidenceSource(
2344
+ path,
2345
+ evidencePlan.scope,
2346
+ includeCodeComments
2347
+ ),
2348
+ sourceKindForPath: (path) =>
2349
+ vocabularyEvidenceSourceKind(path, evidencePlan),
2350
+ });
2351
+ if (prosePrepared.isErr()) {
2352
+ return prosePrepared;
2353
+ }
2354
+ let prosePreview: TrailsResult<RegradeReport | null, Error>;
2355
+ if (params.prepared !== undefined) {
2356
+ prosePreview = Result.ok(params.prepared.proseReport);
2357
+ } else if (prosePrepared.value === null) {
2358
+ prosePreview = runProse(false);
2359
+ } else {
2360
+ prosePreview = Result.ok(prosePrepared.value.report);
2361
+ }
2062
2362
  if (prosePreview.isErr()) {
2063
2363
  return prosePreview;
2064
2364
  }
2065
2365
  const prosePreviewReport = withoutVocabularySourceFilterSkips(
2066
2366
  prosePreview.value
2067
2367
  );
2068
- const symbolPreview = runSymbols(false);
2368
+ const symbolConfiguration = governedSymbolRegradeConfiguration({
2369
+ plan: params.plan,
2370
+ preserveInventory: params.preserveInventory,
2371
+ });
2372
+ const symbolPrepared =
2373
+ params.prepareIdentity === undefined || symbolConfiguration === null
2374
+ ? Result.ok(null)
2375
+ : prepareRegradeRun({
2376
+ ...symbolConfiguration,
2377
+ identity: params.prepareIdentity,
2378
+ includeEntries: params.includeEntries,
2379
+ root: params.rootDir,
2380
+ });
2381
+ if (symbolPrepared.isErr()) {
2382
+ return symbolPrepared;
2383
+ }
2384
+ // Apply reevaluates symbol work after prose because shared code comments can
2385
+ // change source bytes and offsets. Initial preparation retains the original
2386
+ // symbol source state solely for pre-mutation freshness validation.
2387
+ const symbolPreview =
2388
+ params.prepared !== undefined || symbolPrepared.value === null
2389
+ ? runSymbols(false)
2390
+ : Result.ok(symbolPrepared.value.report);
2069
2391
  if (symbolPreview.isErr()) {
2070
2392
  return symbolPreview;
2071
2393
  }
2394
+ return Result.ok({
2395
+ fileRenamePreflight: fileRenamePreflight.value,
2396
+ prosePrepared: prosePrepared.value,
2397
+ prosePreviewReport,
2398
+ runProse,
2399
+ runSymbols,
2400
+ symbolPrepared: symbolPrepared.value,
2401
+ symbolPreviewReport: symbolPreview.value,
2402
+ });
2403
+ };
2404
+
2405
+ const previewResolvedVocabularyPlan = (
2406
+ params: ResolvedVocabularyPlanParams,
2407
+ execution: VocabularyPlanExecution
2408
+ ): TrailsResult<RegradeReport, Error> => {
2409
+ if (
2410
+ execution.prosePreviewReport?.scanned === 0 &&
2411
+ execution.symbolPreviewReport === null &&
2412
+ execution.fileRenamePreflight === null &&
2413
+ !vocabularyProseEngineApplies(params.plan.scope)
2414
+ ) {
2415
+ return regradeNoEngineForScope();
2416
+ }
2417
+ const report = combineVocabularyReports({
2418
+ fileRenameRun: execution.fileRenamePreflight,
2419
+ plan: params.plan,
2420
+ preserveInventory: params.preserveInventory,
2421
+ proseReport: execution.prosePreviewReport,
2422
+ symbolReport: execution.symbolPreviewReport,
2423
+ });
2424
+ if (
2425
+ report.isOk() &&
2426
+ params.prepareIdentity !== undefined &&
2427
+ params.preparedResult !== undefined
2428
+ ) {
2429
+ params.preparedResult.value = {
2430
+ fileRenamePreflight: execution.fileRenamePreflight,
2431
+ identity: params.prepareIdentity,
2432
+ preserveInventory: params.preserveInventory,
2433
+ prose: execution.prosePrepared,
2434
+ proseReport: execution.prosePreviewReport,
2435
+ symbol: execution.symbolPrepared,
2436
+ };
2437
+ }
2438
+ return report;
2439
+ };
2440
+
2441
+ export const validatePreparedFileRenameSourceState = (params: {
2442
+ readonly includeEntries: RegradeInput['includeEntries'];
2443
+ readonly plan: VocabularyRegradePlan;
2444
+ readonly prepared: FileRenameRegradeRun | null;
2445
+ readonly rootDir: string;
2446
+ }): TrailsResult<void, Error> => {
2447
+ if (params.prepared === null) {
2448
+ return Result.ok();
2449
+ }
2450
+ const current = runPlanFileRenames(params.plan, {
2451
+ apply: false,
2452
+ includeEntries: params.includeEntries,
2453
+ rootDir: params.rootDir,
2454
+ });
2455
+ if (current.isErr()) {
2456
+ return current;
2457
+ }
2458
+ if (current.value === null) {
2459
+ return Result.err(
2460
+ new InternalError('Prepared Regrade file rename set changed.')
2461
+ );
2462
+ }
2463
+ if (current.value.sourceStateHash !== params.prepared.sourceStateHash) {
2464
+ return Result.err(
2465
+ new ValidationError(
2466
+ 'Prepared Regrade file rename source state is stale.',
2467
+ {
2468
+ context: {
2469
+ actual: current.value.sourceStateHash,
2470
+ expected: params.prepared.sourceStateHash,
2471
+ },
2472
+ }
2473
+ )
2474
+ );
2475
+ }
2476
+ return Result.ok();
2477
+ };
2478
+
2479
+ const validatePreparedVocabularyPlanState = (params: {
2480
+ readonly currentIdentity: PreparedRegradeRunIdentity;
2481
+ readonly execution: VocabularyPlanExecution;
2482
+ readonly includeEntries: RegradeInput['includeEntries'];
2483
+ readonly plan: VocabularyRegradePlan;
2484
+ readonly prepared: PreparedVocabularyPlanRun;
2485
+ readonly rootDir: string;
2486
+ }): TrailsResult<void, Error> => {
2487
+ const identity = validatePreparedPlanIdentity(
2488
+ params.prepared.identity,
2489
+ params.currentIdentity
2490
+ );
2491
+ if (identity.isErr()) {
2492
+ return identity;
2493
+ }
2494
+ const fileRenameState = validatePreparedFileRenameSourceState({
2495
+ includeEntries: params.includeEntries,
2496
+ plan: params.plan,
2497
+ prepared: params.execution.fileRenamePreflight,
2498
+ rootDir: params.rootDir,
2499
+ });
2500
+ if (fileRenameState.isErr() || params.prepared.symbol === null) {
2501
+ return fileRenameState;
2502
+ }
2503
+ return validatePreparedRegradeRun(
2504
+ params.prepared.symbol,
2505
+ params.currentIdentity
2506
+ );
2507
+ };
2508
+
2509
+ /**
2510
+ * Prepared vocabulary lifecycle seam for focused conformance tests.
2511
+ *
2512
+ * @internal
2513
+ */
2514
+ export const runResolvedVocabularyPlan = (
2515
+ params: ResolvedVocabularyPlanParams
2516
+ ): TrailsResult<RegradeReport, Error> => {
2517
+ const execution = vocabularyPlanExecution(params);
2518
+ if (execution.isErr()) {
2519
+ return execution;
2520
+ }
2072
2521
  if (!params.apply) {
2073
- if (
2074
- prosePreviewReport?.scanned === 0 &&
2075
- symbolPreview.value === null &&
2076
- fileRenamePreflight.value === null &&
2077
- !vocabularyProseEngineApplies(params.plan.scope)
2078
- ) {
2079
- return regradeNoEngineForScope();
2522
+ return previewResolvedVocabularyPlan(params, execution.value);
2523
+ }
2524
+
2525
+ let { currentIdentity } = params;
2526
+ if (params.prepared !== undefined) {
2527
+ if (currentIdentity === undefined) {
2528
+ return Result.err(
2529
+ new InternalError('Prepared Regrade apply is missing current identity.')
2530
+ );
2080
2531
  }
2081
- return combineVocabularyReports({
2082
- fileRenameRun: fileRenamePreflight.value,
2532
+ const preparedState = validatePreparedVocabularyPlanState({
2533
+ currentIdentity,
2534
+ execution: execution.value,
2535
+ includeEntries: params.includeEntries,
2083
2536
  plan: params.plan,
2084
- preserveInventory: params.preserveInventory,
2085
- proseReport: prosePreviewReport,
2086
- symbolReport: symbolPreview.value,
2537
+ prepared: params.prepared,
2538
+ rootDir: params.rootDir,
2087
2539
  });
2540
+ if (preparedState.isErr()) {
2541
+ return preparedState;
2542
+ }
2088
2543
  }
2089
2544
 
2090
2545
  const snapshots = snapshotRegradeSources({
2091
- reports: [prosePreviewReport, symbolPreview.value],
2546
+ reports: [
2547
+ execution.value.prosePreviewReport,
2548
+ execution.value.symbolPreviewReport,
2549
+ ],
2092
2550
  rootDir: params.rootDir,
2093
2551
  });
2094
2552
  if (snapshots.isErr()) {
2095
2553
  return snapshots;
2096
2554
  }
2097
- const reportResult = runProse(true);
2555
+ let reportResult: TrailsResult<RegradeReport | null, Error>;
2556
+ if (params.prepared?.prose === undefined || params.prepared.prose === null) {
2557
+ reportResult = execution.value.runProse(true);
2558
+ } else {
2559
+ currentIdentity ??= params.prepared.identity;
2560
+ reportResult = applyPreparedVocabularyRegradeRun(
2561
+ params.prepared.prose,
2562
+ currentIdentity
2563
+ );
2564
+ }
2098
2565
  if (reportResult.isErr()) {
2099
2566
  return regradeApplyErrorAfterRollback(reportResult.error, snapshots.value);
2100
2567
  }
2101
- const symbolReportResult = runSymbols(true);
2568
+ const symbolReportResult = execution.value.runSymbols(true);
2102
2569
  if (symbolReportResult.isErr()) {
2103
2570
  return regradeApplyErrorAfterRollback(
2104
2571
  symbolReportResult.error,
@@ -2247,6 +2714,131 @@ const runPlanArtifactDryRun = async (params: {
2247
2714
  });
2248
2715
  };
2249
2716
 
2717
+ interface PreparedClassPlanRun {
2718
+ readonly identity: PreparedRegradeRunIdentity;
2719
+ readonly run: PreparedRegradeRun;
2720
+ }
2721
+
2722
+ type PreparedPlanRun =
2723
+ | { readonly kind: 'class'; readonly prepared: PreparedClassPlanRun }
2724
+ | {
2725
+ readonly kind: 'vocabulary';
2726
+ readonly prepared: PreparedVocabularyPlanRun;
2727
+ };
2728
+
2729
+ const preparePlanArtifactRun = async (params: {
2730
+ readonly artifact: RegradePlanArtifact;
2731
+ readonly includeEntries: RegradePlanReferenceInput['includeEntries'];
2732
+ readonly rootDir: string;
2733
+ }): Promise<
2734
+ TrailsResult<
2735
+ { readonly prepared: PreparedPlanRun; readonly report: RegradeReport },
2736
+ Error
2737
+ >
2738
+ > => {
2739
+ const planBody = params.artifact.plan;
2740
+ if (planBody.kind === 'class') {
2741
+ const classSet = await loadWardenRegradeClasses(params.rootDir);
2742
+ if (classSet.diagnostics.length > 0) {
2743
+ return Result.err(
2744
+ new InternalError('Failed to load Regrade project Warden rules.', {
2745
+ context: {
2746
+ diagnostics: classSet.diagnostics,
2747
+ rootDir: params.rootDir,
2748
+ },
2749
+ })
2750
+ );
2751
+ }
2752
+ const identity = preparedRegradeRunIdentity({
2753
+ artifact: params.artifact,
2754
+ classIds: classSet.classes.map((regradeClass) => regradeClass.id),
2755
+ classes: classSet.classes,
2756
+ includeEntries: params.includeEntries,
2757
+ rootDir: params.rootDir,
2758
+ });
2759
+ if (identity.isErr()) {
2760
+ return identity;
2761
+ }
2762
+ const prepared = prepareRegradeRun({
2763
+ classes: classSet.classes,
2764
+ ...(planBody.scope === undefined
2765
+ ? {}
2766
+ : {
2767
+ collection: {
2768
+ ...(planBody.scope.exclude === undefined
2769
+ ? {}
2770
+ : { exclude: planBody.scope.exclude }),
2771
+ ...(planBody.scope.extensions === undefined
2772
+ ? {}
2773
+ : { extensions: planBody.scope.extensions }),
2774
+ ...(planBody.scope.include === undefined
2775
+ ? {}
2776
+ : { include: planBody.scope.include }),
2777
+ },
2778
+ }),
2779
+ identity: identity.value,
2780
+ includeEntries: params.includeEntries,
2781
+ root: params.rootDir,
2782
+ selection: { classIds: planBody.classIds },
2783
+ });
2784
+ if (prepared.isErr()) {
2785
+ return prepared;
2786
+ }
2787
+ if (prepared.value === null) {
2788
+ return regradeRootNotFound(params.rootDir);
2789
+ }
2790
+ const report = validateRegradeReport(prepared.value.report);
2791
+ if (report.isErr()) {
2792
+ return report;
2793
+ }
2794
+ return Result.ok({
2795
+ prepared: {
2796
+ kind: 'class',
2797
+ prepared: { identity: identity.value, run: prepared.value },
2798
+ },
2799
+ report: report.value,
2800
+ });
2801
+ }
2802
+
2803
+ const preserveResult = await deriveLiveApiPreserveInventory(
2804
+ planBody,
2805
+ params.rootDir
2806
+ );
2807
+ if (preserveResult.isErr()) {
2808
+ return preserveResult;
2809
+ }
2810
+ const identity = preparedRegradeRunIdentity({
2811
+ artifact: params.artifact,
2812
+ includeEntries: params.includeEntries,
2813
+ rootDir: params.rootDir,
2814
+ });
2815
+ if (identity.isErr()) {
2816
+ return identity;
2817
+ }
2818
+ const preparedResult: { value?: PreparedVocabularyPlanRun } = {};
2819
+ const report = runResolvedVocabularyPlan({
2820
+ apply: false,
2821
+ includeEntries: params.includeEntries,
2822
+ plan: planBody,
2823
+ prepareIdentity: identity.value,
2824
+ preparedResult,
2825
+ preserveInventory: preserveResult.value,
2826
+ rootDir: params.rootDir,
2827
+ });
2828
+ if (report.isErr()) {
2829
+ return report;
2830
+ }
2831
+ if (preparedResult.value === undefined) {
2832
+ return Result.err(
2833
+ new InternalError('Regrade vocabulary run was not prepared.')
2834
+ );
2835
+ }
2836
+ return Result.ok({
2837
+ prepared: { kind: 'vocabulary', prepared: preparedResult.value },
2838
+ report: report.value,
2839
+ });
2840
+ };
2841
+
2250
2842
  const runLegacyVocabularyRecordRegrade = (
2251
2843
  input: RegradeInput,
2252
2844
  rootDir: string,
@@ -3054,12 +3646,13 @@ const loadPlanForInput = async (
3054
3646
 
3055
3647
  const reportWithCheckedHistorySummary = (
3056
3648
  report: RegradeReport,
3057
- historyPath: string
3649
+ historyPath: string,
3650
+ schemaVersion: number
3058
3651
  ): RegradeReport => ({
3059
3652
  ...report,
3060
3653
  history: {
3061
3654
  path: historyPath,
3062
- schemaVersion: REGRADE_HISTORY_SCHEMA_VERSION,
3655
+ schemaVersion,
3063
3656
  status: 'checked',
3064
3657
  },
3065
3658
  });
@@ -3089,7 +3682,11 @@ const checkGraduatedRegradeHistory = (
3089
3682
  );
3090
3683
  }
3091
3684
  return validateRegradeReport(
3092
- reportWithCheckedHistorySummary(lastRun.report, artifact.value.path)
3685
+ reportWithCheckedHistorySummary(
3686
+ lastRun.report,
3687
+ artifact.value.path,
3688
+ artifact.value.schemaVersion
3689
+ )
3093
3690
  );
3094
3691
  };
3095
3692
 
@@ -3104,6 +3701,7 @@ const runCheckRegradePlan = async (
3104
3701
  if (historyPath.isOk()) {
3105
3702
  return checkGraduatedRegradeHistory(historyPath.value);
3106
3703
  }
3704
+ return historyPath;
3107
3705
  }
3108
3706
  return planPath;
3109
3707
  }
@@ -3114,24 +3712,17 @@ const runCheckRegradePlan = async (
3114
3712
  const loaded = {
3115
3713
  value: { artifact: artifact.value, path: planPath.value },
3116
3714
  };
3117
- const report = await runPlanArtifactDryRun({
3715
+ const prepared = await preparePlanArtifactRun({
3118
3716
  artifact: loaded.value.artifact,
3119
3717
  includeEntries: input.includeEntries,
3120
3718
  rootDir,
3121
3719
  });
3122
- if (report.isErr()) {
3123
- return report;
3720
+ if (prepared.isErr()) {
3721
+ return prepared;
3124
3722
  }
3125
- const status = planStatusForReport(
3126
- loaded.value.artifact,
3127
- report.value,
3128
- rootDir
3129
- );
3130
- const checked = reportWithPlanSummary(
3131
- report.value,
3132
- loaded.value.artifact,
3133
- status
3134
- );
3723
+ const { report } = prepared.value;
3724
+ const status = planStatusForReport(loaded.value.artifact, report, rootDir);
3725
+ const checked = reportWithPlanSummary(report, loaded.value.artifact, status);
3135
3726
  if (status === 'stale') {
3136
3727
  return Result.err(
3137
3728
  new ValidationError(
@@ -3164,29 +3755,32 @@ const runPreviewRegradePlan = async (
3164
3755
  if (loaded.isErr()) {
3165
3756
  return loaded;
3166
3757
  }
3167
- const report = await runPlanArtifactDryRun({
3758
+ const prepared = await preparePlanArtifactRun({
3168
3759
  artifact: loaded.value.artifact,
3169
3760
  includeEntries: input.includeEntries,
3170
3761
  rootDir,
3171
3762
  });
3172
- if (report.isErr()) {
3173
- return report;
3763
+ if (prepared.isErr()) {
3764
+ return prepared;
3174
3765
  }
3766
+ const { report } = prepared.value;
3175
3767
  return validateRegradeReport(
3176
3768
  reportWithPlanSummary(
3177
- report.value,
3769
+ report,
3178
3770
  loaded.value.artifact,
3179
- planStatusForReport(loaded.value.artifact, report.value, rootDir)
3771
+ planStatusForReport(loaded.value.artifact, report, rootDir)
3180
3772
  )
3181
3773
  );
3182
3774
  };
3183
3775
 
3184
3776
  const writeRegradeHistory = (params: {
3185
3777
  readonly artifact: RegradePlanArtifact;
3778
+ readonly changedFiles: readonly RegradeChangedFileEvidence[];
3186
3779
  readonly completedReport: RegradeReport;
3187
3780
  readonly planPath: string;
3188
3781
  readonly report: RegradeReport;
3189
3782
  readonly rootDir: string;
3783
+ readonly sourceRevision: string;
3190
3784
  }): TrailsResult<RegradeHistorySummary, Error> => {
3191
3785
  const absolutePath = regradeHistoryPathForPlan(
3192
3786
  params.rootDir,
@@ -3207,37 +3801,27 @@ const writeRegradeHistory = (params: {
3207
3801
  }
3208
3802
  const appended = appendRegradeHistoryRun({
3209
3803
  artifact: params.artifact,
3804
+ changedFiles: params.changedFiles,
3210
3805
  completedReport: params.completedReport,
3211
3806
  report: params.report,
3212
3807
  rootDir: params.rootDir,
3808
+ sourceRevision: params.sourceRevision,
3213
3809
  });
3214
3810
  if (appended.isErr()) {
3215
3811
  return appended;
3216
3812
  }
3217
- try {
3218
- // Apply always consumes the active plan, replay included: the plan is a
3219
- // single-use apply intent, and the consolidated history already records
3220
- // the run the replay repeats.
3221
- rmSync(params.planPath, { force: true });
3222
- } catch (error) {
3223
- try {
3224
- if (priorHistoryBytes === undefined) {
3225
- rmSync(absolutePath, { force: true });
3226
- } else {
3227
- writeFileSync(absolutePath, priorHistoryBytes);
3228
- }
3229
- } catch {
3230
- // Best-effort rollback; the surfaced error below preserves the primary failure.
3231
- }
3232
- return Result.err(
3233
- new InternalError('Failed to remove active Regrade plan.', {
3234
- ...(error instanceof Error ? { cause: error } : {}),
3235
- context: {
3236
- history: appended.value.path,
3237
- plan: rootRelativePath(params.rootDir, params.planPath),
3238
- },
3239
- })
3240
- );
3813
+ // Apply always consumes the active plan, replay included: the plan is a
3814
+ // single-use apply intent, and the consolidated history already records
3815
+ // the run the replay repeats.
3816
+ const consumed = consumeActiveRegradePlanAfterHistoryWrite({
3817
+ absoluteHistoryPath: absolutePath,
3818
+ absolutePlanPath: params.planPath,
3819
+ historyPath: appended.value.path,
3820
+ planPath: rootRelativePath(params.rootDir, params.planPath),
3821
+ priorHistoryBytes,
3822
+ });
3823
+ if (consumed.isErr()) {
3824
+ return consumed;
3241
3825
  }
3242
3826
  return appended;
3243
3827
  };
@@ -3260,6 +3844,87 @@ const historyReportForAppliedPlan = (
3260
3844
  }),
3261
3845
  });
3262
3846
 
3847
+ /**
3848
+ * Re-read the active plan after preparation and before mutation.
3849
+ *
3850
+ * @internal
3851
+ */
3852
+ export const reloadPreparedRegradePlan = async (params: {
3853
+ readonly input: RegradeApplyPlanInput;
3854
+ readonly loaded: {
3855
+ readonly artifact: RegradePlanArtifact;
3856
+ readonly path: string;
3857
+ };
3858
+ readonly rootDir: string;
3859
+ }): Promise<TrailsResult<RegradePlanArtifact, Error>> => {
3860
+ const current = await loadPlanForInput(params.input, params.rootDir);
3861
+ if (current.isErr()) {
3862
+ return current;
3863
+ }
3864
+ const unchanged = validatePreparedRegradePlanArtifact({
3865
+ current: current.value.artifact,
3866
+ currentPath: current.value.path,
3867
+ expected: params.loaded.artifact,
3868
+ expectedPath: params.loaded.path,
3869
+ });
3870
+ return unchanged.isErr() ? unchanged : Result.ok(current.value.artifact);
3871
+ };
3872
+
3873
+ const applyPreparedPlanRun = async (params: {
3874
+ readonly artifact: RegradePlanArtifact;
3875
+ readonly includeEntries: RegradeInput['includeEntries'];
3876
+ readonly prepared: PreparedPlanRun;
3877
+ readonly rootDir: string;
3878
+ }): Promise<TrailsResult<RegradeReport, Error>> => {
3879
+ if (params.artifact.plan.kind === 'class') {
3880
+ if (params.prepared.kind !== 'class') {
3881
+ return Result.err(new InternalError('Prepared Regrade kind changed.'));
3882
+ }
3883
+ const classSet = await loadWardenRegradeClasses(params.rootDir);
3884
+ if (classSet.diagnostics.length > 0) {
3885
+ return Result.err(
3886
+ new InternalError('Failed to load Regrade project Warden rules.', {
3887
+ context: {
3888
+ diagnostics: classSet.diagnostics,
3889
+ rootDir: params.rootDir,
3890
+ },
3891
+ })
3892
+ );
3893
+ }
3894
+ const identity = preparedRegradeRunIdentity({
3895
+ artifact: params.artifact,
3896
+ classIds: classSet.classes.map((regradeClass) => regradeClass.id),
3897
+ classes: classSet.classes,
3898
+ includeEntries: params.includeEntries,
3899
+ rootDir: params.rootDir,
3900
+ });
3901
+ return identity.isErr()
3902
+ ? identity
3903
+ : applyPreparedRegradeRun(params.prepared.prepared.run, identity.value);
3904
+ }
3905
+ if (params.prepared.kind !== 'vocabulary') {
3906
+ return Result.err(new InternalError('Prepared Regrade kind changed.'));
3907
+ }
3908
+ const identity = preparedRegradeRunIdentity({
3909
+ artifact: params.artifact,
3910
+ includeEntries: params.includeEntries,
3911
+ rootDir: params.rootDir,
3912
+ });
3913
+ if (identity.isErr()) {
3914
+ return identity;
3915
+ }
3916
+ const { prepared } = params.prepared;
3917
+ return runResolvedVocabularyPlan({
3918
+ apply: true,
3919
+ currentIdentity: identity.value,
3920
+ includeEntries: params.includeEntries,
3921
+ plan: params.artifact.plan,
3922
+ prepared,
3923
+ preserveInventory: prepared.preserveInventory,
3924
+ rootDir: params.rootDir,
3925
+ });
3926
+ };
3927
+
3263
3928
  const runApplyRegradePlan = async (
3264
3929
  input: RegradeApplyPlanInput,
3265
3930
  rootDir: string,
@@ -3275,17 +3940,18 @@ const runApplyRegradePlan = async (
3275
3940
  if (governedPlanValidation.isErr()) {
3276
3941
  return governedPlanValidation;
3277
3942
  }
3278
- const dryRunReport = await runPlanArtifactDryRun({
3943
+ const preparedRun = await preparePlanArtifactRun({
3279
3944
  artifact: loaded.value.artifact,
3280
3945
  includeEntries: input.includeEntries,
3281
3946
  rootDir,
3282
3947
  });
3283
- if (dryRunReport.isErr()) {
3284
- return dryRunReport;
3948
+ if (preparedRun.isErr()) {
3949
+ return preparedRun;
3285
3950
  }
3951
+ const dryRunReport = preparedRun.value.report;
3286
3952
  const status = planStatusForReport(
3287
3953
  loaded.value.artifact,
3288
- dryRunReport.value,
3954
+ dryRunReport,
3289
3955
  rootDir
3290
3956
  );
3291
3957
  if (status === 'stale') {
@@ -3300,62 +3966,99 @@ const runApplyRegradePlan = async (
3300
3966
  }
3301
3967
  if (shouldDryRun) {
3302
3968
  return validateRegradeReport(
3303
- reportWithPlanSummary(dryRunReport.value, loaded.value.artifact, status)
3969
+ reportWithPlanSummary(dryRunReport, loaded.value.artifact, status)
3304
3970
  );
3305
3971
  }
3306
3972
 
3307
- const planBody = loaded.value.artifact.plan;
3308
- let applied: TrailsResult<RegradeReport, Error>;
3309
- if (planBody.kind === 'class') {
3310
- applied = await runClassPlanRegradeRun({
3311
- apply: true,
3312
- includeEntries: input.includeEntries,
3313
- plan: planBody,
3314
- rootDir,
3315
- });
3316
- } else {
3317
- const preserveResult = await deriveLiveApiPreserveInventory(
3318
- planBody,
3319
- rootDir
3320
- );
3321
- if (preserveResult.isErr()) {
3322
- return preserveResult;
3323
- }
3324
- applied = runResolvedVocabularyPlan({
3325
- apply: true,
3326
- includeEntries: input.includeEntries,
3327
- plan: planBody,
3328
- preserveInventory: preserveResult.value,
3329
- rootDir,
3330
- });
3973
+ const currentPlan = await reloadPreparedRegradePlan({
3974
+ input,
3975
+ loaded: loaded.value,
3976
+ rootDir,
3977
+ });
3978
+ if (currentPlan.isErr()) {
3979
+ return currentPlan;
3980
+ }
3981
+ const activeArtifact = currentPlan.value;
3982
+
3983
+ // Receipt persistence is mandatory for apply. Resolve its Git-owned source
3984
+ // identity before mutating any source so failure leaves the tree untouched.
3985
+ const receiptPlan = validateRegradeReceiptPlan(activeArtifact);
3986
+ if (receiptPlan.isErr()) {
3987
+ return receiptPlan;
3988
+ }
3989
+ const sourceRevision = resolveRegradeSourceRevision(rootDir);
3990
+ if (sourceRevision.isErr()) {
3991
+ return sourceRevision;
3331
3992
  }
3993
+
3994
+ const beforeChangedFiles = captureRegradeChangedFilesBefore({
3995
+ artifact: activeArtifact,
3996
+ report: dryRunReport,
3997
+ rootDir,
3998
+ });
3999
+ if (beforeChangedFiles.isErr()) {
4000
+ return beforeChangedFiles;
4001
+ }
4002
+
4003
+ const planBody = activeArtifact.plan;
4004
+ const sourceSnapshots = snapshotRegradeSources({
4005
+ optionalPaths:
4006
+ planBody.kind === 'vocabulary'
4007
+ ? (planBody.fileRenames ?? []).flatMap((rename) => [
4008
+ rename.from,
4009
+ rename.to,
4010
+ ])
4011
+ : [],
4012
+ reports: [dryRunReport],
4013
+ rootDir,
4014
+ });
4015
+ if (sourceSnapshots.isErr()) {
4016
+ return sourceSnapshots;
4017
+ }
4018
+ const rollbackApplyError = (error: Error): TrailsResult<never, Error> =>
4019
+ regradeApplyErrorAfterRollback(error, sourceSnapshots.value);
4020
+ const applied = await applyPreparedPlanRun({
4021
+ artifact: activeArtifact,
4022
+ includeEntries: input.includeEntries,
4023
+ prepared: preparedRun.value.prepared,
4024
+ rootDir,
4025
+ });
3332
4026
  if (applied.isErr()) {
3333
- return applied;
4027
+ return rollbackApplyError(applied.error);
4028
+ }
4029
+ const changedFiles = completeRegradeChangedFiles({
4030
+ before: beforeChangedFiles.value,
4031
+ rootDir,
4032
+ });
4033
+ if (changedFiles.isErr()) {
4034
+ return rollbackApplyError(changedFiles.error);
3334
4035
  }
3335
4036
  const completionReport = await runPlanArtifactDryRun({
3336
- artifact: loaded.value.artifact,
4037
+ artifact: activeArtifact,
3337
4038
  includeEntries: input.includeEntries,
3338
4039
  rootDir,
3339
4040
  });
3340
4041
  if (completionReport.isErr()) {
3341
- return completionReport;
4042
+ return rollbackApplyError(completionReport.error);
3342
4043
  }
3343
4044
  // Keep the pre-apply occurrence evidence that explains what this run changed,
3344
4045
  // while carrying the completed counters and a separate post-apply source
3345
4046
  // stamp so a later no-op apply can still be recognized as a replay.
3346
4047
  const history = writeRegradeHistory({
3347
- artifact: loaded.value.artifact,
4048
+ artifact: activeArtifact,
4049
+ changedFiles: changedFiles.value,
3348
4050
  completedReport: completionReport.value,
3349
4051
  planPath: loaded.value.path,
3350
- report: historyReportForAppliedPlan(dryRunReport.value, applied.value),
4052
+ report: historyReportForAppliedPlan(dryRunReport, applied.value),
3351
4053
  rootDir,
4054
+ sourceRevision: sourceRevision.value,
3352
4055
  });
3353
4056
  if (history.isErr()) {
3354
- return history;
4057
+ return rollbackApplyError(history.error);
3355
4058
  }
3356
4059
  return validateRegradeReport(
3357
4060
  reportWithHistorySummary(
3358
- reportWithPlanSummary(applied.value, loaded.value.artifact, status),
4061
+ reportWithPlanSummary(applied.value, activeArtifact, status),
3359
4062
  history.value
3360
4063
  )
3361
4064
  );
@@ -3542,30 +4245,37 @@ export const planRegradeTrail = trail('plan.regrade', {
3542
4245
  cli: { path: ['regrade', 'plan'] },
3543
4246
  description: 'Write or update a reviewed Regrade plan',
3544
4247
  implementation: async (input, ctx) => {
3545
- const rootDirResult = resolveTrailRootDir(input.rootDir, ctx.cwd);
4248
+ const lifecycle = new RegradeLifecycleTracker({ progress: ctx.progress });
4249
+ const rootDirResult = await lifecycle.run('resolve-root', () =>
4250
+ resolveTrailRootDir(input.rootDir, ctx.cwd)
4251
+ );
3546
4252
  if (rootDirResult.isErr()) {
3547
- return rootDirResult;
4253
+ return Result.err(rootDirResult.error);
3548
4254
  }
3549
4255
 
3550
- const configResult = await loadRegradeConfig({
3551
- ...(input.configPath === undefined
3552
- ? {}
3553
- : { configPath: input.configPath }),
3554
- env: ctx.env,
3555
- rootDir: rootDirResult.value,
3556
- });
4256
+ const configResult = await lifecycle.run('load-config', () =>
4257
+ loadRegradeConfig({
4258
+ ...(input.configPath === undefined
4259
+ ? {}
4260
+ : { configPath: input.configPath }),
4261
+ env: ctx.env,
4262
+ rootDir: rootDirResult.value,
4263
+ })
4264
+ );
3557
4265
  if (configResult.isErr()) {
3558
- return configResult;
4266
+ return Result.err(configResult.error);
3559
4267
  }
3560
4268
 
3561
- const result = await runPlanRegrade(
3562
- input,
3563
- rootDirResult.value,
3564
- configResult.value.config?.scope,
3565
- ctx.dryRun === true
4269
+ const result = await lifecycle.run('derive-plan', () =>
4270
+ runPlanRegrade(
4271
+ input,
4272
+ rootDirResult.value,
4273
+ configResult.value.config?.scope,
4274
+ ctx.dryRun === true
4275
+ )
3566
4276
  );
3567
4277
  if (result.isErr()) {
3568
- return result;
4278
+ return Result.err(result.error);
3569
4279
  }
3570
4280
  const output = regradePlanArtifactSchema.safeParse(result.value);
3571
4281
  if (!output.success) {
@@ -3575,11 +4285,11 @@ export const planRegradeTrail = trail('plan.regrade', {
3575
4285
  })
3576
4286
  );
3577
4287
  }
3578
- return Result.ok(output.data);
4288
+ return Result.ok({ ...output.data, lifecycle: lifecycle.summary() });
3579
4289
  },
3580
4290
  input: regradePlanInputSchema,
3581
4291
  intent: 'write',
3582
- output: regradePlanArtifactSchema,
4292
+ output: regradePlanCommandOutputSchema,
3583
4293
  permit: 'public',
3584
4294
  });
3585
4295
 
@@ -3650,13 +4360,18 @@ export const checkRegradeTrail = trail('check.regrade', {
3650
4360
  cli: { path: ['regrade', 'check'] },
3651
4361
  description: 'Check a saved Regrade plan gate without writing source',
3652
4362
  implementation: async (input, ctx) => {
3653
- const rootDirResult = resolveTrailRootDir(input.rootDir, ctx.cwd);
4363
+ const lifecycle = new RegradeLifecycleTracker({ progress: ctx.progress });
4364
+ const rootDirResult = await lifecycle.run('resolve-root', () =>
4365
+ resolveTrailRootDir(input.rootDir, ctx.cwd)
4366
+ );
3654
4367
  if (rootDirResult.isErr()) {
3655
- return rootDirResult;
4368
+ return Result.err(rootDirResult.error);
3656
4369
  }
3657
- const result = await runCheckRegradePlan(input, rootDirResult.value);
4370
+ const result = await lifecycle.run('check-plan', () =>
4371
+ runCheckRegradePlan(input, rootDirResult.value)
4372
+ );
3658
4373
  if (result.isErr()) {
3659
- return result;
4374
+ return Result.err(result.error);
3660
4375
  }
3661
4376
  const checked = {
3662
4377
  ...result.value,
@@ -3668,6 +4383,7 @@ export const checkRegradeTrail = trail('check.regrade', {
3668
4383
  '',
3669
4384
  status: 'passed' as const,
3670
4385
  },
4386
+ lifecycle: lifecycle.summary(),
3671
4387
  };
3672
4388
  const output = validateOutput(regradeCheckOutputSchema, checked);
3673
4389
  if (output.isErr()) {
@@ -3685,15 +4401,23 @@ export const previewRegradeTrail = trail('preview.regrade', {
3685
4401
  cli: { path: ['regrade', 'preview'] },
3686
4402
  description: 'Preview a saved Regrade plan without writing source',
3687
4403
  implementation: async (input, ctx) => {
3688
- const rootDirResult = resolveTrailRootDir(input.rootDir, ctx.cwd);
4404
+ const lifecycle = new RegradeLifecycleTracker({ progress: ctx.progress });
4405
+ const rootDirResult = await lifecycle.run('resolve-root', () =>
4406
+ resolveTrailRootDir(input.rootDir, ctx.cwd)
4407
+ );
3689
4408
  if (rootDirResult.isErr()) {
3690
- return rootDirResult;
4409
+ return Result.err(rootDirResult.error);
3691
4410
  }
3692
- const result = await runPreviewRegradePlan(input, rootDirResult.value);
4411
+ const result = await lifecycle.run('preview-plan', () =>
4412
+ runPreviewRegradePlan(input, rootDirResult.value)
4413
+ );
3693
4414
  if (result.isErr()) {
3694
- return result;
4415
+ return Result.err(result.error);
3695
4416
  }
3696
- const output = validateOutput(regradeReportOutput, result.value);
4417
+ const output = validateOutput(regradeLifecycleReportOutputSchema, {
4418
+ ...result.value,
4419
+ lifecycle: lifecycle.summary(),
4420
+ });
3697
4421
  if (output.isErr()) {
3698
4422
  return Result.err(output.error);
3699
4423
  }
@@ -3701,7 +4425,7 @@ export const previewRegradeTrail = trail('preview.regrade', {
3701
4425
  },
3702
4426
  input: regradePlanReferenceInputSchema,
3703
4427
  intent: 'read',
3704
- output: regradeReportOutput,
4428
+ output: regradeLifecycleReportOutputSchema,
3705
4429
  permit: 'public',
3706
4430
  });
3707
4431
 
@@ -3709,19 +4433,23 @@ export const applyRegradeTrail = trail('apply.regrade', {
3709
4433
  cli: { path: ['regrade', 'apply'] },
3710
4434
  description: 'Apply a saved Regrade plan and move it to history',
3711
4435
  implementation: async (input, ctx) => {
3712
- const rootDirResult = resolveTrailRootDir(input.rootDir, ctx.cwd);
4436
+ const lifecycle = new RegradeLifecycleTracker({ progress: ctx.progress });
4437
+ const rootDirResult = await lifecycle.run('resolve-root', () =>
4438
+ resolveTrailRootDir(input.rootDir, ctx.cwd)
4439
+ );
3713
4440
  if (rootDirResult.isErr()) {
3714
- return rootDirResult;
4441
+ return Result.err(rootDirResult.error);
3715
4442
  }
3716
- const result = await runApplyRegradePlan(
3717
- input,
3718
- rootDirResult.value,
3719
- ctx.dryRun === true
4443
+ const result = await lifecycle.run('apply-plan', () =>
4444
+ runApplyRegradePlan(input, rootDirResult.value, ctx.dryRun === true)
3720
4445
  );
3721
4446
  if (result.isErr()) {
3722
- return result;
4447
+ return Result.err(result.error);
3723
4448
  }
3724
- const output = validateOutput(regradeReportOutput, result.value);
4449
+ const output = validateOutput(regradeLifecycleReportOutputSchema, {
4450
+ ...result.value,
4451
+ lifecycle: lifecycle.summary(),
4452
+ });
3725
4453
  if (output.isErr()) {
3726
4454
  return Result.err(output.error);
3727
4455
  }
@@ -3729,7 +4457,7 @@ export const applyRegradeTrail = trail('apply.regrade', {
3729
4457
  },
3730
4458
  input: regradeApplyPlanInputSchema,
3731
4459
  intent: 'write',
3732
- output: regradeReportOutput,
4460
+ output: regradeLifecycleReportOutputSchema,
3733
4461
  permit: 'public',
3734
4462
  });
3735
4463
 
@@ -3739,17 +4467,18 @@ export const adjustRegradeTrail = trail('adjust.regrade', {
3739
4467
  description:
3740
4468
  'Pull a graduated Regrade transition back to an active plan for adjustment',
3741
4469
  implementation: async (input, ctx) => {
3742
- const rootDirResult = resolveTrailRootDir(input.rootDir, ctx.cwd);
4470
+ const lifecycle = new RegradeLifecycleTracker({ progress: ctx.progress });
4471
+ const rootDirResult = await lifecycle.run('resolve-root', () =>
4472
+ resolveTrailRootDir(input.rootDir, ctx.cwd)
4473
+ );
3743
4474
  if (rootDirResult.isErr()) {
3744
- return rootDirResult;
4475
+ return Result.err(rootDirResult.error);
3745
4476
  }
3746
- const result = await runAdjustRegrade(
3747
- input,
3748
- rootDirResult.value,
3749
- ctx.dryRun === true
4477
+ const result = await lifecycle.run('adjust-plan', () =>
4478
+ runAdjustRegrade(input, rootDirResult.value, ctx.dryRun === true)
3750
4479
  );
3751
4480
  if (result.isErr()) {
3752
- return result;
4481
+ return Result.err(result.error);
3753
4482
  }
3754
4483
  const output = regradePlanArtifactSchema.safeParse(result.value);
3755
4484
  if (!output.success) {
@@ -3759,10 +4488,10 @@ export const adjustRegradeTrail = trail('adjust.regrade', {
3759
4488
  })
3760
4489
  );
3761
4490
  }
3762
- return Result.ok(output.data);
4491
+ return Result.ok({ ...output.data, lifecycle: lifecycle.summary() });
3763
4492
  },
3764
4493
  input: regradeAdjustInputSchema,
3765
4494
  intent: 'write',
3766
- output: regradePlanArtifactSchema,
4495
+ output: regradePlanCommandOutputSchema,
3767
4496
  permit: 'public',
3768
4497
  });