@ontrails/trails 1.0.0-beta.42 → 1.0.0-beta.43

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.
@@ -15,19 +15,21 @@ import {
15
15
  import type { PathScope, Result as TrailsResult } from '@ontrails/core';
16
16
  import {
17
17
  createGovernedAstIdentifierRenameClasses,
18
- listVocabularyRegradePlansFromRegistry,
19
18
  loadWardenRegradeClasses,
20
19
  readVocabularyTransitionRecord,
21
20
  regradeReportOutput,
21
+ runFileRenameRegrade,
22
22
  runRegrade,
23
23
  runVocabularyRegrade,
24
24
  transitionRecordReportWithSummary,
25
25
  vocabularyRegradeTransitionForInput,
26
26
  vocabularyDispositionValues,
27
27
  vocabularyRegradePlanSchema,
28
+ vocabularyRegradePlanForInput,
28
29
  writeVocabularyTransitionRecord,
29
30
  } from '@ontrails/regrade';
30
31
  import type {
32
+ FileRenameRegradeRun,
31
33
  RegradeApplySummary,
32
34
  RegradeReport,
33
35
  RegradeReportEntry,
@@ -37,6 +39,7 @@ import type {
37
39
  VocabularyRegradePlan,
38
40
  VocabularyPreserveInventoryEntry,
39
41
  } from '@ontrails/regrade';
42
+ import { listGovernedVocabularyTransitions } from '@ontrails/warden';
40
43
  import { execFileSync } from 'node:child_process';
41
44
  import {
42
45
  existsSync,
@@ -47,9 +50,14 @@ import {
47
50
  writeFileSync,
48
51
  } from 'node:fs';
49
52
  import type { Dirent } from 'node:fs';
50
- import { basename, dirname, isAbsolute, join } from 'node:path';
53
+ import { basename, dirname, extname, isAbsolute, join, posix } from 'node:path';
51
54
  import { z } from 'zod';
52
55
 
56
+ import {
57
+ auditRegradeHistory,
58
+ regradeAuditInputSchema,
59
+ regradeAuditOutputSchema,
60
+ } from '../regrade/audit.js';
53
61
  import { loadRegradeConfig } from '../regrade/config.js';
54
62
  import {
55
63
  REGRADE_HISTORY_SCHEMA_VERSION,
@@ -57,16 +65,24 @@ import {
57
65
  readRegradeHistoryArtifact,
58
66
  regradeHistoryPathForPlan,
59
67
  resolveRegradeHistoryPath,
68
+ validateGovernedRegradePlan,
60
69
  verifyRegradeHistoryRuns,
61
70
  } from '../regrade/history.js';
62
71
  import type { RegradeHistorySummary } from '../regrade/history.js';
63
72
  import { deriveLiveApiPreserveInventory } from '../regrade/live-api-preserve.js';
73
+ import { deriveRegradePlanDerivation } from '../regrade/plan-derivation.js';
74
+ import {
75
+ regradeApplyErrorAfterRollback,
76
+ snapshotRegradeSources,
77
+ } from '../regrade/source-transaction.js';
64
78
  import {
65
79
  REGRADE_PLAN_SCHEMA_VERSION,
80
+ canonicalJsonStringify,
81
+ currentRegradeSourceHashMatches,
82
+ isGeneratedRegradeArtifactPath,
66
83
  regradePlanArtifactSchema,
67
84
  regradePlanPathForPlan,
68
85
  regradeSourceHash,
69
- regradeSourceHashMatches,
70
86
  rootRelativePath,
71
87
  } from '../regrade/plan-artifact.js';
72
88
  import type {
@@ -88,6 +104,21 @@ const regradePathScopeInputSchema = pathScopeSchema.extend({
88
104
  include: pathScopeSchema.shape.include.describe(
89
105
  'Root-relative path patterns to include in vocabulary regrade mode'
90
106
  ),
107
+ policyClassified: z
108
+ .array(
109
+ z.object({
110
+ disposition: z.enum(vocabularyDispositionValues),
111
+ expectMatches: z.boolean().optional(),
112
+ paths: z.array(z.string().min(1)).min(1),
113
+ reason: z.string().min(1),
114
+ })
115
+ )
116
+ .optional()
117
+ .describe('Protected paths scanned and counted without default rewrites'),
118
+ teachingSurfaces: z
119
+ .array(z.string().min(1))
120
+ .optional()
121
+ .describe('Expected current teaching-surface path patterns'),
91
122
  });
92
123
 
93
124
  const regradePreserveRuleInputSchema = z.object({
@@ -112,6 +143,11 @@ const regradePreserveInputSchema = z.union([
112
143
  regradePreserveRuleInputSchema,
113
144
  ]);
114
145
 
146
+ const regradeFileRenameInputSchema = z.object({
147
+ from: z.string().min(1).describe('Root-relative source file path'),
148
+ to: z.string().min(1).describe('Root-relative target file path'),
149
+ });
150
+
115
151
  const regradeInputSchema = regradePathScopeInputSchema.extend({
116
152
  apply: z
117
153
  .boolean()
@@ -131,6 +167,10 @@ const regradeInputSchema = regradePathScopeInputSchema.extend({
131
167
  .string()
132
168
  .optional()
133
169
  .describe('Path to a Trails config file with regrade defaults'),
170
+ fileRenames: z
171
+ .array(regradeFileRenameInputSchema)
172
+ .optional()
173
+ .describe('Governed file moves with references derived from scope'),
134
174
  from: z
135
175
  .string()
136
176
  .min(1)
@@ -227,6 +267,10 @@ const regradePlanInputSchema = regradePathScopeInputSchema.extend({
227
267
  .boolean()
228
268
  .default(false)
229
269
  .describe('Stage wide-net review candidates in the saved plan'),
270
+ fileRenames: z
271
+ .array(regradeFileRenameInputSchema)
272
+ .optional()
273
+ .describe('Governed file moves with references derived from scope'),
230
274
  fresh: z
231
275
  .boolean()
232
276
  .default(false)
@@ -297,7 +341,7 @@ const regradeAdjustInputSchema = z.object({
297
341
  transition: z
298
342
  .string()
299
343
  .min(1)
300
- .describe('Graduated transition name, e.g. facet-to-trailhead'),
344
+ .describe('Graduated transition name, e.g. <transition-name>'),
301
345
  });
302
346
 
303
347
  type RegradePlanInput = z.output<typeof regradePlanInputSchema>;
@@ -308,6 +352,7 @@ type RegradeApplyPlanInput = z.output<typeof regradeApplyPlanInputSchema>;
308
352
  type RegradeAdjustInput = z.output<typeof regradeAdjustInputSchema>;
309
353
 
310
354
  const hasVocabularyInput = (input: RegradeInput) =>
355
+ input.fileRenames !== undefined ||
311
356
  input.from !== undefined ||
312
357
  input.check ||
313
358
  input.include !== undefined ||
@@ -315,6 +360,8 @@ const hasVocabularyInput = (input: RegradeInput) =>
315
360
  input.overrides !== undefined ||
316
361
  input.planRecord !== undefined ||
317
362
  input.preserve !== undefined ||
363
+ input.policyClassified !== undefined ||
364
+ input.teachingSurfaces !== undefined ||
318
365
  input.to !== undefined;
319
366
 
320
367
  const classModeCollection = (
@@ -376,6 +423,15 @@ const vocabularyProseExtensions: readonly string[] = [
376
423
  '.txt',
377
424
  ] as const;
378
425
 
426
+ const vocabularyEvidenceExtensions: readonly string[] = [
427
+ ...symbolSourceExtensions,
428
+ ...vocabularyProseExtensions,
429
+ '.json',
430
+ '.jsonc',
431
+ '.yaml',
432
+ '.yml',
433
+ ] as const;
434
+
379
435
  const normalizeExtension = (extension: string): string =>
380
436
  extension === '' || extension.startsWith('.') ? extension : `.${extension}`;
381
437
 
@@ -457,6 +513,14 @@ const symbolOccurrenceIsPreserved = (
457
513
  preserveRuleMatchesSymbolOccurrence(rule, occurrence)
458
514
  ) ?? false;
459
515
 
516
+ const symbolOccurrenceIsPolicyClassified = (
517
+ scope: VocabularyRegradePlan['scope'] | undefined,
518
+ path: string
519
+ ): boolean =>
520
+ scope?.policyClassified?.some((policy) =>
521
+ matchesAnyPathGlob(path, policy.paths)
522
+ ) ?? false;
523
+
460
524
  const vocabularyScopeFromConfig = (
461
525
  scope: RegradeConfigScope | undefined
462
526
  ): VocabularyRegradePlan['scope'] | undefined =>
@@ -492,9 +556,9 @@ const vocabularyPreserveFromInput = (
492
556
  const vocabularyRegistryPlanForInput = (
493
557
  input: RegradeInput
494
558
  ): VocabularyRegradePlan | undefined =>
495
- listVocabularyRegradePlansFromRegistry().find(
496
- (plan) => plan.from === input.from && plan.to === input.to
497
- );
559
+ input.from === undefined || input.to === undefined
560
+ ? undefined
561
+ : (vocabularyRegradePlanForInput(input.from, input.to) ?? undefined);
498
562
 
499
563
  const uniqueSorted = (values: readonly string[]): readonly string[] =>
500
564
  [...new Set(values)].toSorted((left, right) => left.localeCompare(right));
@@ -511,10 +575,22 @@ const mergeScopeList = (
511
575
  return merged.length === 0 ? undefined : merged;
512
576
  };
513
577
 
578
+ const scopePathsOverlap = (left: string, right: string): boolean =>
579
+ left === right ||
580
+ matchesAnyPathGlob(left, [right]) ||
581
+ matchesAnyPathGlob(right, [left]);
582
+
514
583
  const mergeVocabularyScope = (
515
584
  registryScope: VocabularyRegradePlan['scope'] | undefined,
516
585
  configScope: VocabularyRegradePlan['scope'] | undefined,
517
- input: Pick<RegradeInput, 'exclude' | 'extensions' | 'include'>
586
+ input: Pick<
587
+ RegradeInput,
588
+ | 'exclude'
589
+ | 'extensions'
590
+ | 'include'
591
+ | 'policyClassified'
592
+ | 'teachingSurfaces'
593
+ >
518
594
  ): VocabularyRegradePlan['scope'] | undefined => {
519
595
  const callerExclude = input.exclude ?? configScope?.exclude;
520
596
  const callerInclude = input.include ?? configScope?.include;
@@ -522,20 +598,37 @@ const mergeVocabularyScope = (
522
598
  input.extensions ?? configScope?.extensions ?? registryScope?.extensions;
523
599
  const exclude = mergeScopeList(registryScope?.exclude, callerExclude);
524
600
  const include = mergeScopeList(registryScope?.include, callerInclude);
601
+ const policyClassified = [
602
+ ...(registryScope?.policyClassified ?? [])
603
+ .map((policy) => ({
604
+ ...policy,
605
+ paths: policy.paths.filter(
606
+ (path) =>
607
+ !callerExclude?.some((excludedPath) =>
608
+ scopePathsOverlap(path, excludedPath)
609
+ )
610
+ ),
611
+ }))
612
+ .filter((policy) => policy.paths.length > 0),
613
+ ...(input.policyClassified ?? []),
614
+ ];
615
+ const teachingSurfaces = mergeScopeList(
616
+ registryScope?.teachingSurfaces,
617
+ input.teachingSurfaces
618
+ );
525
619
 
526
- if (
527
- exclude === undefined &&
528
- extensions === undefined &&
529
- include === undefined
530
- ) {
531
- return undefined;
532
- }
533
-
534
- return {
535
- ...(exclude === undefined ? {} : { exclude }),
536
- ...(extensions === undefined ? {} : { extensions }),
537
- ...(include === undefined ? {} : { include }),
620
+ const fields = {
621
+ exclude,
622
+ extensions,
623
+ include,
624
+ policyClassified:
625
+ policyClassified.length === 0 ? undefined : policyClassified,
626
+ teachingSurfaces,
538
627
  };
628
+ const scope = Object.fromEntries(
629
+ Object.entries(fields).filter(([, value]) => value !== undefined)
630
+ ) as NonNullable<VocabularyRegradePlan['scope']>;
631
+ return Object.keys(scope).length === 0 ? undefined : scope;
539
632
  };
540
633
 
541
634
  const mergeNumericRecords = (
@@ -723,11 +816,16 @@ const transitionRunReportForRegradeReport = (
723
816
  reasons,
724
817
  remaining: open,
725
818
  remainingByDisposition,
726
- status: open === 0 ? 'green' : 'open',
819
+ status: open === 0 && reasons.length === 0 ? 'green' : 'open',
727
820
  },
728
821
  modified,
729
822
  open,
823
+ scopeTiers: {
824
+ 'in-scope': report.rewritten + report.review,
825
+ 'policy-classified': 0,
826
+ },
730
827
  skipped: report.skipped,
828
+ teachingSurfaces: { expected: [], missing: [], touched: [] },
731
829
  };
732
830
  };
733
831
 
@@ -760,11 +858,20 @@ const mergeTransitionRunReportWithSymbol = (
760
858
  reasons,
761
859
  remaining: open,
762
860
  remainingByDisposition,
763
- status: open === 0 ? 'green' : 'open',
861
+ status: open === 0 && reasons.length === 0 ? 'green' : 'open',
764
862
  },
765
863
  modified,
766
864
  open,
865
+ scopeTiers: {
866
+ 'in-scope':
867
+ vocabularyReport.scopeTiers['in-scope'] +
868
+ symbolRunReport.scopeTiers['in-scope'],
869
+ 'policy-classified':
870
+ vocabularyReport.scopeTiers['policy-classified'] +
871
+ symbolRunReport.scopeTiers['policy-classified'],
872
+ },
767
873
  skipped: vocabularyReport.skipped + symbolRunReport.skipped,
874
+ teachingSurfaces: vocabularyReport.teachingSurfaces,
768
875
  };
769
876
  };
770
877
 
@@ -877,18 +984,18 @@ const vocabularySymbolCollection = (
877
984
  };
878
985
  };
879
986
 
880
- const vocabularyProseScope = (
987
+ const vocabularyEvidenceScope = (
881
988
  scope: VocabularyRegradePlan['scope'] | undefined
882
989
  ): NonNullable<VocabularyRegradePlan['scope']> | null => {
883
990
  const explicitExtensions = scope?.extensions !== undefined;
884
991
  const extensions =
885
992
  scope?.extensions === undefined
886
- ? vocabularyProseExtensions
993
+ ? vocabularyEvidenceExtensions
887
994
  : uniqueSorted(
888
995
  scope.extensions
889
996
  .map(normalizeExtension)
890
997
  .filter((extension) =>
891
- vocabularyProseExtensions.includes(extension)
998
+ vocabularyEvidenceExtensions.includes(extension)
892
999
  )
893
1000
  );
894
1001
 
@@ -903,13 +1010,19 @@ const vocabularyProseScope = (
903
1010
  ? {}
904
1011
  : { ignoredDirectories: scope.ignoredDirectories }),
905
1012
  ...(scope?.include === undefined ? {} : { include: scope.include }),
1013
+ ...(scope?.policyClassified === undefined
1014
+ ? {}
1015
+ : { policyClassified: scope.policyClassified }),
1016
+ ...(scope?.teachingSurfaces === undefined
1017
+ ? {}
1018
+ : { teachingSurfaces: scope.teachingSurfaces }),
906
1019
  };
907
1020
  };
908
1021
 
909
- const vocabularyProsePlan = (
1022
+ const vocabularyEvidencePlan = (
910
1023
  plan: VocabularyRegradePlan
911
1024
  ): VocabularyRegradePlan | null => {
912
- const scope = vocabularyProseScope(plan.scope);
1025
+ const scope = vocabularyEvidenceScope(plan.scope);
913
1026
  if (scope === null) {
914
1027
  return null;
915
1028
  }
@@ -917,6 +1030,65 @@ const vocabularyProsePlan = (
917
1030
  return { ...plan, scope };
918
1031
  };
919
1032
 
1033
+ const withoutNotSelectedSourceCount = (
1034
+ counts: Readonly<Record<string, number>>
1035
+ ): Readonly<Record<string, number>> =>
1036
+ Object.fromEntries(
1037
+ Object.entries(counts).filter(
1038
+ ([reason]) => reason !== 'not-selected-source'
1039
+ )
1040
+ );
1041
+
1042
+ const withoutVocabularySourceFilterSkips = (
1043
+ report: RegradeReport | null
1044
+ ): RegradeReport | null => {
1045
+ const rejected = report?.skipsByReason['not-selected-source'] ?? 0;
1046
+ if (report === null || rejected === 0) {
1047
+ return report;
1048
+ }
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
1070
+ ),
1071
+ },
1072
+ skipped: Math.max(0, report.skipped - rejected),
1073
+ skipsByReason: withoutNotSelectedSourceCount(report.skipsByReason),
1074
+ };
1075
+ };
1076
+
1077
+ const vocabularyEvidenceSource = (
1078
+ path: string,
1079
+ scope: VocabularyRegradePlan['scope'] | undefined
1080
+ ): boolean =>
1081
+ vocabularyProseExtensions.includes(extname(path)) ||
1082
+ symbolOccurrenceIsPolicyClassified(scope, path);
1083
+
1084
+ const vocabularyProseEngineApplies = (
1085
+ scope: VocabularyRegradePlan['scope'] | undefined
1086
+ ): boolean =>
1087
+ scope?.extensions === undefined ||
1088
+ scope.extensions.some((extension) =>
1089
+ vocabularyProseExtensions.includes(normalizeExtension(extension))
1090
+ );
1091
+
920
1092
  const mergeVocabularyOverrides = (
921
1093
  registryPlan: VocabularyRegradePlan | undefined,
922
1094
  input: z.output<typeof regradeInputSchema>
@@ -946,9 +1118,67 @@ const vocabularyIntentForInput = (
946
1118
  return registryPlan?.intent;
947
1119
  };
948
1120
 
1121
+ const classifiedOverrideError = (
1122
+ input: RegradeInput & { readonly from: string; readonly to: string }
1123
+ ): ValidationError | undefined => {
1124
+ const transition = vocabularyRegradeTransitionForInput(input.from, input.to);
1125
+ return transition?.target.kind === 'classified' &&
1126
+ input.overrides !== undefined
1127
+ ? new ValidationError(
1128
+ 'Classified governed vocabulary transitions are review-only and cannot accept rewrite overrides.'
1129
+ )
1130
+ : undefined;
1131
+ };
1132
+
1133
+ const governedTargetError = (
1134
+ input: RegradeInput & { readonly from: string; readonly to: string }
1135
+ ): ValidationError | undefined => {
1136
+ const governedFormTransition = listGovernedVocabularyTransitions().find(
1137
+ (candidate) =>
1138
+ candidate.from !== input.from &&
1139
+ (candidate.oldForms.includes(input.from) ||
1140
+ candidate.reviewForms.includes(input.from))
1141
+ );
1142
+ if (governedFormTransition !== undefined) {
1143
+ return new ValidationError(
1144
+ `Governed vocabulary form "${input.from}" belongs to transition "${governedFormTransition.id}". Plan from its canonical source "${governedFormTransition.from}" instead.`
1145
+ );
1146
+ }
1147
+ const transition = listGovernedVocabularyTransitions().find(
1148
+ (candidate) => candidate.from === input.from
1149
+ );
1150
+ if (
1151
+ transition === undefined ||
1152
+ vocabularyRegradeTransitionForInput(input.from, input.to) !== undefined
1153
+ ) {
1154
+ return undefined;
1155
+ }
1156
+ const expectedTargets =
1157
+ transition.target.kind === 'single'
1158
+ ? [transition.target.to]
1159
+ : transition.target.options.map((option) => option.to);
1160
+ return new ValidationError(
1161
+ `Governed vocabulary transition "${transition.id}" does not define target "${input.to}". Expected ${expectedTargets.map((target) => `"${target}"`).join(' or ')}`
1162
+ );
1163
+ };
1164
+
1165
+ const registryFileRenamesForRoot = (
1166
+ registryPlan: VocabularyRegradePlan | undefined,
1167
+ rootDir: string | undefined
1168
+ ): VocabularyRegradePlan['fileRenames'] | undefined => {
1169
+ const fileRenames = registryPlan?.fileRenames?.filter(
1170
+ (rename) =>
1171
+ rootDir === undefined ||
1172
+ existsSync(join(rootDir, rename.from)) ||
1173
+ existsSync(join(rootDir, rename.to))
1174
+ );
1175
+ return fileRenames?.length === 0 ? undefined : fileRenames;
1176
+ };
1177
+
949
1178
  const buildVocabularyPlan = (
950
1179
  input: RegradeInput,
951
- configScope?: VocabularyRegradePlan['scope']
1180
+ configScope?: VocabularyRegradePlan['scope'],
1181
+ rootDir?: string
952
1182
  ): TrailsResult<VocabularyRegradePlan, ValidationError> => {
953
1183
  if (input.from === undefined || input.to === undefined) {
954
1184
  return Result.err(
@@ -964,11 +1194,29 @@ const buildVocabularyPlan = (
964
1194
  }
965
1195
 
966
1196
  const preserve = vocabularyPreserveFromInput(input.preserve);
1197
+ const targetError = governedTargetError({
1198
+ ...input,
1199
+ from: input.from,
1200
+ to: input.to,
1201
+ });
1202
+ if (targetError !== undefined) {
1203
+ return Result.err(targetError);
1204
+ }
967
1205
  const registryPlan = vocabularyRegistryPlanForInput(input);
1206
+ const overrideError = classifiedOverrideError({
1207
+ ...input,
1208
+ from: input.from,
1209
+ to: input.to,
1210
+ });
1211
+ if (overrideError !== undefined) {
1212
+ return Result.err(overrideError);
1213
+ }
968
1214
  const intent = vocabularyIntentForInput(input, registryPlan);
969
1215
  const overrides = mergeVocabularyOverrides(registryPlan, input);
970
1216
  const preserveRules = mergeVocabularyPreserveRules(registryPlan, preserve);
971
1217
  const scope = mergeVocabularyScope(registryPlan?.scope, configScope, input);
1218
+ const fileRenames =
1219
+ input.fileRenames ?? registryFileRenamesForRoot(registryPlan, rootDir);
972
1220
 
973
1221
  return Result.ok({
974
1222
  ...(registryPlan?.caseSensitive === undefined
@@ -977,6 +1225,7 @@ const buildVocabularyPlan = (
977
1225
  ...(registryPlan?.deferForms === undefined
978
1226
  ? {}
979
1227
  : { deferForms: registryPlan.deferForms }),
1228
+ ...(fileRenames === undefined ? {} : { fileRenames }),
980
1229
  from: input.from,
981
1230
  id: registryPlan?.id ?? `vocabulary:${input.from}->${input.to}`,
982
1231
  kind: 'vocabulary',
@@ -988,6 +1237,33 @@ const buildVocabularyPlan = (
988
1237
  });
989
1238
  };
990
1239
 
1240
+ const withDerivedTeachingSurfaceInventory = (params: {
1241
+ readonly plan: VocabularyRegradePlan;
1242
+ readonly report: RegradeReport;
1243
+ }): VocabularyRegradePlan => {
1244
+ const expected = params.plan.scope?.teachingSurfaces;
1245
+ if (expected === undefined) {
1246
+ return params.plan;
1247
+ }
1248
+ const teachingSurfaces = uniqueSorted(
1249
+ (params.report.run?.ledger.occurrences ?? [])
1250
+ .filter(
1251
+ (occurrence) =>
1252
+ occurrence.scopeTier === 'in-scope' &&
1253
+ !isGeneratedRegradeArtifactPath(occurrence.path) &&
1254
+ matchesAnyPathGlob(occurrence.path, expected)
1255
+ )
1256
+ .map((occurrence) => occurrence.path)
1257
+ );
1258
+ const scope = { ...params.plan.scope };
1259
+ if (teachingSurfaces.length === 0) {
1260
+ delete scope.teachingSurfaces;
1261
+ } else {
1262
+ scope.teachingSurfaces = teachingSurfaces;
1263
+ }
1264
+ return { ...params.plan, scope };
1265
+ };
1266
+
991
1267
  const regradeRootNotFound = (rootDir: string) =>
992
1268
  Result.err(
993
1269
  new NotFoundError(
@@ -1078,6 +1354,10 @@ const runGovernedSymbolRegrade = (params: {
1078
1354
  },
1079
1355
  {
1080
1356
  shouldPreserve: (occurrence) =>
1357
+ symbolOccurrenceIsPolicyClassified(
1358
+ params.plan.scope,
1359
+ occurrence.path
1360
+ ) ||
1081
1361
  symbolOccurrenceIsPreserved(params.plan.preserve, {
1082
1362
  end: occurrence.end,
1083
1363
  form: occurrence.from,
@@ -1157,7 +1437,11 @@ const reportWithHistorySummary = (
1157
1437
  ): RegradeReport => ({
1158
1438
  ...report,
1159
1439
  history: {
1440
+ id: params.id,
1160
1441
  path: params.path,
1442
+ ...(params.provenance === undefined
1443
+ ? {}
1444
+ : { provenance: params.provenance }),
1161
1445
  schemaVersion: params.schemaVersion,
1162
1446
  status: params.status,
1163
1447
  },
@@ -1166,6 +1450,7 @@ const reportWithHistorySummary = (
1166
1450
  const authoredPlanFieldKeys = [
1167
1451
  'caseSensitive',
1168
1452
  'deferForms',
1453
+ 'fileRenames',
1169
1454
  'id',
1170
1455
  'intent',
1171
1456
  'overrides',
@@ -1179,6 +1464,7 @@ const isAuthoredPlanField = (
1179
1464
  ): boolean => {
1180
1465
  switch (key) {
1181
1466
  case 'intent':
1467
+ case 'fileRenames':
1182
1468
  case 'overrides':
1183
1469
  case 'preserve': {
1184
1470
  return input[key] !== undefined;
@@ -1187,7 +1473,9 @@ const isAuthoredPlanField = (
1187
1473
  return (
1188
1474
  input.exclude !== undefined ||
1189
1475
  input.extensions !== undefined ||
1190
- input.include !== undefined
1476
+ input.include !== undefined ||
1477
+ input.policyClassified !== undefined ||
1478
+ input.teachingSurfaces !== undefined
1191
1479
  );
1192
1480
  }
1193
1481
  default: {
@@ -1209,6 +1497,7 @@ const regradePlanProvenanceForInput = (
1209
1497
  for (const key of [
1210
1498
  'caseSensitive',
1211
1499
  'deferForms',
1500
+ 'fileRenames',
1212
1501
  'id',
1213
1502
  'intent',
1214
1503
  'overrides',
@@ -1256,6 +1545,7 @@ const preserveAuthoredPlanProvenance = (
1256
1545
  };
1257
1546
 
1258
1547
  const buildRegradePlanArtifact = (params: {
1548
+ readonly derivation?: RegradePlanArtifact['derivation'];
1259
1549
  readonly expansion?: RegradePlanArtifact['expansion'];
1260
1550
  readonly input: RegradePlanInput;
1261
1551
  readonly plan: VocabularyRegradePlan;
@@ -1265,6 +1555,9 @@ const buildRegradePlanArtifact = (params: {
1265
1555
  }): RegradePlanArtifact => {
1266
1556
  const absolutePath = regradePlanPathForPlan(params.rootDir, params.plan);
1267
1557
  return {
1558
+ ...(params.derivation === undefined
1559
+ ? {}
1560
+ : { derivation: params.derivation }),
1268
1561
  ...(params.expansion === undefined ? {} : { expansion: params.expansion }),
1269
1562
  kind: 'regrade-plan',
1270
1563
  path: rootRelativePath(params.rootDir, absolutePath),
@@ -1479,9 +1772,27 @@ const resolveRegradePlanPath = (
1479
1772
 
1480
1773
  const planStatusForReport = (
1481
1774
  artifact: RegradePlanArtifact,
1482
- report: RegradeReport
1483
- ): 'active' | 'stale' =>
1484
- regradeSourceHashMatches(artifact.sourceHash, report) ? 'active' : 'stale';
1775
+ report: RegradeReport,
1776
+ rootDir: string
1777
+ ): 'active' | 'stale' => {
1778
+ if (!currentRegradeSourceHashMatches(artifact.sourceHash, report)) {
1779
+ return 'stale';
1780
+ }
1781
+ if (artifact.plan.kind === 'class' || artifact.derivation === undefined) {
1782
+ return 'active';
1783
+ }
1784
+ const current = deriveRegradePlanDerivation({
1785
+ plan: artifact.plan,
1786
+ preserveInventory: report.run?.preserveInventory ?? [],
1787
+ provenance: artifact.provenance,
1788
+ report,
1789
+ rootDir,
1790
+ });
1791
+ return canonicalJsonStringify(current) ===
1792
+ canonicalJsonStringify(artifact.derivation)
1793
+ ? 'active'
1794
+ : 'stale';
1795
+ };
1485
1796
 
1486
1797
  const regradePlanGateContext = (
1487
1798
  report: RegradeReport
@@ -1529,6 +1840,182 @@ const persistVocabularyRecord = (params: {
1529
1840
  );
1530
1841
  };
1531
1842
 
1843
+ const withFileRenameEvidence = (params: {
1844
+ readonly plan: VocabularyRegradePlan;
1845
+ readonly report: RegradeReport & {
1846
+ readonly run: NonNullable<RegradeReport['run']>;
1847
+ };
1848
+ readonly run: FileRenameRegradeRun;
1849
+ }): RegradeReport => {
1850
+ const vocabularyPaths = params.report.run.ledger.occurrences
1851
+ .filter((occurrence) => occurrence.scopeTier === 'in-scope')
1852
+ .map((occurrence) => occurrence.path);
1853
+ const remainingPolicyPaths = new Map<string, number>();
1854
+ for (const path of params.run.policyOccurrencePaths) {
1855
+ remainingPolicyPaths.set(path, (remainingPolicyPaths.get(path) ?? 0) + 1);
1856
+ }
1857
+ const fileInScopePaths = params.run.occurrencePaths.filter((path) => {
1858
+ const remaining = remainingPolicyPaths.get(path) ?? 0;
1859
+ if (remaining === 0) {
1860
+ return true;
1861
+ }
1862
+ remainingPolicyPaths.set(path, remaining - 1);
1863
+ return false;
1864
+ });
1865
+ const evidencePaths = [...vocabularyPaths, ...fileInScopePaths];
1866
+ const expected = uniqueSorted(params.plan.scope?.teachingSurfaces ?? []);
1867
+ const touched = expected.filter((pattern) =>
1868
+ evidencePaths.some((path) => matchesAnyPathGlob(path, [pattern]))
1869
+ );
1870
+ const missing = expected.filter((pattern) => !touched.includes(pattern));
1871
+ const vocabularyPolicyPaths = params.report.run.ledger.occurrences
1872
+ .filter((occurrence) => occurrence.scopeTier === 'policy-classified')
1873
+ .map((occurrence) => occurrence.path);
1874
+ const policyPaths = [
1875
+ ...vocabularyPolicyPaths,
1876
+ ...params.run.policyOccurrencePaths,
1877
+ ];
1878
+ const policyEvidenceMissing =
1879
+ params.plan.scope?.policyClassified?.some(
1880
+ (policy) =>
1881
+ policy.expectMatches === true &&
1882
+ !policyPaths.some((path) => matchesAnyPathGlob(path, policy.paths))
1883
+ ) ?? false;
1884
+ const evidenceReasons = [
1885
+ ...(policyEvidenceMissing
1886
+ ? ['expected-policy-classified-evidence-missing']
1887
+ : []),
1888
+ ...(missing.length === 0 ? [] : ['expected-teaching-surfaces-missing']),
1889
+ ];
1890
+ const reasons = uniqueSorted([
1891
+ ...params.report.run.report.gate.reasons.filter(
1892
+ (reason) =>
1893
+ reason !== 'expected-policy-classified-evidence-missing' &&
1894
+ reason !== 'expected-teaching-surfaces-missing'
1895
+ ),
1896
+ ...evidenceReasons,
1897
+ ]);
1898
+ const filePolicyCount = params.run.policyOccurrencePaths.length;
1899
+ const fileInScopeCount = params.run.occurrencePaths.length - filePolicyCount;
1900
+ const projectedFileInScopeCount =
1901
+ params.run.report.rewritten + params.run.report.review;
1902
+ return {
1903
+ ...params.report,
1904
+ run: {
1905
+ ...params.report.run,
1906
+ report: {
1907
+ ...params.report.run.report,
1908
+ fileRenames: params.run.evidence,
1909
+ gate: {
1910
+ ...params.report.run.report.gate,
1911
+ reasons,
1912
+ status: reasons.length === 0 ? 'green' : 'open',
1913
+ },
1914
+ scopeTiers: {
1915
+ 'in-scope':
1916
+ params.report.run.report.scopeTiers['in-scope'] -
1917
+ projectedFileInScopeCount +
1918
+ fileInScopeCount,
1919
+ 'policy-classified':
1920
+ params.report.run.report.scopeTiers['policy-classified'] +
1921
+ filePolicyCount,
1922
+ },
1923
+ teachingSurfaces: { expected, missing, touched },
1924
+ },
1925
+ },
1926
+ };
1927
+ };
1928
+
1929
+ const combineVocabularyReports = (params: {
1930
+ readonly fileRenameRun: FileRenameRegradeRun | null;
1931
+ readonly plan: VocabularyRegradePlan;
1932
+ readonly preserveInventory: readonly VocabularyPreserveInventoryEntry[];
1933
+ readonly proseReport: RegradeReport | null;
1934
+ readonly symbolReport: RegradeReport | null;
1935
+ }): TrailsResult<RegradeReport, Error> => {
1936
+ const baseReport =
1937
+ params.proseReport ?? params.symbolReport ?? params.fileRenameRun?.report;
1938
+ if (baseReport === undefined) {
1939
+ return regradeNoEngineForScope();
1940
+ }
1941
+ let combined = reportWithVocabularyTransitionRun({
1942
+ plan: params.plan,
1943
+ preserveInventory: params.preserveInventory,
1944
+ report: baseReport,
1945
+ });
1946
+ if (params.proseReport !== null && params.symbolReport !== null) {
1947
+ combined = mergeRegradeReports(combined, params.symbolReport);
1948
+ }
1949
+ if (
1950
+ params.fileRenameRun !== null &&
1951
+ baseReport !== params.fileRenameRun.report
1952
+ ) {
1953
+ combined = mergeRegradeReports(combined, params.fileRenameRun.report);
1954
+ }
1955
+ if (params.fileRenameRun !== null && combined.run !== undefined) {
1956
+ combined = withFileRenameEvidence({
1957
+ plan: params.plan,
1958
+ report: { ...combined, run: combined.run },
1959
+ run: params.fileRenameRun,
1960
+ });
1961
+ }
1962
+ if (combined.apply !== undefined && params.fileRenameRun !== null) {
1963
+ const movedPaths = new Map(
1964
+ (params.plan.fileRenames ?? []).map((rename) => [
1965
+ posix.normalize(rename.from.replaceAll('\\', '/')),
1966
+ posix.normalize(rename.to.replaceAll('\\', '/')),
1967
+ ])
1968
+ );
1969
+ const changedPaths = new Set(params.fileRenameRun.changedPaths);
1970
+ for (const entry of combined.entries) {
1971
+ if (entry.outcome !== 'rewrite') {
1972
+ continue;
1973
+ }
1974
+ const normalizedEntryPath = posix.normalize(entry.path);
1975
+ const movedPath = movedPaths.get(normalizedEntryPath);
1976
+ changedPaths.add(
1977
+ movedPath !== undefined && changedPaths.has(movedPath)
1978
+ ? movedPath
1979
+ : normalizedEntryPath
1980
+ );
1981
+ }
1982
+ const filesChanged = changedPaths.size;
1983
+ combined = {
1984
+ ...combined,
1985
+ apply: { ...combined.apply, filesChanged },
1986
+ ...(combined.run === undefined
1987
+ ? {}
1988
+ : {
1989
+ run: {
1990
+ ...combined.run,
1991
+ report: { ...combined.run.report, filesChanged },
1992
+ },
1993
+ }),
1994
+ };
1995
+ }
1996
+ return validateRegradeReport(combined);
1997
+ };
1998
+
1999
+ const runPlanFileRenames = (
2000
+ plan: VocabularyRegradePlan,
2001
+ params: {
2002
+ readonly apply: boolean;
2003
+ readonly includeEntries: RegradeInput['includeEntries'];
2004
+ readonly rootDir: string;
2005
+ }
2006
+ ): TrailsResult<FileRenameRegradeRun | null, Error> =>
2007
+ plan.fileRenames === undefined || plan.fileRenames.length === 0
2008
+ ? Result.ok(null)
2009
+ : runFileRenameRegrade({
2010
+ apply: params.apply,
2011
+ excludeGeneratedArtifacts: true,
2012
+ includeEntries: params.includeEntries,
2013
+ renames: plan.fileRenames,
2014
+ root: params.rootDir,
2015
+ ...(plan.scope === undefined ? {} : { scope: plan.scope }),
2016
+ vocabularyPlan: plan,
2017
+ });
2018
+
1532
2019
  const runResolvedVocabularyPlan = (params: {
1533
2020
  readonly apply: boolean;
1534
2021
  readonly includeEntries: RegradeInput['includeEntries'];
@@ -1536,70 +2023,117 @@ const runResolvedVocabularyPlan = (params: {
1536
2023
  readonly preserveInventory: readonly VocabularyPreserveInventoryEntry[];
1537
2024
  readonly rootDir: string;
1538
2025
  }): TrailsResult<RegradeReport, Error> => {
1539
- const prosePlan = vocabularyProsePlan(params.plan);
1540
- const reportResult: TrailsResult<RegradeReport | null, Error> =
1541
- prosePlan === null
2026
+ const fileRenamePreflight = runPlanFileRenames(params.plan, {
2027
+ apply: false,
2028
+ includeEntries: params.includeEntries,
2029
+ rootDir: params.rootDir,
2030
+ });
2031
+ if (fileRenamePreflight.isErr()) {
2032
+ return fileRenamePreflight;
2033
+ }
2034
+ const evidencePlan = vocabularyEvidencePlan(params.plan);
2035
+ const runProse = (
2036
+ apply: boolean
2037
+ ): TrailsResult<RegradeReport | null, Error> =>
2038
+ evidencePlan === null
1542
2039
  ? Result.ok(null)
1543
2040
  : runVocabularyRegrade({
1544
- apply: params.apply,
2041
+ apply,
1545
2042
  includeEntries: params.includeEntries,
1546
- plan: prosePlan,
2043
+ plan: evidencePlan,
1547
2044
  ...(params.preserveInventory.length === 0
1548
2045
  ? {}
1549
2046
  : { preserveInventory: params.preserveInventory }),
1550
2047
  root: params.rootDir,
2048
+ sourceFilter: (path) =>
2049
+ vocabularyEvidenceSource(path, evidencePlan.scope),
1551
2050
  });
1552
- if (reportResult.isErr()) {
1553
- return reportResult;
2051
+ const runSymbols = (
2052
+ apply: boolean
2053
+ ): TrailsResult<RegradeReport | null, Error> =>
2054
+ runGovernedSymbolRegrade({
2055
+ apply,
2056
+ includeEntries: params.includeEntries,
2057
+ plan: params.plan,
2058
+ preserveInventory: params.preserveInventory,
2059
+ rootDir: params.rootDir,
2060
+ });
2061
+ const prosePreview = runProse(false);
2062
+ if (prosePreview.isErr()) {
2063
+ return prosePreview;
2064
+ }
2065
+ const prosePreviewReport = withoutVocabularySourceFilterSkips(
2066
+ prosePreview.value
2067
+ );
2068
+ const symbolPreview = runSymbols(false);
2069
+ if (symbolPreview.isErr()) {
2070
+ return symbolPreview;
2071
+ }
2072
+ 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();
2080
+ }
2081
+ return combineVocabularyReports({
2082
+ fileRenameRun: fileRenamePreflight.value,
2083
+ plan: params.plan,
2084
+ preserveInventory: params.preserveInventory,
2085
+ proseReport: prosePreviewReport,
2086
+ symbolReport: symbolPreview.value,
2087
+ });
1554
2088
  }
1555
2089
 
1556
- const symbolReportResult = runGovernedSymbolRegrade({
1557
- apply: params.apply,
1558
- includeEntries: params.includeEntries,
1559
- plan: params.plan,
1560
- preserveInventory: params.preserveInventory,
2090
+ const snapshots = snapshotRegradeSources({
2091
+ reports: [prosePreviewReport, symbolPreview.value],
1561
2092
  rootDir: params.rootDir,
1562
2093
  });
2094
+ if (snapshots.isErr()) {
2095
+ return snapshots;
2096
+ }
2097
+ const reportResult = runProse(true);
2098
+ if (reportResult.isErr()) {
2099
+ return regradeApplyErrorAfterRollback(reportResult.error, snapshots.value);
2100
+ }
2101
+ const symbolReportResult = runSymbols(true);
1563
2102
  if (symbolReportResult.isErr()) {
1564
- return symbolReportResult;
2103
+ return regradeApplyErrorAfterRollback(
2104
+ symbolReportResult.error,
2105
+ snapshots.value
2106
+ );
1565
2107
  }
1566
-
1567
- const report = reportResult.value;
1568
- const symbolReport = symbolReportResult.value;
1569
- if (report === null) {
1570
- if (symbolReport === null) {
1571
- return regradeNoEngineForScope();
1572
- }
1573
- return validateRegradeReport(
1574
- reportWithVocabularyTransitionRun({
1575
- plan: params.plan,
1576
- preserveInventory: params.preserveInventory,
1577
- report: symbolReport,
1578
- })
2108
+ const fileRenameResult = runPlanFileRenames(params.plan, {
2109
+ apply: true,
2110
+ includeEntries: params.includeEntries,
2111
+ rootDir: params.rootDir,
2112
+ });
2113
+ if (fileRenameResult.isErr()) {
2114
+ return regradeApplyErrorAfterRollback(
2115
+ fileRenameResult.error,
2116
+ snapshots.value
1579
2117
  );
1580
2118
  }
1581
2119
 
1582
- const validated = validateRegradeReport(
1583
- reportWithVocabularyTransitionRun({
1584
- plan: params.plan,
1585
- preserveInventory: params.preserveInventory,
1586
- report,
1587
- })
1588
- );
1589
- if (validated.isErr()) {
1590
- return validated;
1591
- }
1592
- if (symbolReport === null) {
1593
- return Result.ok(validated.value);
2120
+ const report = withoutVocabularySourceFilterSkips(reportResult.value);
2121
+ const symbolReport = symbolReportResult.value;
2122
+ if (
2123
+ report?.scanned === 0 &&
2124
+ symbolReport === null &&
2125
+ fileRenameResult.value === null &&
2126
+ !vocabularyProseEngineApplies(params.plan.scope)
2127
+ ) {
2128
+ return regradeNoEngineForScope();
1594
2129
  }
1595
-
1596
- return validateRegradeReport(
1597
- reportWithVocabularyTransitionRun({
1598
- plan: params.plan,
1599
- preserveInventory: params.preserveInventory,
1600
- report: mergeRegradeReports(report, symbolReport),
1601
- })
1602
- );
2130
+ return combineVocabularyReports({
2131
+ fileRenameRun: fileRenameResult.value,
2132
+ plan: params.plan,
2133
+ preserveInventory: params.preserveInventory,
2134
+ proseReport: report,
2135
+ symbolReport,
2136
+ });
1603
2137
  };
1604
2138
 
1605
2139
  interface ClassRegradeCoreParams {
@@ -1697,12 +2231,18 @@ const runPlanArtifactDryRun = async (params: {
1697
2231
  rootDir: params.rootDir,
1698
2232
  });
1699
2233
  }
1700
- const preserveInventory = await deriveLiveApiPreserveInventory(planBody);
2234
+ const preserveResult = await deriveLiveApiPreserveInventory(
2235
+ planBody,
2236
+ params.rootDir
2237
+ );
2238
+ if (preserveResult.isErr()) {
2239
+ return preserveResult;
2240
+ }
1701
2241
  return runResolvedVocabularyPlan({
1702
2242
  apply: false,
1703
2243
  includeEntries: params.includeEntries,
1704
2244
  plan: planBody,
1705
- preserveInventory,
2245
+ preserveInventory: preserveResult.value,
1706
2246
  rootDir: params.rootDir,
1707
2247
  });
1708
2248
  };
@@ -1816,7 +2356,8 @@ const runVocabularyCommandRegrade = async (
1816
2356
 
1817
2357
  const planResult = buildVocabularyPlan(
1818
2358
  input,
1819
- vocabularyScopeFromConfig(configScope)
2359
+ vocabularyScopeFromConfig(configScope),
2360
+ rootDir
1820
2361
  );
1821
2362
  if (planResult.isErr()) {
1822
2363
  return planResult;
@@ -1825,14 +2366,18 @@ const runVocabularyCommandRegrade = async (
1825
2366
  return regradeRootNotFound(rootDir);
1826
2367
  }
1827
2368
 
1828
- const preserveInventory = await deriveLiveApiPreserveInventory(
1829
- planResult.value
2369
+ const preserveResult = await deriveLiveApiPreserveInventory(
2370
+ planResult.value,
2371
+ rootDir
1830
2372
  );
2373
+ if (preserveResult.isErr()) {
2374
+ return preserveResult;
2375
+ }
1831
2376
  const report = runResolvedVocabularyPlan({
1832
2377
  apply: input.apply,
1833
2378
  includeEntries: input.includeEntries,
1834
2379
  plan: planResult.value,
1835
- preserveInventory,
2380
+ preserveInventory: preserveResult.value,
1836
2381
  rootDir,
1837
2382
  });
1838
2383
  if (report.isErr() || !input.writeRecord) {
@@ -1935,6 +2480,7 @@ const expansionForReport = (
1935
2480
  },
1936
2481
  ],
1937
2482
  kind: 'form',
2483
+ provenance: 'derived',
1938
2484
  status: 'pending',
1939
2485
  suggestedClassification: occurrence.disposition,
1940
2486
  value: occurrence.form,
@@ -1966,6 +2512,7 @@ const expansionForReport = (
1966
2512
  },
1967
2513
  ],
1968
2514
  kind: 'form',
2515
+ provenance: 'derived',
1969
2516
  status: 'pending',
1970
2517
  suggestedClassification: entry.reason ?? detail.reason,
1971
2518
  value: detail.symbol,
@@ -2119,6 +2666,11 @@ const validateClassPlanInput = (
2119
2666
  '`type: vocabulary` cannot be combined with `classIds`.'
2120
2667
  );
2121
2668
  }
2669
+ if (input.fileRenames !== undefined && input.fileRenames.length > 0) {
2670
+ return new ValidationError(
2671
+ '`fileRenames` is not supported for class-mode plans; governed file moves require a vocabulary plan.'
2672
+ );
2673
+ }
2122
2674
  if (input.expand) {
2123
2675
  return new ValidationError(
2124
2676
  '`expand` stages vocabulary review candidates and is not supported for class-mode plans.'
@@ -2339,6 +2891,84 @@ const readCurrentVocabularyPlanArtifact = (
2339
2891
  return Result.ok({ ...candidate, plan: candidate.plan });
2340
2892
  };
2341
2893
 
2894
+ const finishVocabularyPlanArtifact = (params: {
2895
+ readonly current?: VocabularyRegradePlanArtifact | undefined;
2896
+ readonly currentPath: string;
2897
+ readonly input: RegradePlanInput;
2898
+ readonly plan: VocabularyRegradePlan;
2899
+ readonly preserveInventory: readonly VocabularyPreserveInventoryEntry[];
2900
+ readonly rootDir: string;
2901
+ readonly shouldDryRun: boolean;
2902
+ }): TrailsResult<RegradePlanArtifact, Error> => {
2903
+ const initialReport = runResolvedVocabularyPlan({
2904
+ apply: false,
2905
+ includeEntries: params.input.includeEntries,
2906
+ plan: params.plan,
2907
+ preserveInventory: params.preserveInventory,
2908
+ rootDir: params.rootDir,
2909
+ });
2910
+ if (initialReport.isErr()) {
2911
+ return initialReport;
2912
+ }
2913
+ const initialProvenance = regradePlanProvenanceForInput(
2914
+ params.input,
2915
+ params.plan
2916
+ );
2917
+ const scopeIsAuthored =
2918
+ initialProvenance.fields['scope'] === 'authored' ||
2919
+ params.current?.provenance.fields['scope'] === 'authored';
2920
+ const plan = scopeIsAuthored
2921
+ ? params.plan
2922
+ : withDerivedTeachingSurfaceInventory({
2923
+ plan: params.plan,
2924
+ report: initialReport.value,
2925
+ });
2926
+ const report =
2927
+ plan === params.plan
2928
+ ? initialReport
2929
+ : runResolvedVocabularyPlan({
2930
+ apply: false,
2931
+ includeEntries: params.input.includeEntries,
2932
+ plan,
2933
+ preserveInventory: params.preserveInventory,
2934
+ rootDir: params.rootDir,
2935
+ });
2936
+ if (report.isErr()) {
2937
+ return report;
2938
+ }
2939
+ const expansion = mergeRegradePlanExpansion(
2940
+ params.current?.expansion,
2941
+ params.input.expand ? expansionForReport(report.value) : undefined,
2942
+ plan
2943
+ );
2944
+ const transitionId = priorTransitionId(params.currentPath, 'vocabulary');
2945
+ const derivedProvenance = regradePlanProvenanceForInput(params.input, plan);
2946
+ const provenance =
2947
+ params.current === undefined
2948
+ ? derivedProvenance
2949
+ : preserveAuthoredPlanProvenance(params.current, derivedProvenance);
2950
+ const artifact = buildRegradePlanArtifact({
2951
+ derivation: deriveRegradePlanDerivation({
2952
+ plan,
2953
+ preserveInventory: params.preserveInventory,
2954
+ provenance,
2955
+ report: report.value,
2956
+ rootDir: params.rootDir,
2957
+ }),
2958
+ ...(expansion === undefined ? {} : { expansion }),
2959
+ input: params.input,
2960
+ plan,
2961
+ report: report.value,
2962
+ rootDir: params.rootDir,
2963
+ ...(transitionId === undefined ? {} : { transitionId }),
2964
+ });
2965
+ const mergedArtifact =
2966
+ params.current === undefined ? artifact : { ...artifact, provenance };
2967
+ return params.shouldDryRun
2968
+ ? validateRegradePlanArtifact(mergedArtifact)
2969
+ : writeRegradePlanArtifact(params.rootDir, mergedArtifact);
2970
+ };
2971
+
2342
2972
  const runPlanRegrade = async (
2343
2973
  input: RegradePlanInput,
2344
2974
  rootDir: string,
@@ -2368,7 +2998,8 @@ const runPlanRegrade = async (
2368
2998
  };
2369
2999
  const planResult = buildVocabularyPlan(
2370
3000
  planInput,
2371
- vocabularyScopeFromConfig(configScope)
3001
+ vocabularyScopeFromConfig(configScope),
3002
+ rootDir
2372
3003
  );
2373
3004
  if (planResult.isErr()) {
2374
3005
  return planResult;
@@ -2386,45 +3017,19 @@ const runPlanRegrade = async (
2386
3017
  current === undefined
2387
3018
  ? planResult.value
2388
3019
  : mergeAuthoredPlanFields(current, planResult.value);
2389
- const preserveInventory = await deriveLiveApiPreserveInventory(plan);
2390
- const report = runResolvedVocabularyPlan({
2391
- apply: false,
2392
- includeEntries: input.includeEntries,
2393
- plan,
2394
- preserveInventory,
2395
- rootDir,
2396
- });
2397
- if (report.isErr()) {
2398
- return report;
3020
+ const preserveResult = await deriveLiveApiPreserveInventory(plan, rootDir);
3021
+ if (preserveResult.isErr()) {
3022
+ return preserveResult;
2399
3023
  }
2400
- const expansion = mergeRegradePlanExpansion(
2401
- current?.expansion,
2402
- input.expand ? expansionForReport(report.value) : undefined,
2403
- plan
2404
- );
2405
- const transitionId = priorTransitionId(currentPath, 'vocabulary');
2406
- const artifact = buildRegradePlanArtifact({
2407
- ...(expansion === undefined ? {} : { expansion }),
3024
+ return finishVocabularyPlanArtifact({
3025
+ current,
3026
+ currentPath,
2408
3027
  input,
2409
3028
  plan,
2410
- report: report.value,
3029
+ preserveInventory: preserveResult.value,
2411
3030
  rootDir,
2412
- ...(transitionId === undefined ? {} : { transitionId }),
3031
+ shouldDryRun,
2413
3032
  });
2414
- const mergedArtifact =
2415
- current === undefined
2416
- ? artifact
2417
- : {
2418
- ...artifact,
2419
- provenance: preserveAuthoredPlanProvenance(
2420
- current,
2421
- artifact.provenance
2422
- ),
2423
- };
2424
- if (shouldDryRun) {
2425
- return validateRegradePlanArtifact(mergedArtifact);
2426
- }
2427
- return writeRegradePlanArtifact(rootDir, mergedArtifact);
2428
3033
  };
2429
3034
 
2430
3035
  const loadPlanForInput = async (
@@ -2517,7 +3122,11 @@ const runCheckRegradePlan = async (
2517
3122
  if (report.isErr()) {
2518
3123
  return report;
2519
3124
  }
2520
- const status = planStatusForReport(loaded.value.artifact, report.value);
3125
+ const status = planStatusForReport(
3126
+ loaded.value.artifact,
3127
+ report.value,
3128
+ rootDir
3129
+ );
2521
3130
  const checked = reportWithPlanSummary(
2522
3131
  report.value,
2523
3132
  loaded.value.artifact,
@@ -2567,7 +3176,7 @@ const runPreviewRegradePlan = async (
2567
3176
  reportWithPlanSummary(
2568
3177
  report.value,
2569
3178
  loaded.value.artifact,
2570
- planStatusForReport(loaded.value.artifact, report.value)
3179
+ planStatusForReport(loaded.value.artifact, report.value, rootDir)
2571
3180
  )
2572
3181
  );
2573
3182
  };
@@ -2660,6 +3269,12 @@ const runApplyRegradePlan = async (
2660
3269
  if (loaded.isErr()) {
2661
3270
  return loaded;
2662
3271
  }
3272
+ const governedPlanValidation = validateGovernedRegradePlan(
3273
+ loaded.value.artifact
3274
+ );
3275
+ if (governedPlanValidation.isErr()) {
3276
+ return governedPlanValidation;
3277
+ }
2663
3278
  const dryRunReport = await runPlanArtifactDryRun({
2664
3279
  artifact: loaded.value.artifact,
2665
3280
  includeEntries: input.includeEntries,
@@ -2668,7 +3283,11 @@ const runApplyRegradePlan = async (
2668
3283
  if (dryRunReport.isErr()) {
2669
3284
  return dryRunReport;
2670
3285
  }
2671
- const status = planStatusForReport(loaded.value.artifact, dryRunReport.value);
3286
+ const status = planStatusForReport(
3287
+ loaded.value.artifact,
3288
+ dryRunReport.value,
3289
+ rootDir
3290
+ );
2672
3291
  if (status === 'stale') {
2673
3292
  return Result.err(
2674
3293
  new ValidationError(
@@ -2695,12 +3314,18 @@ const runApplyRegradePlan = async (
2695
3314
  rootDir,
2696
3315
  });
2697
3316
  } else {
2698
- const preserveInventory = await deriveLiveApiPreserveInventory(planBody);
3317
+ const preserveResult = await deriveLiveApiPreserveInventory(
3318
+ planBody,
3319
+ rootDir
3320
+ );
3321
+ if (preserveResult.isErr()) {
3322
+ return preserveResult;
3323
+ }
2699
3324
  applied = runResolvedVocabularyPlan({
2700
3325
  apply: true,
2701
3326
  includeEntries: input.includeEntries,
2702
3327
  plan: planBody,
2703
- preserveInventory,
3328
+ preserveInventory: preserveResult.value,
2704
3329
  rootDir,
2705
3330
  });
2706
3331
  }
@@ -2800,6 +3425,17 @@ const runAdjustRegrade = async (
2800
3425
  }
2801
3426
  const artifact: RegradePlanArtifact = {
2802
3427
  ...draft,
3428
+ ...(draft.plan.kind === 'class'
3429
+ ? {}
3430
+ : {
3431
+ derivation: deriveRegradePlanDerivation({
3432
+ plan: draft.plan,
3433
+ preserveInventory: report.value.run?.preserveInventory ?? [],
3434
+ provenance: draft.provenance,
3435
+ report: report.value,
3436
+ rootDir,
3437
+ }),
3438
+ }),
2803
3439
  sourceHash: regradeSourceHash(report.value),
2804
3440
  };
2805
3441
  if (shouldDryRun) {
@@ -2834,7 +3470,7 @@ const listRegradePlans = async (
2834
3470
  kind: body.kind,
2835
3471
  path: artifact.value.path,
2836
3472
  schemaVersion: artifact.value.schemaVersion,
2837
- status: planStatusForReport(artifact.value, report.value),
3473
+ status: planStatusForReport(artifact.value, report.value, rootDir),
2838
3474
  });
2839
3475
  }
2840
3476
  return Result.ok({ plans });
@@ -2969,6 +3605,47 @@ export const listRegradesTrail = trail('list.regrades', {
2969
3605
  permit: 'public',
2970
3606
  });
2971
3607
 
3608
+ export const auditRegradeTrail = trail('audit.regrade', {
3609
+ cli: { path: ['regrade', 'audit'] },
3610
+ description:
3611
+ 'Audit applied Regrade vocabulary transitions against current source',
3612
+ implementation: async (input, ctx) => {
3613
+ const rootDirResult = resolveTrailRootDir(input.rootDir, ctx.cwd);
3614
+ if (rootDirResult.isErr()) {
3615
+ return rootDirResult;
3616
+ }
3617
+ const result = await auditRegradeHistory(input, rootDirResult.value);
3618
+ if (result.isErr()) {
3619
+ return result;
3620
+ }
3621
+ const output = validateOutput(regradeAuditOutputSchema, result.value);
3622
+ if (output.isErr()) {
3623
+ return Result.err(output.error);
3624
+ }
3625
+ if (input.failOnOpen && output.value.gate.status === 'open') {
3626
+ return Result.err(
3627
+ new ValidationError('Regrade audit found current-tree residue.', {
3628
+ context: {
3629
+ gate: output.value.gate,
3630
+ transitions: output.value.transitions
3631
+ .filter((transition) => transition.report.status === 'open')
3632
+ .map((transition) => ({
3633
+ open: transition.report.open,
3634
+ source: transition.source,
3635
+ transitionId: transition.transitionId,
3636
+ })),
3637
+ },
3638
+ })
3639
+ );
3640
+ }
3641
+ return Result.ok(output.value);
3642
+ },
3643
+ input: regradeAuditInputSchema,
3644
+ intent: 'read',
3645
+ output: regradeAuditOutputSchema,
3646
+ permit: 'public',
3647
+ });
3648
+
2972
3649
  export const checkRegradeTrail = trail('check.regrade', {
2973
3650
  cli: { path: ['regrade', 'check'] },
2974
3651
  description: 'Check a saved Regrade plan gate without writing source',