@ontrails/trails 1.0.0-beta.5 → 1.0.0-beta.50

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.
Files changed (175) hide show
  1. package/CHANGELOG.md +1213 -4
  2. package/README.md +29 -0
  3. package/package.json +35 -7
  4. package/src/app.ts +147 -3
  5. package/src/cli.ts +358 -11
  6. package/src/completions.ts +240 -0
  7. package/src/lifecycle-source-io.ts +33 -0
  8. package/src/load-app-mirror.ts +202 -0
  9. package/src/local-state-io.ts +173 -0
  10. package/src/mcp-app.ts +42 -0
  11. package/src/mcp-options.ts +92 -0
  12. package/src/mcp.ts +8 -0
  13. package/src/project-writes.ts +377 -0
  14. package/src/regrade/audit.ts +571 -0
  15. package/src/regrade/config.ts +152 -0
  16. package/src/regrade/history.ts +636 -0
  17. package/src/regrade/lifecycle.ts +76 -0
  18. package/src/regrade/live-api-preserve.ts +123 -0
  19. package/src/regrade/plan-artifact.ts +461 -0
  20. package/src/regrade/plan-derivation.ts +301 -0
  21. package/src/regrade/prepared-run.ts +259 -0
  22. package/src/regrade/receipt-history.ts +446 -0
  23. package/src/regrade/source-transaction.ts +185 -0
  24. package/src/release/bindings.ts +58 -0
  25. package/src/release/check.ts +1146 -0
  26. package/src/release/cli-bundle.ts +575 -0
  27. package/src/release/config.ts +73 -0
  28. package/src/release/contract-facts.ts +425 -0
  29. package/src/release/homebrew.ts +219 -0
  30. package/src/release/index.ts +180 -0
  31. package/src/release/lock-roundtrip-smoke.ts +230 -0
  32. package/src/release/native-bun-publish.ts +964 -0
  33. package/src/release/native-bun-registry.ts +830 -0
  34. package/src/release/notes-cli.ts +171 -0
  35. package/src/release/notes.ts +390 -0
  36. package/src/release/pack-coherence.ts +455 -0
  37. package/src/release/package-route-facts.ts +146 -0
  38. package/src/release/packed-artifacts-smoke.ts +236 -0
  39. package/src/release/policy.ts +1700 -0
  40. package/src/release/semver.ts +104 -0
  41. package/src/release/smoke.ts +56 -0
  42. package/src/release/wayfinder-dogfood-smoke.ts +760 -0
  43. package/src/retired-topo-command.ts +36 -0
  44. package/src/run-adapter-check.ts +76 -0
  45. package/src/run-collision.ts +126 -0
  46. package/src/run-completions-install.ts +179 -0
  47. package/src/run-example.ts +149 -0
  48. package/src/run-examples.ts +148 -0
  49. package/src/run-quiet.ts +75 -0
  50. package/src/run-regrade-progress.ts +47 -0
  51. package/src/run-release-check.ts +74 -0
  52. package/src/run-schema.ts +74 -0
  53. package/src/run-trace.ts +273 -0
  54. package/src/run-warden.ts +39 -0
  55. package/src/run-watch.ts +432 -0
  56. package/src/run-wayfind-outline.ts +170 -0
  57. package/src/scaffold-version-sync.ts +183 -0
  58. package/src/scaffold-versions.generated.ts +12 -0
  59. package/src/trails/adapter-check.ts +244 -0
  60. package/src/trails/add-surface.ts +99 -45
  61. package/src/trails/add-trail.ts +84 -37
  62. package/src/trails/add-verify.ts +100 -30
  63. package/src/trails/compile.ts +58 -0
  64. package/src/trails/completions-complete.ts +165 -0
  65. package/src/trails/completions.ts +47 -0
  66. package/src/trails/create-adapter.ts +785 -0
  67. package/src/trails/create-scaffold.ts +402 -106
  68. package/src/trails/create-versions.ts +62 -0
  69. package/src/trails/create.ts +186 -72
  70. package/src/trails/deprecate.ts +59 -0
  71. package/src/trails/dev-clean.ts +82 -0
  72. package/src/trails/dev-reset.ts +50 -0
  73. package/src/trails/dev-stats.ts +72 -0
  74. package/src/trails/dev-support.ts +360 -0
  75. package/src/trails/doctor.ts +77 -0
  76. package/src/trails/draft-promote.ts +949 -0
  77. package/src/trails/guide.ts +70 -68
  78. package/src/trails/load-app.ts +1123 -15
  79. package/src/trails/operator-context.ts +66 -0
  80. package/src/trails/project.ts +17 -3
  81. package/src/trails/regrade.ts +4497 -0
  82. package/src/trails/release-check.ts +113 -0
  83. package/src/trails/release-smoke.ts +49 -0
  84. package/src/trails/revise.ts +53 -0
  85. package/src/trails/root-dir.ts +21 -0
  86. package/src/trails/run-example.ts +475 -0
  87. package/src/trails/run-examples.ts +129 -0
  88. package/src/trails/run.ts +434 -0
  89. package/src/trails/scaffold-json.ts +58 -0
  90. package/src/trails/survey.ts +877 -214
  91. package/src/trails/topo-activation.ts +14 -0
  92. package/src/trails/topo-constants.ts +2 -0
  93. package/src/trails/topo-history.ts +47 -0
  94. package/src/trails/topo-output-schemas.ts +259 -0
  95. package/src/trails/topo-pin.ts +38 -0
  96. package/src/trails/topo-read-support.ts +368 -0
  97. package/src/trails/topo-reports.ts +809 -0
  98. package/src/trails/topo-store-support.ts +323 -0
  99. package/src/trails/topo-support.ts +228 -0
  100. package/src/trails/topo-unpin.ts +61 -0
  101. package/src/trails/topo.ts +92 -0
  102. package/src/trails/validate.ts +27 -0
  103. package/src/trails/version-lifecycle-support.ts +936 -0
  104. package/src/trails/warden-guide.ts +134 -0
  105. package/src/trails/warden.ts +198 -58
  106. package/src/trails/wayfind-outline.ts +876 -0
  107. package/src/trails/wayfind.ts +1053 -0
  108. package/src/versions.ts +31 -0
  109. package/.turbo/turbo-build.log +0 -1
  110. package/.turbo/turbo-lint.log +0 -3
  111. package/.turbo/turbo-typecheck.log +0 -1
  112. package/__tests__/examples.test.ts +0 -6
  113. package/dist/bin/trails.d.ts +0 -3
  114. package/dist/bin/trails.d.ts.map +0 -1
  115. package/dist/bin/trails.js +0 -4
  116. package/dist/bin/trails.js.map +0 -1
  117. package/dist/src/app.d.ts +0 -2
  118. package/dist/src/app.d.ts.map +0 -1
  119. package/dist/src/app.js +0 -11
  120. package/dist/src/app.js.map +0 -1
  121. package/dist/src/clack.d.ts +0 -9
  122. package/dist/src/clack.d.ts.map +0 -1
  123. package/dist/src/clack.js +0 -84
  124. package/dist/src/clack.js.map +0 -1
  125. package/dist/src/cli.d.ts +0 -2
  126. package/dist/src/cli.d.ts.map +0 -1
  127. package/dist/src/cli.js +0 -13
  128. package/dist/src/cli.js.map +0 -1
  129. package/dist/src/trails/add-surface.d.ts +0 -13
  130. package/dist/src/trails/add-surface.d.ts.map +0 -1
  131. package/dist/src/trails/add-surface.js +0 -88
  132. package/dist/src/trails/add-surface.js.map +0 -1
  133. package/dist/src/trails/add-trail.d.ts +0 -10
  134. package/dist/src/trails/add-trail.d.ts.map +0 -1
  135. package/dist/src/trails/add-trail.js +0 -77
  136. package/dist/src/trails/add-trail.js.map +0 -1
  137. package/dist/src/trails/add-verify.d.ts +0 -10
  138. package/dist/src/trails/add-verify.d.ts.map +0 -1
  139. package/dist/src/trails/add-verify.js +0 -67
  140. package/dist/src/trails/add-verify.js.map +0 -1
  141. package/dist/src/trails/create-scaffold.d.ts +0 -15
  142. package/dist/src/trails/create-scaffold.d.ts.map +0 -1
  143. package/dist/src/trails/create-scaffold.js +0 -288
  144. package/dist/src/trails/create-scaffold.js.map +0 -1
  145. package/dist/src/trails/create.d.ts +0 -22
  146. package/dist/src/trails/create.d.ts.map +0 -1
  147. package/dist/src/trails/create.js +0 -121
  148. package/dist/src/trails/create.js.map +0 -1
  149. package/dist/src/trails/guide.d.ts +0 -11
  150. package/dist/src/trails/guide.d.ts.map +0 -1
  151. package/dist/src/trails/guide.js +0 -80
  152. package/dist/src/trails/guide.js.map +0 -1
  153. package/dist/src/trails/load-app.d.ts +0 -4
  154. package/dist/src/trails/load-app.d.ts.map +0 -1
  155. package/dist/src/trails/load-app.js +0 -24
  156. package/dist/src/trails/load-app.js.map +0 -1
  157. package/dist/src/trails/project.d.ts +0 -8
  158. package/dist/src/trails/project.d.ts.map +0 -1
  159. package/dist/src/trails/project.js +0 -43
  160. package/dist/src/trails/project.js.map +0 -1
  161. package/dist/src/trails/survey.d.ts +0 -31
  162. package/dist/src/trails/survey.d.ts.map +0 -1
  163. package/dist/src/trails/survey.js +0 -221
  164. package/dist/src/trails/survey.js.map +0 -1
  165. package/dist/src/trails/warden.d.ts +0 -19
  166. package/dist/src/trails/warden.d.ts.map +0 -1
  167. package/dist/src/trails/warden.js +0 -88
  168. package/dist/src/trails/warden.js.map +0 -1
  169. package/dist/tsconfig.tsbuildinfo +0 -1
  170. package/src/__tests__/create.test.ts +0 -349
  171. package/src/__tests__/guide.test.ts +0 -91
  172. package/src/__tests__/load-app.test.ts +0 -15
  173. package/src/__tests__/survey.test.ts +0 -159
  174. package/src/__tests__/warden.test.ts +0 -74
  175. package/tsconfig.json +0 -9
@@ -0,0 +1,4497 @@
1
+ /**
2
+ * `regrade` trail -- Run downstream migration checks and safe rewrites.
3
+ */
4
+
5
+ import {
6
+ InternalError,
7
+ NotFoundError,
8
+ Result,
9
+ ValidationError,
10
+ matchesAnyPathGlob,
11
+ pathScopeSchema,
12
+ trail,
13
+ validateOutput,
14
+ } from '@ontrails/core';
15
+ import type { PathScope, Result as TrailsResult } from '@ontrails/core';
16
+ import {
17
+ applyPreparedRegradeRun,
18
+ applyPreparedVocabularyRegradeRun,
19
+ createGovernedAstIdentifierRenameClasses,
20
+ loadWardenRegradeClasses,
21
+ prepareRegradeRun,
22
+ prepareVocabularyRegradeRun,
23
+ readVocabularyTransitionRecord,
24
+ regradeReportOutput,
25
+ runFileRenameRegrade,
26
+ runRegrade,
27
+ runVocabularyRegrade,
28
+ transitionRecordReportWithSummary,
29
+ validatePreparedRegradeRun,
30
+ vocabularyRegradeTransitionForInput,
31
+ vocabularyDispositionValues,
32
+ vocabularyRegradePlanSchema,
33
+ vocabularyRegradePlanForInput,
34
+ writeVocabularyTransitionRecord,
35
+ } from '@ontrails/regrade';
36
+ import type {
37
+ FileRenameRegradeRun,
38
+ PreparedRegradeRun,
39
+ PreparedRegradeRunIdentity,
40
+ PreparedVocabularyRegradeRun,
41
+ RegradeApplySummary,
42
+ RegradeReport,
43
+ RegradeReportEntry,
44
+ RegradeScanDirectoryBucket,
45
+ RegradeScanExtensionBucket,
46
+ VocabularyPreserveRule,
47
+ VocabularyRegradePlan,
48
+ VocabularyPreserveInventoryEntry,
49
+ } from '@ontrails/regrade';
50
+ import { listGovernedVocabularyTransitions } from '@ontrails/warden';
51
+ import { execFileSync } from 'node:child_process';
52
+ import {
53
+ existsSync,
54
+ mkdirSync,
55
+ readdirSync,
56
+ readFileSync,
57
+ writeFileSync,
58
+ } from 'node:fs';
59
+ import type { Dirent } from 'node:fs';
60
+ import { basename, dirname, extname, isAbsolute, join, posix } from 'node:path';
61
+ import { z } from 'zod';
62
+
63
+ import {
64
+ auditRegradeHistory,
65
+ regradeAuditInputSchema,
66
+ regradeAuditOutputSchema,
67
+ } from '../regrade/audit.js';
68
+ import { loadRegradeConfig } from '../regrade/config.js';
69
+ import {
70
+ RegradeLifecycleTracker,
71
+ regradeLifecycleSchema,
72
+ } from '../regrade/lifecycle.js';
73
+ import {
74
+ appendRegradeHistoryRun,
75
+ consumeActiveRegradePlanAfterHistoryWrite,
76
+ readRegradeHistoryArtifact,
77
+ regradeHistoryPathForPlan,
78
+ resolveRegradeHistoryPath,
79
+ validateGovernedRegradePlan,
80
+ verifyRegradeHistoryRuns,
81
+ } from '../regrade/history.js';
82
+ import type { RegradeHistorySummary } from '../regrade/history.js';
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';
91
+ import { deriveRegradePlanDerivation } from '../regrade/plan-derivation.js';
92
+ import {
93
+ preparedRegradeRunIdentity,
94
+ validatePreparedRegradePlanArtifact,
95
+ } from '../regrade/prepared-run.js';
96
+ import {
97
+ regradeApplyErrorAfterRollback,
98
+ snapshotRegradeSources,
99
+ } from '../regrade/source-transaction.js';
100
+ import {
101
+ REGRADE_PLAN_SCHEMA_VERSION,
102
+ canonicalJsonStringify,
103
+ currentRegradeSourceHashMatches,
104
+ isGeneratedRegradeArtifactPath,
105
+ regradePlanArtifactSchema,
106
+ regradePlanPathForPlan,
107
+ regradeSourceHash,
108
+ rootRelativePath,
109
+ } from '../regrade/plan-artifact.js';
110
+ import type {
111
+ ClassRegradePlan,
112
+ RegradePlanArtifact,
113
+ RegradePlanBody,
114
+ RegradePlanExpansion,
115
+ VocabularyRegradePlanArtifact,
116
+ } from '../regrade/plan-artifact.js';
117
+ import { resolveTrailRootDir } from './root-dir.js';
118
+
119
+ const regradePathScopeInputSchema = pathScopeSchema.extend({
120
+ exclude: pathScopeSchema.shape.exclude.describe(
121
+ 'Root-relative path globs to exclude during Regrade collection'
122
+ ),
123
+ extensions: pathScopeSchema.shape.extensions.describe(
124
+ 'Source file extensions to scan during Regrade collection'
125
+ ),
126
+ include: pathScopeSchema.shape.include.describe(
127
+ 'Root-relative path patterns to include in vocabulary regrade mode'
128
+ ),
129
+ policyClassified: z
130
+ .array(
131
+ z.object({
132
+ disposition: z.enum(vocabularyDispositionValues),
133
+ expectMatches: z.boolean().optional(),
134
+ paths: z.array(z.string().min(1)).min(1),
135
+ reason: z.string().min(1),
136
+ })
137
+ )
138
+ .optional()
139
+ .describe('Protected paths scanned and counted without default rewrites'),
140
+ teachingSurfaces: z
141
+ .array(z.string().min(1))
142
+ .optional()
143
+ .describe('Expected current teaching-surface path patterns'),
144
+ });
145
+
146
+ const regradePreserveRuleInputSchema = z.object({
147
+ disposition: z
148
+ .enum(vocabularyDispositionValues)
149
+ .optional()
150
+ .describe('Classification to assign to occurrences this rule preserves'),
151
+ forms: z
152
+ .array(z.string().min(1))
153
+ .optional()
154
+ .describe('Matched forms this preserve rule applies to'),
155
+ paths: z
156
+ .array(z.string())
157
+ .optional()
158
+ .describe('Root-relative path globs where this preserve rule applies'),
159
+ pattern: z.string().min(1).describe('Regex or literal pattern to preserve'),
160
+ reason: z.string().optional().describe('Why this form is preserved'),
161
+ });
162
+
163
+ const regradePreserveInputSchema = z.union([
164
+ z.string().min(1),
165
+ regradePreserveRuleInputSchema,
166
+ ]);
167
+
168
+ const regradeFileRenameInputSchema = z.object({
169
+ from: z.string().min(1).describe('Root-relative source file path'),
170
+ to: z.string().min(1).describe('Root-relative target file path'),
171
+ });
172
+
173
+ const regradeInputSchema = regradePathScopeInputSchema.extend({
174
+ apply: z
175
+ .boolean()
176
+ .default(false)
177
+ .describe('Write safe rewrites to disk; dry-run report only by default'),
178
+ check: z
179
+ .boolean()
180
+ .default(false)
181
+ .describe(
182
+ 'Legacy compatibility: check a saved transition record gate without applying rewrites; prefer `regrade check` for saved plans'
183
+ ),
184
+ classIds: z
185
+ .array(z.string())
186
+ .optional()
187
+ .describe('Regrade class ids to run (defaults to all built-in classes)'),
188
+ configPath: z
189
+ .string()
190
+ .optional()
191
+ .describe('Path to a Trails config file with regrade defaults'),
192
+ fileRenames: z
193
+ .array(regradeFileRenameInputSchema)
194
+ .optional()
195
+ .describe('Governed file moves with references derived from scope'),
196
+ from: z
197
+ .string()
198
+ .min(1)
199
+ .optional()
200
+ .describe('Source vocabulary term for a vocabulary regrade'),
201
+ includeEntries: z
202
+ .enum(['actionable', 'all'])
203
+ .default('actionable')
204
+ .describe(
205
+ 'Report entry detail to include; counts always cover the full run'
206
+ ),
207
+ intent: z
208
+ .string()
209
+ .optional()
210
+ .describe('Human-authored migration intent for a vocabulary regrade'),
211
+ overrides: z
212
+ .record(z.string().min(1), z.string().min(1))
213
+ .optional()
214
+ .describe('Explicit source-form to target-form mappings'),
215
+ planRecord: z
216
+ .string()
217
+ .optional()
218
+ .describe(
219
+ 'Legacy compatibility path to a confirmed transition record; prefer `regrade check`, `regrade preview`, and `regrade apply` with saved plans'
220
+ ),
221
+ preserve: z
222
+ .array(regradePreserveInputSchema)
223
+ .optional()
224
+ .describe(
225
+ 'Regex or literal contexts, or structured preserve rules, for a vocabulary regrade'
226
+ ),
227
+ rootDir: z.string().optional().describe('Workspace root directory'),
228
+ to: z
229
+ .string()
230
+ .min(1)
231
+ .optional()
232
+ .describe('Target vocabulary term for a vocabulary regrade'),
233
+ writeRecord: z
234
+ .boolean()
235
+ .default(false)
236
+ .describe(
237
+ 'Legacy compatibility: persist dry-run or apply evidence as a transition record; prefer `regrade plan` and plan history'
238
+ ),
239
+ });
240
+
241
+ type RegradeInput = z.output<typeof regradeInputSchema>;
242
+
243
+ const regradePlanSummarySchema = z.object({
244
+ classIds: z
245
+ .array(z.string())
246
+ .optional()
247
+ .describe('Class ids for a class-mode plan'),
248
+ expansionPending: z
249
+ .number()
250
+ .optional()
251
+ .describe('Pending staged expansion candidates on this plan'),
252
+ from: z.string().optional().describe('Source term for a vocabulary plan'),
253
+ kind: z.enum(['class', 'vocabulary']).describe('Regrade plan kind'),
254
+ path: z.string(),
255
+ schemaVersion: z.number(),
256
+ status: z.enum(['active', 'stale']),
257
+ to: z.string().optional().describe('Target term for a vocabulary plan'),
258
+ });
259
+
260
+ const regradePlansOutputSchema = z.object({
261
+ plans: z.array(regradePlanSummarySchema),
262
+ });
263
+
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({
277
+ check: z
278
+ .object({
279
+ plan: z
280
+ .string()
281
+ .describe(
282
+ 'Saved Regrade plan or graduated history path that passed checks'
283
+ ),
284
+ status: z.literal('passed').describe('Check result'),
285
+ })
286
+ .describe('Saved Regrade plan check result'),
287
+ });
288
+
289
+ const regradePlanInputSchema = regradePathScopeInputSchema.extend({
290
+ classIds: z
291
+ .array(z.string().min(1))
292
+ .optional()
293
+ .describe(
294
+ 'Regrade class ids for a class-mode plan; pair with `type: class` on the CLI so the plan subcommand wins over `regrade` positionals'
295
+ ),
296
+ configPath: z
297
+ .string()
298
+ .optional()
299
+ .describe('Path to a Trails config file with regrade defaults'),
300
+ expand: z
301
+ .boolean()
302
+ .default(false)
303
+ .describe('Stage wide-net review candidates in the saved plan'),
304
+ fileRenames: z
305
+ .array(regradeFileRenameInputSchema)
306
+ .optional()
307
+ .describe('Governed file moves with references derived from scope'),
308
+ fresh: z
309
+ .boolean()
310
+ .default(false)
311
+ .describe(
312
+ 'Replace an existing active plan instead of preserving authored fields'
313
+ ),
314
+ from: z
315
+ .string()
316
+ .min(1)
317
+ .optional()
318
+ .describe('Source vocabulary term or phrase'),
319
+ include: pathScopeSchema.shape.include.describe(
320
+ 'Root-relative path globs to collect during the plan run'
321
+ ),
322
+ includeEntries: z
323
+ .enum(['actionable', 'all'])
324
+ .default('actionable')
325
+ .describe(
326
+ 'Report entry detail to inspect while deriving plan freshness and expansion'
327
+ ),
328
+ intent: z
329
+ .string()
330
+ .optional()
331
+ .describe('Human-authored migration intent for the plan'),
332
+ name: z
333
+ .string()
334
+ .min(1)
335
+ .optional()
336
+ .describe(
337
+ 'Transition name for a class-mode plan; names the plan and history files'
338
+ ),
339
+ overrides: z
340
+ .record(z.string().min(1), z.string().min(1))
341
+ .optional()
342
+ .describe('Explicit source-form to target-form mappings'),
343
+ preserve: z
344
+ .array(regradePreserveInputSchema)
345
+ .optional()
346
+ .describe(
347
+ 'Regex or literal contexts, or structured preserve rules, for a vocabulary regrade'
348
+ ),
349
+ rootDir: z.string().optional().describe('Workspace root directory'),
350
+ to: z.string().min(1).optional().describe('Target vocabulary term or phrase'),
351
+ type: z
352
+ .enum(['class', 'vocabulary'])
353
+ .optional()
354
+ .describe(
355
+ 'Optional plan type qualifier when a source/target pair is ambiguous'
356
+ ),
357
+ });
358
+
359
+ const regradePlanReferenceInputSchema = z.object({
360
+ includeEntries: z
361
+ .enum(['actionable', 'all'])
362
+ .default('actionable')
363
+ .describe('Report entry detail to include while evaluating a saved plan'),
364
+ plan: z
365
+ .string()
366
+ .optional()
367
+ .describe('Plan name or path; omitted when exactly one active plan exists'),
368
+ rootDir: z.string().optional().describe('Workspace root directory'),
369
+ });
370
+
371
+ const regradeApplyPlanInputSchema = regradePlanReferenceInputSchema;
372
+
373
+ const regradeAdjustInputSchema = z.object({
374
+ rootDir: z.string().optional().describe('Workspace root directory'),
375
+ transition: z
376
+ .string()
377
+ .min(1)
378
+ .describe('Graduated transition name, e.g. <transition-name>'),
379
+ });
380
+
381
+ type RegradePlanInput = z.output<typeof regradePlanInputSchema>;
382
+ type RegradePlanReferenceInput = z.output<
383
+ typeof regradePlanReferenceInputSchema
384
+ >;
385
+ type RegradeApplyPlanInput = z.output<typeof regradeApplyPlanInputSchema>;
386
+ type RegradeAdjustInput = z.output<typeof regradeAdjustInputSchema>;
387
+
388
+ const hasVocabularyInput = (input: RegradeInput) =>
389
+ input.fileRenames !== undefined ||
390
+ input.from !== undefined ||
391
+ input.check ||
392
+ input.include !== undefined ||
393
+ input.intent !== undefined ||
394
+ input.overrides !== undefined ||
395
+ input.planRecord !== undefined ||
396
+ input.preserve !== undefined ||
397
+ input.policyClassified !== undefined ||
398
+ input.teachingSurfaces !== undefined ||
399
+ input.to !== undefined;
400
+
401
+ const classModeCollection = (
402
+ input: RegradeInput,
403
+ configScope?: RegradeConfigScope | undefined
404
+ ):
405
+ | {
406
+ readonly exclude?: readonly string[];
407
+ readonly extensions?: readonly string[];
408
+ }
409
+ | undefined => {
410
+ if (
411
+ configScope?.exclude === undefined &&
412
+ configScope?.extensions === undefined &&
413
+ input.exclude === undefined &&
414
+ input.extensions === undefined
415
+ ) {
416
+ return undefined;
417
+ }
418
+
419
+ return {
420
+ ...(configScope?.exclude === undefined
421
+ ? {}
422
+ : { exclude: configScope.exclude }),
423
+ ...(configScope?.extensions === undefined
424
+ ? {}
425
+ : { extensions: configScope.extensions }),
426
+ ...(input.exclude === undefined ? {} : { exclude: input.exclude }),
427
+ ...(input.extensions === undefined ? {} : { extensions: input.extensions }),
428
+ };
429
+ };
430
+
431
+ interface RegradeConfigScope {
432
+ readonly exclude?: PathScope['exclude'] | undefined;
433
+ readonly extensions?: PathScope['extensions'] | undefined;
434
+ readonly include?: PathScope['include'] | undefined;
435
+ }
436
+
437
+ interface RegradeCollectionScope {
438
+ readonly exclude?: readonly string[];
439
+ readonly extensions?: readonly string[];
440
+ readonly include?: readonly string[];
441
+ }
442
+
443
+ const symbolSourceExtensions: readonly string[] = [
444
+ '.cjs',
445
+ '.cts',
446
+ '.js',
447
+ '.jsx',
448
+ '.mjs',
449
+ '.mts',
450
+ '.ts',
451
+ '.tsx',
452
+ ] as const;
453
+
454
+ const vocabularyProseExtensions: readonly string[] = [
455
+ '.md',
456
+ '.mdx',
457
+ '.txt',
458
+ ] as const;
459
+
460
+ const vocabularyEvidenceExtensions: readonly string[] = [
461
+ ...symbolSourceExtensions,
462
+ ...vocabularyProseExtensions,
463
+ '.json',
464
+ '.jsonc',
465
+ '.yaml',
466
+ '.yml',
467
+ ] as const;
468
+
469
+ const normalizeExtension = (extension: string): string =>
470
+ extension === '' || extension.startsWith('.') ? extension : `.${extension}`;
471
+
472
+ const compileVocabularyPreservePattern = (pattern: string): RegExp => {
473
+ try {
474
+ return new RegExp(pattern);
475
+ } catch {
476
+ return new RegExp(pattern.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&'));
477
+ }
478
+ };
479
+
480
+ const globalVocabularyPreservePattern = (pattern: RegExp): RegExp => {
481
+ const flags = pattern.flags.includes('g')
482
+ ? pattern.flags
483
+ : `${pattern.flags}g`;
484
+ return new RegExp(pattern.source, flags);
485
+ };
486
+
487
+ const preservePatternOverlapsSpan = (
488
+ pattern: RegExp,
489
+ source: string,
490
+ start: number,
491
+ end: number
492
+ ): boolean => {
493
+ for (const match of source.matchAll(
494
+ globalVocabularyPreservePattern(pattern)
495
+ )) {
496
+ const matchStart = match.index ?? 0;
497
+ const matchEnd = matchStart + match[0].length;
498
+ if (matchStart !== matchEnd && start < matchEnd && matchStart < end) {
499
+ return true;
500
+ }
501
+ }
502
+ return false;
503
+ };
504
+
505
+ const preserveRuleMatchesSymbolOccurrence = (
506
+ rule: VocabularyPreserveRule,
507
+ occurrence: {
508
+ readonly form: string;
509
+ readonly path: string;
510
+ readonly source: string;
511
+ readonly start: number;
512
+ readonly end: number;
513
+ }
514
+ ): boolean => {
515
+ if (rule.forms !== undefined && !rule.forms.includes(occurrence.form)) {
516
+ return false;
517
+ }
518
+ if (
519
+ rule.paths !== undefined &&
520
+ !matchesAnyPathGlob(occurrence.path, rule.paths)
521
+ ) {
522
+ return false;
523
+ }
524
+ const pattern = compileVocabularyPreservePattern(rule.pattern);
525
+ return (
526
+ pattern.test(occurrence.form) ||
527
+ preservePatternOverlapsSpan(
528
+ pattern,
529
+ occurrence.source,
530
+ occurrence.start,
531
+ occurrence.end
532
+ )
533
+ );
534
+ };
535
+
536
+ const symbolOccurrenceIsPreserved = (
537
+ rules: readonly VocabularyPreserveRule[] | undefined,
538
+ occurrence: {
539
+ readonly form: string;
540
+ readonly path: string;
541
+ readonly source: string;
542
+ readonly start: number;
543
+ readonly end: number;
544
+ }
545
+ ): boolean =>
546
+ rules?.some((rule) =>
547
+ preserveRuleMatchesSymbolOccurrence(rule, occurrence)
548
+ ) ?? false;
549
+
550
+ const symbolOccurrenceIsPolicyClassified = (
551
+ scope: VocabularyRegradePlan['scope'] | undefined,
552
+ path: string
553
+ ): boolean =>
554
+ scope?.policyClassified?.some((policy) =>
555
+ matchesAnyPathGlob(path, policy.paths)
556
+ ) ?? false;
557
+
558
+ const vocabularyScopeFromConfig = (
559
+ scope: RegradeConfigScope | undefined
560
+ ): VocabularyRegradePlan['scope'] | undefined =>
561
+ scope === undefined
562
+ ? undefined
563
+ : {
564
+ ...(scope.exclude === undefined ? {} : { exclude: scope.exclude }),
565
+ ...(scope.extensions === undefined
566
+ ? {}
567
+ : { extensions: scope.extensions }),
568
+ ...(scope.include === undefined ? {} : { include: scope.include }),
569
+ };
570
+
571
+ const vocabularyPreserveFromInput = (
572
+ preserve: RegradeInput['preserve']
573
+ ): readonly VocabularyPreserveRule[] | undefined =>
574
+ preserve?.map((rule) => {
575
+ if (typeof rule === 'string') {
576
+ return { pattern: rule, reason: 'preserved-by-operator-input' };
577
+ }
578
+
579
+ return {
580
+ ...(rule.disposition === undefined
581
+ ? {}
582
+ : { disposition: rule.disposition }),
583
+ ...(rule.forms === undefined ? {} : { forms: rule.forms }),
584
+ ...(rule.paths === undefined ? {} : { paths: rule.paths }),
585
+ pattern: rule.pattern,
586
+ ...(rule.reason === undefined ? {} : { reason: rule.reason }),
587
+ };
588
+ });
589
+
590
+ const vocabularyRegistryPlanForInput = (
591
+ input: RegradeInput
592
+ ): VocabularyRegradePlan | undefined =>
593
+ input.from === undefined || input.to === undefined
594
+ ? undefined
595
+ : (vocabularyRegradePlanForInput(input.from, input.to) ?? undefined);
596
+
597
+ const uniqueSorted = (values: readonly string[]): readonly string[] =>
598
+ [...new Set(values)].toSorted((left, right) => left.localeCompare(right));
599
+
600
+ const uniqueInOrder = (values: readonly string[]): readonly string[] => [
601
+ ...new Set(values),
602
+ ];
603
+
604
+ const mergeScopeList = (
605
+ left: readonly string[] | undefined,
606
+ right: readonly string[] | undefined
607
+ ): readonly string[] | undefined => {
608
+ const merged = uniqueInOrder([...(left ?? []), ...(right ?? [])]);
609
+ return merged.length === 0 ? undefined : merged;
610
+ };
611
+
612
+ const scopePathsOverlap = (left: string, right: string): boolean =>
613
+ left === right ||
614
+ matchesAnyPathGlob(left, [right]) ||
615
+ matchesAnyPathGlob(right, [left]);
616
+
617
+ const mergeVocabularyScope = (
618
+ registryScope: VocabularyRegradePlan['scope'] | undefined,
619
+ configScope: VocabularyRegradePlan['scope'] | undefined,
620
+ input: Pick<
621
+ RegradeInput,
622
+ | 'exclude'
623
+ | 'extensions'
624
+ | 'include'
625
+ | 'policyClassified'
626
+ | 'teachingSurfaces'
627
+ >
628
+ ): VocabularyRegradePlan['scope'] | undefined => {
629
+ const callerExclude = input.exclude ?? configScope?.exclude;
630
+ const callerInclude = input.include ?? configScope?.include;
631
+ const extensions =
632
+ input.extensions ?? configScope?.extensions ?? registryScope?.extensions;
633
+ const exclude = mergeScopeList(registryScope?.exclude, callerExclude);
634
+ const include = mergeScopeList(registryScope?.include, callerInclude);
635
+ const policyClassified = [
636
+ ...(registryScope?.policyClassified ?? [])
637
+ .map((policy) => ({
638
+ ...policy,
639
+ paths: policy.paths.filter(
640
+ (path) =>
641
+ !callerExclude?.some((excludedPath) =>
642
+ scopePathsOverlap(path, excludedPath)
643
+ )
644
+ ),
645
+ }))
646
+ .filter((policy) => policy.paths.length > 0),
647
+ ...(input.policyClassified ?? []),
648
+ ];
649
+ const teachingSurfaces = mergeScopeList(
650
+ registryScope?.teachingSurfaces,
651
+ input.teachingSurfaces
652
+ );
653
+
654
+ const fields = {
655
+ exclude,
656
+ extensions,
657
+ include,
658
+ policyClassified:
659
+ policyClassified.length === 0 ? undefined : policyClassified,
660
+ teachingSurfaces,
661
+ };
662
+ const scope = Object.fromEntries(
663
+ Object.entries(fields).filter(([, value]) => value !== undefined)
664
+ ) as NonNullable<VocabularyRegradePlan['scope']>;
665
+ return Object.keys(scope).length === 0 ? undefined : scope;
666
+ };
667
+
668
+ const mergeNumericRecords = (
669
+ left: Readonly<Record<string, number>>,
670
+ right: Readonly<Record<string, number>>
671
+ ): Readonly<Record<string, number>> => {
672
+ const keys = uniqueSorted([...Object.keys(left), ...Object.keys(right)]);
673
+ return Object.fromEntries(
674
+ keys.map((key) => [key, Math.max(left[key] ?? 0, right[key] ?? 0)])
675
+ );
676
+ };
677
+
678
+ const sumNumericRecords = (
679
+ left: Readonly<Record<string, number>>,
680
+ right: Readonly<Record<string, number>>
681
+ ): Readonly<Record<string, number>> => {
682
+ const keys = uniqueSorted([...Object.keys(left), ...Object.keys(right)]);
683
+ return Object.fromEntries(
684
+ keys.map((key) => [key, (left[key] ?? 0) + (right[key] ?? 0)])
685
+ );
686
+ };
687
+
688
+ const extensionForPath = (path: string): string => {
689
+ const name = path.split('/').at(-1) ?? path;
690
+ const dot = name.lastIndexOf('.');
691
+ return dot <= 0 || dot === name.length - 1 ? '<none>' : name.slice(dot);
692
+ };
693
+
694
+ const topLevelForPath = (path: string): string => {
695
+ const [segment] = path.split('/');
696
+ return segment === undefined || segment.length === 0 ? '.' : segment;
697
+ };
698
+
699
+ const countFilesBy = (
700
+ paths: readonly string[],
701
+ keyForPath: (path: string) => string
702
+ ): Map<string, number> => {
703
+ const counts = new Map<string, number>();
704
+ for (const path of new Set(paths)) {
705
+ const key = keyForPath(path);
706
+ counts.set(key, (counts.get(key) ?? 0) + 1);
707
+ }
708
+ return counts;
709
+ };
710
+
711
+ const countOccurrencesBy = (
712
+ paths: readonly string[],
713
+ keyForPath: (path: string) => string
714
+ ): Map<string, number> => {
715
+ const counts = new Map<string, number>();
716
+ for (const path of paths) {
717
+ const key = keyForPath(path);
718
+ counts.set(key, (counts.get(key) ?? 0) + 1);
719
+ }
720
+ return counts;
721
+ };
722
+
723
+ const sortBuckets = <T extends { readonly files: number }>(
724
+ left: T & { readonly key: string },
725
+ right: T & { readonly key: string }
726
+ ): number => right.files - left.files || left.key.localeCompare(right.key);
727
+
728
+ const mergedDirectoryBuckets = (
729
+ matchedPaths: readonly string[],
730
+ occurrencePaths: readonly string[]
731
+ ): readonly RegradeScanDirectoryBucket[] => {
732
+ const fileCounts = countFilesBy(matchedPaths, topLevelForPath);
733
+ const occurrenceCounts = countOccurrencesBy(occurrencePaths, topLevelForPath);
734
+ const buckets: (RegradeScanDirectoryBucket & { readonly key: string })[] = [];
735
+ for (const [path, files] of fileCounts.entries()) {
736
+ buckets.push(
737
+ occurrencePaths.length === 0
738
+ ? { files, key: path, path }
739
+ : {
740
+ files,
741
+ key: path,
742
+ occurrences: occurrenceCounts.get(path) ?? 0,
743
+ path,
744
+ }
745
+ );
746
+ }
747
+ return buckets
748
+ .toSorted(sortBuckets)
749
+ .map(({ key: _key, ...bucket }) => bucket);
750
+ };
751
+
752
+ const mergedExtensionBuckets = (
753
+ matchedPaths: readonly string[],
754
+ occurrencePaths: readonly string[]
755
+ ): readonly RegradeScanExtensionBucket[] => {
756
+ const fileCounts = countFilesBy(matchedPaths, extensionForPath);
757
+ const occurrenceCounts = countOccurrencesBy(
758
+ occurrencePaths,
759
+ extensionForPath
760
+ );
761
+ const buckets: (RegradeScanExtensionBucket & { readonly key: string })[] = [];
762
+ for (const [extension, files] of fileCounts.entries()) {
763
+ buckets.push(
764
+ occurrencePaths.length === 0
765
+ ? { extension, files, key: extension }
766
+ : {
767
+ extension,
768
+ files,
769
+ key: extension,
770
+ occurrences: occurrenceCounts.get(extension) ?? 0,
771
+ }
772
+ );
773
+ }
774
+ return buckets
775
+ .toSorted(sortBuckets)
776
+ .map(({ key: _key, ...bucket }) => bucket);
777
+ };
778
+
779
+ const actionableEntryPaths = (
780
+ entries: readonly RegradeReportEntry[]
781
+ ): readonly string[] =>
782
+ entries.flatMap((entry) =>
783
+ entry.outcome === 'rewrite' || entry.outcome === 'needs-review'
784
+ ? [entry.path]
785
+ : []
786
+ );
787
+
788
+ const mergeApplySummary = (
789
+ left: RegradeApplySummary | undefined,
790
+ right: RegradeApplySummary | undefined
791
+ ): RegradeApplySummary | undefined => {
792
+ if (left === undefined && right === undefined) {
793
+ return undefined;
794
+ }
795
+
796
+ const leftValue = left ?? {
797
+ applied: 0,
798
+ filesChanged: 0,
799
+ review: 0,
800
+ skipped: 0,
801
+ unknown: 0,
802
+ };
803
+ const rightValue = right ?? {
804
+ applied: 0,
805
+ filesChanged: 0,
806
+ review: 0,
807
+ skipped: 0,
808
+ unknown: 0,
809
+ };
810
+
811
+ return {
812
+ applied: leftValue.applied + rightValue.applied,
813
+ filesChanged: leftValue.filesChanged + rightValue.filesChanged,
814
+ review: leftValue.review + rightValue.review,
815
+ skipped: Math.max(leftValue.skipped, rightValue.skipped),
816
+ unknown: leftValue.unknown + rightValue.unknown,
817
+ };
818
+ };
819
+
820
+ type VocabularyTransitionRunReport = NonNullable<
821
+ RegradeReport['run']
822
+ >['report'];
823
+
824
+ const transitionRunReportForRegradeReport = (
825
+ report: RegradeReport
826
+ ): VocabularyTransitionRunReport => {
827
+ const applied = report.apply?.applied ?? 0;
828
+ const filesChanged = report.apply?.filesChanged ?? 0;
829
+ const modified = report.apply === undefined ? report.rewritten : 0;
830
+ const deferred = report.review;
831
+ const open =
832
+ report.apply === undefined
833
+ ? report.rewritten + report.review
834
+ : report.review;
835
+ const remainingByDisposition =
836
+ open === 0 ? {} : { 'code-context-out-of-engine': open };
837
+ const reasons = [
838
+ ...(report.apply === undefined && report.rewritten > 0
839
+ ? ['safe-modifications-not-yet-applied']
840
+ : []),
841
+ ...(report.review > 0 ? ['deferred-forms-or-occurrences'] : []),
842
+ ];
843
+
844
+ return {
845
+ applied,
846
+ deferred,
847
+ dispositions: remainingByDisposition,
848
+ filesChanged,
849
+ gate: {
850
+ reasons,
851
+ remaining: open,
852
+ remainingByDisposition,
853
+ status: open === 0 && reasons.length === 0 ? 'green' : 'open',
854
+ },
855
+ modified,
856
+ open,
857
+ scopeTiers: {
858
+ 'in-scope': report.rewritten + report.review,
859
+ 'policy-classified': 0,
860
+ },
861
+ skipped: report.skipped,
862
+ teachingSurfaces: { expected: [], missing: [], touched: [] },
863
+ };
864
+ };
865
+
866
+ const mergeTransitionRunReportWithSymbol = (
867
+ vocabularyReport: VocabularyTransitionRunReport,
868
+ symbolReport: RegradeReport
869
+ ): VocabularyTransitionRunReport => {
870
+ const symbolRunReport = transitionRunReportForRegradeReport(symbolReport);
871
+ const modified = vocabularyReport.modified + symbolRunReport.modified;
872
+ const open = vocabularyReport.open + symbolRunReport.open;
873
+ const dispositions = sumNumericRecords(
874
+ vocabularyReport.dispositions,
875
+ symbolRunReport.dispositions
876
+ );
877
+ const remainingByDisposition = sumNumericRecords(
878
+ vocabularyReport.gate.remainingByDisposition,
879
+ symbolRunReport.gate.remainingByDisposition
880
+ );
881
+ const reasons = uniqueSorted([
882
+ ...vocabularyReport.gate.reasons,
883
+ ...symbolRunReport.gate.reasons,
884
+ ]);
885
+
886
+ return {
887
+ applied: vocabularyReport.applied + symbolRunReport.applied,
888
+ deferred: vocabularyReport.deferred + symbolRunReport.deferred,
889
+ dispositions,
890
+ filesChanged: vocabularyReport.filesChanged + symbolRunReport.filesChanged,
891
+ gate: {
892
+ reasons,
893
+ remaining: open,
894
+ remainingByDisposition,
895
+ status: open === 0 && reasons.length === 0 ? 'green' : 'open',
896
+ },
897
+ modified,
898
+ open,
899
+ scopeTiers: {
900
+ 'in-scope':
901
+ vocabularyReport.scopeTiers['in-scope'] +
902
+ symbolRunReport.scopeTiers['in-scope'],
903
+ 'policy-classified':
904
+ vocabularyReport.scopeTiers['policy-classified'] +
905
+ symbolRunReport.scopeTiers['policy-classified'],
906
+ },
907
+ skipped: vocabularyReport.skipped + symbolRunReport.skipped,
908
+ teachingSurfaces: vocabularyReport.teachingSurfaces,
909
+ };
910
+ };
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
+
925
+ const mergeRegradeReports = (
926
+ vocabularyReport: RegradeReport,
927
+ symbolReport: RegradeReport
928
+ ): RegradeReport => {
929
+ const entries = [
930
+ ...vocabularyReport.entries,
931
+ ...symbolReport.entries,
932
+ ].toSorted(
933
+ (left, right) =>
934
+ left.path.localeCompare(right.path) ||
935
+ (left.classId ?? '').localeCompare(right.classId ?? '')
936
+ );
937
+ const matchedPaths = actionableEntryPaths(entries);
938
+ const rewritten = new Set(
939
+ entries
940
+ .filter((entry) => entry.outcome === 'rewrite')
941
+ .map((entry) => entry.path)
942
+ ).size;
943
+ const review = new Set(
944
+ entries
945
+ .filter((entry) => entry.outcome === 'needs-review')
946
+ .map((entry) => entry.path)
947
+ ).size;
948
+ const matched = new Set(matchedPaths).size;
949
+ const occurrencePaths =
950
+ vocabularyReport.run?.ledger.occurrences.map(
951
+ (occurrence) => occurrence.path
952
+ ) ?? [];
953
+ const skippedByReason = mergeNumericRecords(
954
+ vocabularyReport.skipsByReason,
955
+ symbolReport.skipsByReason
956
+ );
957
+ const apply = mergeApplySummary(vocabularyReport.apply, symbolReport.apply);
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;
967
+ const run =
968
+ vocabularyReport.run === undefined
969
+ ? undefined
970
+ : {
971
+ ...vocabularyReport.run,
972
+ report: mergeTransitionRunReportWithSymbol(
973
+ vocabularyReport.run.report,
974
+ symbolReport
975
+ ),
976
+ };
977
+
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,
995
+ },
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
+ ]),
1008
+ },
1009
+ scannedPaths
1010
+ );
1011
+ };
1012
+
1013
+ const vocabularySymbolCollection = (
1014
+ scope: VocabularyRegradePlan['scope'] | undefined
1015
+ ): RegradeCollectionScope | null | undefined => {
1016
+ const exclude = scope?.exclude;
1017
+ const extensions = scope?.extensions;
1018
+ const include = scope?.include;
1019
+ const explicitExtensions = extensions !== undefined;
1020
+ const codeExtensions =
1021
+ extensions === undefined
1022
+ ? symbolSourceExtensions
1023
+ : uniqueSorted(
1024
+ extensions
1025
+ .map(normalizeExtension)
1026
+ .filter((extension) => symbolSourceExtensions.includes(extension))
1027
+ );
1028
+ if (explicitExtensions && codeExtensions.length === 0) {
1029
+ return null;
1030
+ }
1031
+ if (
1032
+ exclude === undefined &&
1033
+ codeExtensions.length === 0 &&
1034
+ include === undefined
1035
+ ) {
1036
+ return undefined;
1037
+ }
1038
+ return {
1039
+ ...(exclude === undefined ? {} : { exclude }),
1040
+ ...(codeExtensions.length === 0 ? {} : { extensions: codeExtensions }),
1041
+ ...(include === undefined ? {} : { include }),
1042
+ };
1043
+ };
1044
+
1045
+ const vocabularyEvidenceScope = (
1046
+ scope: VocabularyRegradePlan['scope'] | undefined
1047
+ ): NonNullable<VocabularyRegradePlan['scope']> | null => {
1048
+ const explicitExtensions = scope?.extensions !== undefined;
1049
+ const extensions =
1050
+ scope?.extensions === undefined
1051
+ ? vocabularyEvidenceExtensions
1052
+ : uniqueSorted(
1053
+ scope.extensions
1054
+ .map(normalizeExtension)
1055
+ .filter((extension) =>
1056
+ vocabularyEvidenceExtensions.includes(extension)
1057
+ )
1058
+ );
1059
+
1060
+ if (explicitExtensions && extensions.length === 0) {
1061
+ return null;
1062
+ }
1063
+
1064
+ return {
1065
+ ...(scope?.exclude === undefined ? {} : { exclude: scope.exclude }),
1066
+ extensions,
1067
+ ...(scope?.ignoredDirectories === undefined
1068
+ ? {}
1069
+ : { ignoredDirectories: scope.ignoredDirectories }),
1070
+ ...(scope?.include === undefined ? {} : { include: scope.include }),
1071
+ ...(scope?.policyClassified === undefined
1072
+ ? {}
1073
+ : { policyClassified: scope.policyClassified }),
1074
+ ...(scope?.teachingSurfaces === undefined
1075
+ ? {}
1076
+ : { teachingSurfaces: scope.teachingSurfaces }),
1077
+ };
1078
+ };
1079
+
1080
+ const vocabularyEvidencePlan = (
1081
+ plan: VocabularyRegradePlan
1082
+ ): VocabularyRegradePlan | null => {
1083
+ const scope = vocabularyEvidenceScope(plan.scope);
1084
+ if (scope === null) {
1085
+ return null;
1086
+ }
1087
+
1088
+ return { ...plan, scope };
1089
+ };
1090
+
1091
+ const withoutNotSelectedSourceCount = (
1092
+ counts: Readonly<Record<string, number>>
1093
+ ): Readonly<Record<string, number>> =>
1094
+ Object.fromEntries(
1095
+ Object.entries(counts).filter(
1096
+ ([reason]) => reason !== 'not-selected-source'
1097
+ )
1098
+ );
1099
+
1100
+ const withoutVocabularySourceFilterSkips = (
1101
+ report: RegradeReport | null
1102
+ ): RegradeReport | null => {
1103
+ const rejected = report?.skipsByReason['not-selected-source'] ?? 0;
1104
+ if (report === null || rejected === 0) {
1105
+ return report;
1106
+ }
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'
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),
1133
+ },
1134
+ report.scannedPaths ?? []
1135
+ );
1136
+ };
1137
+
1138
+ const vocabularyEvidenceSource = (
1139
+ path: string,
1140
+ scope: VocabularyRegradePlan['scope'] | undefined,
1141
+ includeCodeComments = false
1142
+ ): boolean =>
1143
+ vocabularyProseExtensions.includes(extname(path)) ||
1144
+ (includeCodeComments && symbolSourceExtensions.includes(extname(path))) ||
1145
+ symbolOccurrenceIsPolicyClassified(scope, path);
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
+
1163
+ const vocabularyProseEngineApplies = (
1164
+ scope: VocabularyRegradePlan['scope'] | undefined
1165
+ ): boolean =>
1166
+ scope?.extensions === undefined ||
1167
+ scope.extensions.some((extension) =>
1168
+ vocabularyProseExtensions.includes(normalizeExtension(extension))
1169
+ );
1170
+
1171
+ const mergeVocabularyOverrides = (
1172
+ registryPlan: VocabularyRegradePlan | undefined,
1173
+ input: z.output<typeof regradeInputSchema>
1174
+ ): VocabularyRegradePlan['overrides'] | undefined => {
1175
+ const overrides = {
1176
+ ...registryPlan?.overrides,
1177
+ ...input.overrides,
1178
+ };
1179
+ return Object.keys(overrides).length === 0 ? undefined : overrides;
1180
+ };
1181
+
1182
+ const mergeVocabularyPreserveRules = (
1183
+ registryPlan: VocabularyRegradePlan | undefined,
1184
+ preserve: readonly VocabularyPreserveRule[] | undefined
1185
+ ): readonly VocabularyPreserveRule[] | undefined => {
1186
+ const rules = [...(registryPlan?.preserve ?? []), ...(preserve ?? [])];
1187
+ return rules.length === 0 ? undefined : rules;
1188
+ };
1189
+
1190
+ const vocabularyIntentForInput = (
1191
+ input: z.output<typeof regradeInputSchema>,
1192
+ registryPlan: VocabularyRegradePlan | undefined
1193
+ ): string | undefined => {
1194
+ if (input.intent !== undefined) {
1195
+ return input.intent;
1196
+ }
1197
+ return registryPlan?.intent;
1198
+ };
1199
+
1200
+ const classifiedOverrideError = (
1201
+ input: RegradeInput & { readonly from: string; readonly to: string }
1202
+ ): ValidationError | undefined => {
1203
+ const transition = vocabularyRegradeTransitionForInput(input.from, input.to);
1204
+ return transition?.target.kind === 'classified' &&
1205
+ input.overrides !== undefined
1206
+ ? new ValidationError(
1207
+ 'Classified governed vocabulary transitions are review-only and cannot accept rewrite overrides.'
1208
+ )
1209
+ : undefined;
1210
+ };
1211
+
1212
+ const governedTargetError = (
1213
+ input: RegradeInput & { readonly from: string; readonly to: string }
1214
+ ): ValidationError | undefined => {
1215
+ const governedFormTransition = listGovernedVocabularyTransitions().find(
1216
+ (candidate) =>
1217
+ candidate.from !== input.from &&
1218
+ (candidate.oldForms.includes(input.from) ||
1219
+ candidate.reviewForms.includes(input.from))
1220
+ );
1221
+ if (governedFormTransition !== undefined) {
1222
+ return new ValidationError(
1223
+ `Governed vocabulary form "${input.from}" belongs to transition "${governedFormTransition.id}". Plan from its canonical source "${governedFormTransition.from}" instead.`
1224
+ );
1225
+ }
1226
+ const transition = listGovernedVocabularyTransitions().find(
1227
+ (candidate) => candidate.from === input.from
1228
+ );
1229
+ if (
1230
+ transition === undefined ||
1231
+ vocabularyRegradeTransitionForInput(input.from, input.to) !== undefined
1232
+ ) {
1233
+ return undefined;
1234
+ }
1235
+ const expectedTargets =
1236
+ transition.target.kind === 'single'
1237
+ ? [transition.target.to]
1238
+ : transition.target.options.map((option) => option.to);
1239
+ return new ValidationError(
1240
+ `Governed vocabulary transition "${transition.id}" does not define target "${input.to}". Expected ${expectedTargets.map((target) => `"${target}"`).join(' or ')}`
1241
+ );
1242
+ };
1243
+
1244
+ const registryFileRenamesForRoot = (
1245
+ registryPlan: VocabularyRegradePlan | undefined,
1246
+ rootDir: string | undefined
1247
+ ): VocabularyRegradePlan['fileRenames'] | undefined => {
1248
+ const fileRenames = registryPlan?.fileRenames?.filter(
1249
+ (rename) =>
1250
+ rootDir === undefined ||
1251
+ existsSync(join(rootDir, rename.from)) ||
1252
+ existsSync(join(rootDir, rename.to))
1253
+ );
1254
+ return fileRenames?.length === 0 ? undefined : fileRenames;
1255
+ };
1256
+
1257
+ const buildVocabularyPlan = (
1258
+ input: RegradeInput,
1259
+ configScope?: VocabularyRegradePlan['scope'],
1260
+ rootDir?: string
1261
+ ): TrailsResult<VocabularyRegradePlan, ValidationError> => {
1262
+ if (input.from === undefined || input.to === undefined) {
1263
+ return Result.err(
1264
+ new ValidationError('A vocabulary regrade requires both `from` and `to`.')
1265
+ );
1266
+ }
1267
+ if (input.classIds !== undefined) {
1268
+ return Result.err(
1269
+ new ValidationError(
1270
+ '`classIds` selects class-mode Regrade and cannot be combined with vocabulary-regrade `from`/`to`.'
1271
+ )
1272
+ );
1273
+ }
1274
+
1275
+ const preserve = vocabularyPreserveFromInput(input.preserve);
1276
+ const targetError = governedTargetError({
1277
+ ...input,
1278
+ from: input.from,
1279
+ to: input.to,
1280
+ });
1281
+ if (targetError !== undefined) {
1282
+ return Result.err(targetError);
1283
+ }
1284
+ const registryPlan = vocabularyRegistryPlanForInput(input);
1285
+ const overrideError = classifiedOverrideError({
1286
+ ...input,
1287
+ from: input.from,
1288
+ to: input.to,
1289
+ });
1290
+ if (overrideError !== undefined) {
1291
+ return Result.err(overrideError);
1292
+ }
1293
+ const intent = vocabularyIntentForInput(input, registryPlan);
1294
+ const overrides = mergeVocabularyOverrides(registryPlan, input);
1295
+ const preserveRules = mergeVocabularyPreserveRules(registryPlan, preserve);
1296
+ const scope = mergeVocabularyScope(registryPlan?.scope, configScope, input);
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
+ }));
1303
+
1304
+ return Result.ok({
1305
+ ...(registryPlan?.caseSensitive === undefined
1306
+ ? {}
1307
+ : { caseSensitive: registryPlan.caseSensitive }),
1308
+ ...(registryPlan?.deferForms === undefined
1309
+ ? {}
1310
+ : { deferForms: registryPlan.deferForms }),
1311
+ ...(fileRenames === undefined ? {} : { fileRenames }),
1312
+ from: input.from,
1313
+ id: registryPlan?.id ?? `vocabulary:${input.from}->${input.to}`,
1314
+ kind: 'vocabulary',
1315
+ ...(intent === undefined ? {} : { intent }),
1316
+ ...(overrides === undefined ? {} : { overrides }),
1317
+ ...(preserveRules === undefined ? {} : { preserve: preserveRules }),
1318
+ ...(scope === undefined ? {} : { scope }),
1319
+ to: input.to,
1320
+ });
1321
+ };
1322
+
1323
+ const withDerivedTeachingSurfaceInventory = (params: {
1324
+ readonly plan: VocabularyRegradePlan;
1325
+ readonly report: RegradeReport;
1326
+ }): VocabularyRegradePlan => {
1327
+ const expected = params.plan.scope?.teachingSurfaces;
1328
+ if (expected === undefined) {
1329
+ return params.plan;
1330
+ }
1331
+ const teachingSurfaces = uniqueSorted(
1332
+ (params.report.run?.ledger.occurrences ?? [])
1333
+ .filter(
1334
+ (occurrence) =>
1335
+ occurrence.scopeTier === 'in-scope' &&
1336
+ !isGeneratedRegradeArtifactPath(occurrence.path) &&
1337
+ matchesAnyPathGlob(occurrence.path, expected)
1338
+ )
1339
+ .map((occurrence) => occurrence.path)
1340
+ );
1341
+ const scope = { ...params.plan.scope };
1342
+ if (teachingSurfaces.length === 0) {
1343
+ delete scope.teachingSurfaces;
1344
+ } else {
1345
+ scope.teachingSurfaces = teachingSurfaces;
1346
+ }
1347
+ return { ...params.plan, scope };
1348
+ };
1349
+
1350
+ const regradeRootNotFound = (rootDir: string) =>
1351
+ Result.err(
1352
+ new NotFoundError(
1353
+ `Regrade root "${rootDir}" could not be read as a directory.`
1354
+ )
1355
+ );
1356
+
1357
+ const regradeNoEngineForScope = () =>
1358
+ Result.err(
1359
+ new ValidationError(
1360
+ 'Vocabulary regrade has no prose or governed symbol engine for the selected extension scope.'
1361
+ )
1362
+ );
1363
+
1364
+ const regradeRootIsReadable = (rootDir: string): boolean => {
1365
+ try {
1366
+ readdirSync(rootDir, { withFileTypes: true });
1367
+ return true;
1368
+ } catch {
1369
+ return false;
1370
+ }
1371
+ };
1372
+
1373
+ const validateRegradeReport = (
1374
+ report: RegradeReport
1375
+ ): TrailsResult<RegradeReport, Error> => {
1376
+ const validated = validateOutput(regradeReportOutput, report);
1377
+ if (validated.isErr()) {
1378
+ return validated;
1379
+ }
1380
+ return Result.ok(report);
1381
+ };
1382
+
1383
+ const reportWithVocabularyTransitionRun = (params: {
1384
+ readonly plan: VocabularyRegradePlan;
1385
+ readonly preserveInventory: readonly VocabularyPreserveInventoryEntry[];
1386
+ readonly report: RegradeReport;
1387
+ }): RegradeReport => {
1388
+ const preserveInventory =
1389
+ params.preserveInventory.length === 0
1390
+ ? params.report.run?.preserveInventory
1391
+ : params.preserveInventory;
1392
+ const run = params.report.run ?? {
1393
+ ledger: { cycle: 1, forms: {}, occurrences: [] },
1394
+ plan: params.plan,
1395
+ report: transitionRunReportForRegradeReport(params.report),
1396
+ };
1397
+
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
+ },
1410
+ },
1411
+ params.report.scannedPaths ?? []
1412
+ );
1413
+ };
1414
+
1415
+ const governedSymbolRegradeConfiguration = (params: {
1416
+ readonly plan: VocabularyRegradePlan;
1417
+ readonly preserveInventory: readonly VocabularyPreserveInventoryEntry[];
1418
+ }) => {
1419
+ const transition = vocabularyRegradeTransitionForInput(
1420
+ params.plan.from,
1421
+ params.plan.to
1422
+ );
1423
+ if (transition === undefined) {
1424
+ return null;
1425
+ }
1426
+
1427
+ const symbolCollection = vocabularySymbolCollection(params.plan.scope);
1428
+ if (symbolCollection === null) {
1429
+ return null;
1430
+ }
1431
+ return {
1432
+ classes: createGovernedAstIdentifierRenameClasses(
1433
+ {
1434
+ ...transition,
1435
+ symbolRenames: transition.symbolRenames,
1436
+ },
1437
+ {
1438
+ shouldPreserve: (occurrence) =>
1439
+ symbolOccurrenceIsPolicyClassified(
1440
+ params.plan.scope,
1441
+ occurrence.path
1442
+ ) ||
1443
+ symbolOccurrenceIsPreserved(params.plan.preserve, {
1444
+ end: occurrence.end,
1445
+ form: occurrence.from,
1446
+ path: occurrence.path,
1447
+ source: occurrence.source,
1448
+ start: occurrence.start,
1449
+ }) ||
1450
+ symbolOccurrenceIsPreserved(params.preserveInventory, {
1451
+ end: occurrence.end,
1452
+ form: occurrence.from,
1453
+ path: occurrence.path,
1454
+ source: occurrence.source,
1455
+ start: occurrence.start,
1456
+ }),
1457
+ }
1458
+ ),
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,
1477
+ includeEntries: params.includeEntries,
1478
+ root: params.rootDir,
1479
+ });
1480
+ };
1481
+
1482
+ const vocabularyRecordPathForInput = (
1483
+ rootDir: string,
1484
+ recordPath: string
1485
+ ): string => (isAbsolute(recordPath) ? recordPath : join(rootDir, recordPath));
1486
+
1487
+ const currentCommitSha = (rootDir: string): string | undefined => {
1488
+ try {
1489
+ return execFileSync(
1490
+ 'git',
1491
+ ['-C', rootDir, 'rev-parse', '--short=7', 'HEAD'],
1492
+ {
1493
+ encoding: 'utf8',
1494
+ stdio: ['ignore', 'pipe', 'ignore'],
1495
+ }
1496
+ ).trim();
1497
+ } catch {
1498
+ return undefined;
1499
+ }
1500
+ };
1501
+
1502
+ const vocabularyRecordEnvironment = (
1503
+ rootDir: string
1504
+ ): { readonly commitSha?: string; readonly root: string } => {
1505
+ const commitSha = currentCommitSha(rootDir);
1506
+ return {
1507
+ ...(commitSha === undefined ? {} : { commitSha }),
1508
+ root: rootDir,
1509
+ };
1510
+ };
1511
+
1512
+ const pendingExpansionCandidateCount = (plan: RegradePlanArtifact): number =>
1513
+ plan.expansion?.candidates.filter(
1514
+ (candidate) => candidate.status === 'pending'
1515
+ ).length ?? 0;
1516
+
1517
+ const reportWithPlanSummary = (
1518
+ report: RegradeReport,
1519
+ plan: RegradePlanArtifact,
1520
+ status: 'active' | 'stale'
1521
+ ): RegradeReport => ({
1522
+ ...report,
1523
+ plan: {
1524
+ ...(pendingExpansionCandidateCount(plan) === 0
1525
+ ? {}
1526
+ : { expansionPending: pendingExpansionCandidateCount(plan) }),
1527
+ path: plan.path,
1528
+ schemaVersion: plan.schemaVersion,
1529
+ status,
1530
+ },
1531
+ });
1532
+
1533
+ const reportWithHistorySummary = (
1534
+ report: RegradeReport,
1535
+ params: RegradeHistorySummary
1536
+ ): RegradeReport => ({
1537
+ ...report,
1538
+ history: {
1539
+ id: params.id,
1540
+ path: params.path,
1541
+ ...(params.provenance === undefined
1542
+ ? {}
1543
+ : { provenance: params.provenance }),
1544
+ schemaVersion: params.schemaVersion,
1545
+ status: params.status,
1546
+ },
1547
+ });
1548
+
1549
+ const authoredPlanFieldKeys = [
1550
+ 'caseSensitive',
1551
+ 'deferForms',
1552
+ 'fileRenames',
1553
+ 'id',
1554
+ 'intent',
1555
+ 'overrides',
1556
+ 'preserve',
1557
+ 'scope',
1558
+ ] as const;
1559
+
1560
+ const isAuthoredPlanField = (
1561
+ input: RegradePlanInput,
1562
+ key: (typeof authoredPlanFieldKeys)[number]
1563
+ ): boolean => {
1564
+ switch (key) {
1565
+ case 'intent':
1566
+ case 'fileRenames':
1567
+ case 'overrides':
1568
+ case 'preserve': {
1569
+ return input[key] !== undefined;
1570
+ }
1571
+ case 'scope': {
1572
+ return (
1573
+ input.exclude !== undefined ||
1574
+ input.extensions !== undefined ||
1575
+ input.include !== undefined ||
1576
+ input.policyClassified !== undefined ||
1577
+ input.teachingSurfaces !== undefined
1578
+ );
1579
+ }
1580
+ default: {
1581
+ return false;
1582
+ }
1583
+ }
1584
+ };
1585
+
1586
+ const regradePlanProvenanceForInput = (
1587
+ input: RegradePlanInput,
1588
+ plan: VocabularyRegradePlan
1589
+ ): RegradePlanArtifact['provenance'] => {
1590
+ const fields: Record<string, 'authored' | 'derived'> = {
1591
+ from: 'authored',
1592
+ kind: 'derived',
1593
+ to: 'authored',
1594
+ };
1595
+
1596
+ for (const key of [
1597
+ 'caseSensitive',
1598
+ 'deferForms',
1599
+ 'fileRenames',
1600
+ 'id',
1601
+ 'intent',
1602
+ 'overrides',
1603
+ 'preserve',
1604
+ 'scope',
1605
+ ] as const) {
1606
+ if (plan[key] !== undefined) {
1607
+ fields[key] = isAuthoredPlanField(input, key) ? 'authored' : 'derived';
1608
+ }
1609
+ }
1610
+
1611
+ return { fields };
1612
+ };
1613
+
1614
+ const mergeAuthoredPlanFields = (
1615
+ current: VocabularyRegradePlanArtifact,
1616
+ plan: VocabularyRegradePlan
1617
+ ): VocabularyRegradePlan => {
1618
+ const merged: Record<string, unknown> = { ...plan };
1619
+ for (const key of authoredPlanFieldKeys) {
1620
+ if (
1621
+ current.provenance.fields[key] === 'authored' &&
1622
+ current.plan[key] !== undefined
1623
+ ) {
1624
+ Object.assign(merged, { [key]: current.plan[key] });
1625
+ }
1626
+ }
1627
+ return vocabularyRegradePlanSchema.parse(merged) as VocabularyRegradePlan;
1628
+ };
1629
+
1630
+ const preserveAuthoredPlanProvenance = (
1631
+ current: VocabularyRegradePlanArtifact,
1632
+ provenance: RegradePlanArtifact['provenance']
1633
+ ): RegradePlanArtifact['provenance'] => {
1634
+ const fields = { ...provenance.fields };
1635
+ for (const key of authoredPlanFieldKeys) {
1636
+ if (
1637
+ current.provenance.fields[key] === 'authored' &&
1638
+ current.plan[key] !== undefined
1639
+ ) {
1640
+ fields[key] = 'authored';
1641
+ }
1642
+ }
1643
+ return { fields };
1644
+ };
1645
+
1646
+ const buildRegradePlanArtifact = (params: {
1647
+ readonly derivation?: RegradePlanArtifact['derivation'];
1648
+ readonly expansion?: RegradePlanArtifact['expansion'];
1649
+ readonly input: RegradePlanInput;
1650
+ readonly plan: VocabularyRegradePlan;
1651
+ readonly report: RegradeReport;
1652
+ readonly rootDir: string;
1653
+ readonly transitionId?: string | undefined;
1654
+ }): RegradePlanArtifact => {
1655
+ const absolutePath = regradePlanPathForPlan(params.rootDir, params.plan);
1656
+ return {
1657
+ ...(params.derivation === undefined
1658
+ ? {}
1659
+ : { derivation: params.derivation }),
1660
+ ...(params.expansion === undefined ? {} : { expansion: params.expansion }),
1661
+ kind: 'regrade-plan',
1662
+ path: rootRelativePath(params.rootDir, absolutePath),
1663
+ plan: params.plan,
1664
+ provenance: regradePlanProvenanceForInput(params.input, params.plan),
1665
+ schemaVersion: REGRADE_PLAN_SCHEMA_VERSION,
1666
+ sourceHash: regradeSourceHash(params.report),
1667
+ ...(params.transitionId === undefined
1668
+ ? {}
1669
+ : { transitionId: params.transitionId }),
1670
+ };
1671
+ };
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
+
1765
+ const writeRegradePlanArtifact = (
1766
+ rootDir: string,
1767
+ artifact: RegradePlanArtifact
1768
+ ): TrailsResult<RegradePlanArtifact, InternalError | ValidationError> => {
1769
+ const normalizedArtifact = normalizeRegradePlanArtifactPaths(artifact);
1770
+ const parsed = regradePlanArtifactSchema.safeParse(normalizedArtifact);
1771
+ if (!parsed.success) {
1772
+ return Result.err(
1773
+ new ValidationError('Invalid Regrade plan artifact.', {
1774
+ context: { issues: parsed.error.issues },
1775
+ })
1776
+ );
1777
+ }
1778
+ const absolutePath = join(rootDir, artifact.path);
1779
+ try {
1780
+ mkdirSync(dirname(absolutePath), { recursive: true });
1781
+ writeFileSync(absolutePath, `${JSON.stringify(parsed.data, null, 2)}\n`);
1782
+ } catch (error) {
1783
+ return Result.err(
1784
+ new InternalError('Failed to write Regrade plan artifact.', {
1785
+ ...(error instanceof Error ? { cause: error } : {}),
1786
+ context: { path: artifact.path },
1787
+ })
1788
+ );
1789
+ }
1790
+ return Result.ok(parsed.data as unknown as RegradePlanArtifact);
1791
+ };
1792
+
1793
+ const validateRegradePlanArtifact = (
1794
+ artifact: RegradePlanArtifact
1795
+ ): TrailsResult<RegradePlanArtifact, ValidationError> => {
1796
+ const parsed = regradePlanArtifactSchema.safeParse(
1797
+ normalizeRegradePlanArtifactPaths(artifact)
1798
+ );
1799
+ if (!parsed.success) {
1800
+ return Result.err(
1801
+ new ValidationError('Invalid Regrade plan artifact.', {
1802
+ context: { issues: parsed.error.issues },
1803
+ })
1804
+ );
1805
+ }
1806
+ return Result.ok(parsed.data as unknown as RegradePlanArtifact);
1807
+ };
1808
+
1809
+ const readRegradePlanArtifact = (
1810
+ path: string
1811
+ ): TrailsResult<RegradePlanArtifact, InternalError | ValidationError> => {
1812
+ if (!existsSync(path)) {
1813
+ return Result.err(new ValidationError(`Regrade plan "${path}" not found.`));
1814
+ }
1815
+ let parsedJson: unknown;
1816
+ try {
1817
+ parsedJson = JSON.parse(readFileSync(path, 'utf8'));
1818
+ } catch (error) {
1819
+ return Result.err(
1820
+ new InternalError('Failed to read Regrade plan artifact.', {
1821
+ ...(error instanceof Error ? { cause: error } : {}),
1822
+ context: { path },
1823
+ })
1824
+ );
1825
+ }
1826
+ const parsed = regradePlanArtifactSchema.safeParse(parsedJson);
1827
+ if (!parsed.success) {
1828
+ return Result.err(
1829
+ new ValidationError('Invalid Regrade plan artifact.', {
1830
+ context: { issues: parsed.error.issues, path },
1831
+ })
1832
+ );
1833
+ }
1834
+ return Result.ok(
1835
+ normalizeRegradePlanArtifactPaths(
1836
+ parsed.data as unknown as RegradePlanArtifact
1837
+ )
1838
+ );
1839
+ };
1840
+
1841
+ /**
1842
+ * Transition identity is not an authored plan field — it follows the
1843
+ * transition. Plan re-derivation (including `--fresh`) carries it forward
1844
+ * from the existing active plan of the same kind so a subsequent apply
1845
+ * appends to the same consolidated history spine instead of forking it.
1846
+ */
1847
+ const priorTransitionId = (
1848
+ currentPath: string,
1849
+ kind: RegradePlanBody['kind']
1850
+ ): string | undefined => {
1851
+ if (!existsSync(currentPath)) {
1852
+ return undefined;
1853
+ }
1854
+ const existing = readRegradePlanArtifact(currentPath);
1855
+ if (existing.isErr() || existing.value.plan.kind !== kind) {
1856
+ return undefined;
1857
+ }
1858
+ return existing.value.transitionId;
1859
+ };
1860
+
1861
+ const hasPathSeparator = (value: string): boolean =>
1862
+ value.includes('/') || value.includes('\\');
1863
+
1864
+ const isPlanPathReference = (value: string): boolean =>
1865
+ hasPathSeparator(value) ||
1866
+ value.startsWith('.') ||
1867
+ value.startsWith('~') ||
1868
+ isAbsolute(value);
1869
+
1870
+ const collectActiveRegradePlanPaths = (rootDir: string): string[] => {
1871
+ const results: string[] = [];
1872
+ const skipDirectories = new Set([
1873
+ '.git',
1874
+ '.next',
1875
+ '.turbo',
1876
+ 'dist',
1877
+ 'node_modules',
1878
+ ]);
1879
+
1880
+ const visit = (dir: string): void => {
1881
+ let entries: Dirent[] | undefined;
1882
+ try {
1883
+ entries = readdirSync(dir, { withFileTypes: true });
1884
+ } catch {
1885
+ return;
1886
+ }
1887
+ if (entries === undefined) {
1888
+ return;
1889
+ }
1890
+
1891
+ if (
1892
+ entries.some((entry) => entry.isDirectory() && entry.name === '.trails')
1893
+ ) {
1894
+ const regradeDir = join(dir, '.trails', 'regrade');
1895
+ try {
1896
+ for (const entry of readdirSync(regradeDir, { withFileTypes: true })) {
1897
+ if (entry.isFile() && entry.name.endsWith('.json')) {
1898
+ results.push(join(regradeDir, entry.name));
1899
+ }
1900
+ }
1901
+ } catch {
1902
+ // Not every `.trails` directory has Regrade plans.
1903
+ }
1904
+ }
1905
+
1906
+ for (const entry of entries) {
1907
+ if (!entry.isDirectory() || skipDirectories.has(entry.name)) {
1908
+ continue;
1909
+ }
1910
+ visit(join(dir, entry.name));
1911
+ }
1912
+ };
1913
+
1914
+ visit(rootDir);
1915
+ return results.toSorted((left, right) => left.localeCompare(right));
1916
+ };
1917
+
1918
+ const resolveRegradePlanPath = (
1919
+ rootDir: string,
1920
+ planRef?: string | undefined
1921
+ ): TrailsResult<string, ValidationError> => {
1922
+ if (planRef !== undefined) {
1923
+ if (isPlanPathReference(planRef)) {
1924
+ const normalized = planRef.startsWith('~/')
1925
+ ? join(process.env['HOME'] ?? '', planRef.slice(2))
1926
+ : planRef;
1927
+ return Result.ok(
1928
+ isAbsolute(normalized) ? normalized : join(rootDir, normalized)
1929
+ );
1930
+ }
1931
+ const normalizedRef = planRef.endsWith('.json')
1932
+ ? planRef.slice(0, -'.json'.length)
1933
+ : planRef;
1934
+ const matches = collectActiveRegradePlanPaths(rootDir).filter(
1935
+ (candidate) => basename(candidate, '.json') === normalizedRef
1936
+ );
1937
+ if (matches.length === 1) {
1938
+ return Result.ok(matches[0] as string);
1939
+ }
1940
+ if (matches.length === 0) {
1941
+ return Result.err(
1942
+ new ValidationError(`No active Regrade plan named "${planRef}" found.`)
1943
+ );
1944
+ }
1945
+ return Result.err(
1946
+ new ValidationError(
1947
+ `Multiple active Regrade plans named "${planRef}" found.`,
1948
+ {
1949
+ context: {
1950
+ matches: matches.map((match) => rootRelativePath(rootDir, match)),
1951
+ },
1952
+ }
1953
+ )
1954
+ );
1955
+ }
1956
+
1957
+ const plans = collectActiveRegradePlanPaths(rootDir);
1958
+ if (plans.length === 1) {
1959
+ return Result.ok(plans[0] as string);
1960
+ }
1961
+ if (plans.length === 0) {
1962
+ return Result.err(new ValidationError('No active Regrade plans found.'));
1963
+ }
1964
+ return Result.err(
1965
+ new ValidationError('Multiple active Regrade plans found; pass `--plan`.', {
1966
+ context: { plans: plans.map((plan) => rootRelativePath(rootDir, plan)) },
1967
+ })
1968
+ );
1969
+ };
1970
+
1971
+ const planStatusForReport = (
1972
+ artifact: RegradePlanArtifact,
1973
+ report: RegradeReport,
1974
+ rootDir: string
1975
+ ): 'active' | 'stale' => {
1976
+ if (!currentRegradeSourceHashMatches(artifact.sourceHash, report)) {
1977
+ return 'stale';
1978
+ }
1979
+ if (artifact.plan.kind === 'class' || artifact.derivation === undefined) {
1980
+ return 'active';
1981
+ }
1982
+ const current = deriveRegradePlanDerivation({
1983
+ plan: artifact.plan,
1984
+ preserveInventory: report.run?.preserveInventory ?? [],
1985
+ provenance: artifact.provenance,
1986
+ report,
1987
+ rootDir,
1988
+ });
1989
+ return canonicalJsonStringify(current) ===
1990
+ canonicalJsonStringify(artifact.derivation)
1991
+ ? 'active'
1992
+ : 'stale';
1993
+ };
1994
+
1995
+ const regradePlanGateContext = (
1996
+ report: RegradeReport
1997
+ ):
1998
+ | {
1999
+ readonly gate?: unknown;
2000
+ readonly modified?: number;
2001
+ readonly review?: number;
2002
+ }
2003
+ | undefined => {
2004
+ const { apply, review, rewritten } = report;
2005
+ const modified = apply === undefined ? rewritten : 0;
2006
+ const counts = {
2007
+ ...(modified === 0 ? {} : { modified }),
2008
+ ...(review === 0 ? {} : { review }),
2009
+ };
2010
+ // Class-mode reports carry no vocabulary run: the gate is derived from the
2011
+ // outstanding rewrite and review counts alone.
2012
+ const gateStatus = report.run?.report.gate.status;
2013
+ if (gateStatus !== undefined && gateStatus !== 'green') {
2014
+ return { gate: report.run?.report.gate, ...counts };
2015
+ }
2016
+ if (modified > 0 || review > 0) {
2017
+ return { gate: report.run?.report.gate, ...counts };
2018
+ }
2019
+ return undefined;
2020
+ };
2021
+
2022
+ const persistVocabularyRecord = (params: {
2023
+ readonly report: RegradeReport;
2024
+ readonly rootDir: string;
2025
+ readonly status: 'applied' | 'candidate' | 'checked';
2026
+ }): TrailsResult<RegradeReport, Error> => {
2027
+ const recordResult = writeVocabularyTransitionRecord({
2028
+ environment: vocabularyRecordEnvironment(params.rootDir),
2029
+ report: params.report,
2030
+ root: params.rootDir,
2031
+ status: params.status,
2032
+ });
2033
+ if (recordResult.isErr()) {
2034
+ return recordResult;
2035
+ }
2036
+ return validateRegradeReport(
2037
+ transitionRecordReportWithSummary(params.report, recordResult.value.summary)
2038
+ );
2039
+ };
2040
+
2041
+ const withFileRenameEvidence = (params: {
2042
+ readonly plan: VocabularyRegradePlan;
2043
+ readonly report: RegradeReport & {
2044
+ readonly run: NonNullable<RegradeReport['run']>;
2045
+ };
2046
+ readonly run: FileRenameRegradeRun;
2047
+ }): RegradeReport => {
2048
+ const vocabularyPaths = params.report.run.ledger.occurrences
2049
+ .filter((occurrence) => occurrence.scopeTier === 'in-scope')
2050
+ .map((occurrence) => occurrence.path);
2051
+ const remainingPolicyPaths = new Map<string, number>();
2052
+ for (const path of params.run.policyOccurrencePaths) {
2053
+ remainingPolicyPaths.set(path, (remainingPolicyPaths.get(path) ?? 0) + 1);
2054
+ }
2055
+ const fileInScopePaths = params.run.occurrencePaths.filter((path) => {
2056
+ const remaining = remainingPolicyPaths.get(path) ?? 0;
2057
+ if (remaining === 0) {
2058
+ return true;
2059
+ }
2060
+ remainingPolicyPaths.set(path, remaining - 1);
2061
+ return false;
2062
+ });
2063
+ const evidencePaths = [...vocabularyPaths, ...fileInScopePaths];
2064
+ const expected = uniqueSorted(params.plan.scope?.teachingSurfaces ?? []);
2065
+ const touched = expected.filter((pattern) =>
2066
+ evidencePaths.some((path) => matchesAnyPathGlob(path, [pattern]))
2067
+ );
2068
+ const missing = expected.filter((pattern) => !touched.includes(pattern));
2069
+ const vocabularyPolicyPaths = params.report.run.ledger.occurrences
2070
+ .filter((occurrence) => occurrence.scopeTier === 'policy-classified')
2071
+ .map((occurrence) => occurrence.path);
2072
+ const policyPaths = [
2073
+ ...vocabularyPolicyPaths,
2074
+ ...params.run.policyOccurrencePaths,
2075
+ ];
2076
+ const policyEvidenceMissing =
2077
+ params.plan.scope?.policyClassified?.some(
2078
+ (policy) =>
2079
+ policy.expectMatches === true &&
2080
+ !policyPaths.some((path) => matchesAnyPathGlob(path, policy.paths))
2081
+ ) ?? false;
2082
+ const evidenceReasons = [
2083
+ ...(policyEvidenceMissing
2084
+ ? ['expected-policy-classified-evidence-missing']
2085
+ : []),
2086
+ ...(missing.length === 0 ? [] : ['expected-teaching-surfaces-missing']),
2087
+ ];
2088
+ const reasons = uniqueSorted([
2089
+ ...params.report.run.report.gate.reasons.filter(
2090
+ (reason) =>
2091
+ reason !== 'expected-policy-classified-evidence-missing' &&
2092
+ reason !== 'expected-teaching-surfaces-missing'
2093
+ ),
2094
+ ...evidenceReasons,
2095
+ ]);
2096
+ const filePolicyCount = params.run.policyOccurrencePaths.length;
2097
+ const fileInScopeCount = params.run.occurrencePaths.length - filePolicyCount;
2098
+ const derivedFileInScopeCount =
2099
+ params.run.report.rewritten + params.run.report.review;
2100
+ return {
2101
+ ...params.report,
2102
+ run: {
2103
+ ...params.report.run,
2104
+ report: {
2105
+ ...params.report.run.report,
2106
+ fileRenames: params.run.evidence,
2107
+ gate: {
2108
+ ...params.report.run.report.gate,
2109
+ reasons,
2110
+ status: reasons.length === 0 ? 'green' : 'open',
2111
+ },
2112
+ scopeTiers: {
2113
+ 'in-scope':
2114
+ params.report.run.report.scopeTiers['in-scope'] -
2115
+ derivedFileInScopeCount +
2116
+ fileInScopeCount,
2117
+ 'policy-classified':
2118
+ params.report.run.report.scopeTiers['policy-classified'] +
2119
+ filePolicyCount,
2120
+ },
2121
+ teachingSurfaces: { expected, missing, touched },
2122
+ },
2123
+ },
2124
+ };
2125
+ };
2126
+
2127
+ const combineVocabularyReports = (params: {
2128
+ readonly fileRenameRun: FileRenameRegradeRun | null;
2129
+ readonly plan: VocabularyRegradePlan;
2130
+ readonly preserveInventory: readonly VocabularyPreserveInventoryEntry[];
2131
+ readonly proseReport: RegradeReport | null;
2132
+ readonly symbolReport: RegradeReport | null;
2133
+ }): TrailsResult<RegradeReport, Error> => {
2134
+ const baseReport =
2135
+ params.proseReport ?? params.symbolReport ?? params.fileRenameRun?.report;
2136
+ if (baseReport === undefined) {
2137
+ return regradeNoEngineForScope();
2138
+ }
2139
+ let combined = reportWithVocabularyTransitionRun({
2140
+ plan: params.plan,
2141
+ preserveInventory: params.preserveInventory,
2142
+ report: baseReport,
2143
+ });
2144
+ if (params.proseReport !== null && params.symbolReport !== null) {
2145
+ combined = mergeRegradeReports(combined, params.symbolReport);
2146
+ }
2147
+ if (
2148
+ params.fileRenameRun !== null &&
2149
+ baseReport !== params.fileRenameRun.report
2150
+ ) {
2151
+ combined = mergeRegradeReports(combined, params.fileRenameRun.report);
2152
+ }
2153
+ if (params.fileRenameRun !== null && combined.run !== undefined) {
2154
+ combined = withFileRenameEvidence({
2155
+ plan: params.plan,
2156
+ report: { ...combined, run: combined.run },
2157
+ run: params.fileRenameRun,
2158
+ });
2159
+ }
2160
+ if (combined.apply !== undefined && params.fileRenameRun !== null) {
2161
+ const movedPaths = new Map(
2162
+ (params.plan.fileRenames ?? []).map((rename) => [
2163
+ posix.normalize(rename.from.replaceAll('\\', '/')),
2164
+ posix.normalize(rename.to.replaceAll('\\', '/')),
2165
+ ])
2166
+ );
2167
+ const changedPaths = new Set(params.fileRenameRun.changedPaths);
2168
+ for (const entry of combined.entries) {
2169
+ if (entry.outcome !== 'rewrite') {
2170
+ continue;
2171
+ }
2172
+ const normalizedEntryPath = posix.normalize(entry.path);
2173
+ const movedPath = movedPaths.get(normalizedEntryPath);
2174
+ changedPaths.add(
2175
+ movedPath !== undefined && changedPaths.has(movedPath)
2176
+ ? movedPath
2177
+ : normalizedEntryPath
2178
+ );
2179
+ }
2180
+ const filesChanged = changedPaths.size;
2181
+ combined = {
2182
+ ...combined,
2183
+ apply: { ...combined.apply, filesChanged },
2184
+ ...(combined.run === undefined
2185
+ ? {}
2186
+ : {
2187
+ run: {
2188
+ ...combined.run,
2189
+ report: { ...combined.run.report, filesChanged },
2190
+ },
2191
+ }),
2192
+ };
2193
+ }
2194
+ return validateRegradeReport(combined);
2195
+ };
2196
+
2197
+ const runPlanFileRenames = (
2198
+ plan: VocabularyRegradePlan,
2199
+ params: {
2200
+ readonly apply: boolean;
2201
+ readonly includeEntries: RegradeInput['includeEntries'];
2202
+ readonly rootDir: string;
2203
+ }
2204
+ ): TrailsResult<FileRenameRegradeRun | null, Error> =>
2205
+ plan.fileRenames === undefined || plan.fileRenames.length === 0
2206
+ ? Result.ok(null)
2207
+ : runFileRenameRegrade({
2208
+ apply: params.apply,
2209
+ excludeGeneratedArtifacts: true,
2210
+ includeEntries: params.includeEntries,
2211
+ renames: plan.fileRenames,
2212
+ root: params.rootDir,
2213
+ ...(plan.scope === undefined ? {} : { scope: plan.scope }),
2214
+ vocabularyPlan: plan,
2215
+ });
2216
+
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 {
2256
+ readonly apply: boolean;
2257
+ readonly currentIdentity?: PreparedRegradeRunIdentity | undefined;
2258
+ readonly includeEntries: RegradeInput['includeEntries'];
2259
+ readonly plan: VocabularyRegradePlan;
2260
+ readonly prepareIdentity?: PreparedRegradeRunIdentity | undefined;
2261
+ readonly prepared?: PreparedVocabularyPlanRun | undefined;
2262
+ readonly preparedResult?:
2263
+ | { value?: PreparedVocabularyPlanRun | undefined }
2264
+ | undefined;
2265
+ readonly preserveInventory: readonly VocabularyPreserveInventoryEntry[];
2266
+ readonly rootDir: string;
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);
2294
+ if (fileRenamePreflight.isErr()) {
2295
+ return fileRenamePreflight;
2296
+ }
2297
+ const evidencePlan = vocabularyEvidencePlan(params.plan);
2298
+ const includeCodeComments = classifiedCommentInventoryApplies(params.plan);
2299
+ const runProse = (
2300
+ apply: boolean
2301
+ ): TrailsResult<RegradeReport | null, Error> =>
2302
+ evidencePlan === null
2303
+ ? Result.ok(null)
2304
+ : runVocabularyRegrade({
2305
+ apply,
2306
+ includeEntries: params.includeEntries,
2307
+ plan: evidencePlan,
2308
+ ...(params.preserveInventory.length === 0
2309
+ ? {}
2310
+ : { preserveInventory: params.preserveInventory }),
2311
+ root: params.rootDir,
2312
+ sourceFilter: (path) =>
2313
+ vocabularyEvidenceSource(
2314
+ path,
2315
+ evidencePlan.scope,
2316
+ includeCodeComments
2317
+ ),
2318
+ sourceKindForPath: (path) =>
2319
+ vocabularyEvidenceSourceKind(path, evidencePlan),
2320
+ });
2321
+ const runSymbols = (
2322
+ apply: boolean
2323
+ ): TrailsResult<RegradeReport | null, Error> =>
2324
+ runGovernedSymbolRegrade({
2325
+ apply,
2326
+ includeEntries: params.includeEntries,
2327
+ plan: params.plan,
2328
+ preserveInventory: params.preserveInventory,
2329
+ rootDir: params.rootDir,
2330
+ });
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
+ }
2362
+ if (prosePreview.isErr()) {
2363
+ return prosePreview;
2364
+ }
2365
+ const prosePreviewReport = withoutVocabularySourceFilterSkips(
2366
+ prosePreview.value
2367
+ );
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);
2391
+ if (symbolPreview.isErr()) {
2392
+ return symbolPreview;
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
+ }
2521
+ if (!params.apply) {
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
+ );
2531
+ }
2532
+ const preparedState = validatePreparedVocabularyPlanState({
2533
+ currentIdentity,
2534
+ execution: execution.value,
2535
+ includeEntries: params.includeEntries,
2536
+ plan: params.plan,
2537
+ prepared: params.prepared,
2538
+ rootDir: params.rootDir,
2539
+ });
2540
+ if (preparedState.isErr()) {
2541
+ return preparedState;
2542
+ }
2543
+ }
2544
+
2545
+ const snapshots = snapshotRegradeSources({
2546
+ reports: [
2547
+ execution.value.prosePreviewReport,
2548
+ execution.value.symbolPreviewReport,
2549
+ ],
2550
+ rootDir: params.rootDir,
2551
+ });
2552
+ if (snapshots.isErr()) {
2553
+ return snapshots;
2554
+ }
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
+ }
2565
+ if (reportResult.isErr()) {
2566
+ return regradeApplyErrorAfterRollback(reportResult.error, snapshots.value);
2567
+ }
2568
+ const symbolReportResult = execution.value.runSymbols(true);
2569
+ if (symbolReportResult.isErr()) {
2570
+ return regradeApplyErrorAfterRollback(
2571
+ symbolReportResult.error,
2572
+ snapshots.value
2573
+ );
2574
+ }
2575
+ const fileRenameResult = runPlanFileRenames(params.plan, {
2576
+ apply: true,
2577
+ includeEntries: params.includeEntries,
2578
+ rootDir: params.rootDir,
2579
+ });
2580
+ if (fileRenameResult.isErr()) {
2581
+ return regradeApplyErrorAfterRollback(
2582
+ fileRenameResult.error,
2583
+ snapshots.value
2584
+ );
2585
+ }
2586
+
2587
+ const report = withoutVocabularySourceFilterSkips(reportResult.value);
2588
+ const symbolReport = symbolReportResult.value;
2589
+ if (
2590
+ report?.scanned === 0 &&
2591
+ symbolReport === null &&
2592
+ fileRenameResult.value === null &&
2593
+ !vocabularyProseEngineApplies(params.plan.scope)
2594
+ ) {
2595
+ return regradeNoEngineForScope();
2596
+ }
2597
+ return combineVocabularyReports({
2598
+ fileRenameRun: fileRenameResult.value,
2599
+ plan: params.plan,
2600
+ preserveInventory: params.preserveInventory,
2601
+ proseReport: report,
2602
+ symbolReport,
2603
+ });
2604
+ };
2605
+
2606
+ interface ClassRegradeCoreParams {
2607
+ readonly apply: boolean;
2608
+ readonly classIds?: readonly string[] | undefined;
2609
+ readonly collection?:
2610
+ | {
2611
+ readonly exclude?: readonly string[] | undefined;
2612
+ readonly extensions?: readonly string[] | undefined;
2613
+ readonly include?: readonly string[] | undefined;
2614
+ }
2615
+ | undefined;
2616
+ readonly includeEntries: RegradeInput['includeEntries'];
2617
+ readonly rootDir: string;
2618
+ }
2619
+
2620
+ const runClassRegradeCore = async (
2621
+ params: ClassRegradeCoreParams
2622
+ ): Promise<TrailsResult<RegradeReport, Error>> => {
2623
+ const classSet = await loadWardenRegradeClasses(params.rootDir);
2624
+ if (classSet.diagnostics.length > 0) {
2625
+ return Result.err(
2626
+ new InternalError('Failed to load Regrade project Warden rules.', {
2627
+ context: {
2628
+ diagnostics: classSet.diagnostics,
2629
+ rootDir: params.rootDir,
2630
+ },
2631
+ })
2632
+ );
2633
+ }
2634
+
2635
+ const collection =
2636
+ params.collection === undefined
2637
+ ? undefined
2638
+ : {
2639
+ ...(params.collection.exclude === undefined
2640
+ ? {}
2641
+ : { exclude: params.collection.exclude }),
2642
+ ...(params.collection.extensions === undefined
2643
+ ? {}
2644
+ : { extensions: params.collection.extensions }),
2645
+ ...(params.collection.include === undefined
2646
+ ? {}
2647
+ : { include: params.collection.include }),
2648
+ };
2649
+ const reportResult: TrailsResult<RegradeReport | null, Error> = runRegrade({
2650
+ apply: params.apply,
2651
+ classes: classSet.classes,
2652
+ ...(collection === undefined ? {} : { collection }),
2653
+ includeEntries: params.includeEntries,
2654
+ root: params.rootDir,
2655
+ ...(params.classIds === undefined
2656
+ ? {}
2657
+ : { selection: { classIds: params.classIds } }),
2658
+ });
2659
+ if (reportResult.isErr()) {
2660
+ return reportResult;
2661
+ }
2662
+
2663
+ const report = reportResult.value;
2664
+ if (report === null) {
2665
+ return regradeRootNotFound(params.rootDir);
2666
+ }
2667
+
2668
+ return validateRegradeReport(report);
2669
+ };
2670
+
2671
+ const runClassPlanRegradeRun = (params: {
2672
+ readonly apply: boolean;
2673
+ readonly includeEntries: RegradePlanReferenceInput['includeEntries'];
2674
+ readonly plan: ClassRegradePlan;
2675
+ readonly rootDir: string;
2676
+ }): Promise<TrailsResult<RegradeReport, Error>> =>
2677
+ runClassRegradeCore({
2678
+ apply: params.apply,
2679
+ classIds: params.plan.classIds,
2680
+ ...(params.plan.scope === undefined
2681
+ ? {}
2682
+ : { collection: params.plan.scope }),
2683
+ includeEntries: params.includeEntries,
2684
+ rootDir: params.rootDir,
2685
+ });
2686
+
2687
+ const runPlanArtifactDryRun = async (params: {
2688
+ readonly artifact: RegradePlanArtifact;
2689
+ readonly includeEntries: RegradePlanReferenceInput['includeEntries'];
2690
+ readonly rootDir: string;
2691
+ }): Promise<TrailsResult<RegradeReport, Error>> => {
2692
+ const planBody = params.artifact.plan;
2693
+ if (planBody.kind === 'class') {
2694
+ return runClassPlanRegradeRun({
2695
+ apply: false,
2696
+ includeEntries: params.includeEntries,
2697
+ plan: planBody,
2698
+ rootDir: params.rootDir,
2699
+ });
2700
+ }
2701
+ const preserveResult = await deriveLiveApiPreserveInventory(
2702
+ planBody,
2703
+ params.rootDir
2704
+ );
2705
+ if (preserveResult.isErr()) {
2706
+ return preserveResult;
2707
+ }
2708
+ return runResolvedVocabularyPlan({
2709
+ apply: false,
2710
+ includeEntries: params.includeEntries,
2711
+ plan: planBody,
2712
+ preserveInventory: preserveResult.value,
2713
+ rootDir: params.rootDir,
2714
+ });
2715
+ };
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
+
2842
+ const runLegacyVocabularyRecordRegrade = (
2843
+ input: RegradeInput,
2844
+ rootDir: string,
2845
+ absoluteRecordPath: string
2846
+ ): TrailsResult<RegradeReport, Error> => {
2847
+ const recordResult = readVocabularyTransitionRecord(absoluteRecordPath);
2848
+ if (recordResult.isErr()) {
2849
+ return recordResult;
2850
+ }
2851
+ const record = recordResult.value;
2852
+ if (record.report.run === undefined) {
2853
+ return Result.err(
2854
+ new ValidationError(
2855
+ 'Transition record does not contain a vocabulary run.'
2856
+ )
2857
+ );
2858
+ }
2859
+ const dryRun = runResolvedVocabularyPlan({
2860
+ apply: false,
2861
+ includeEntries: input.includeEntries,
2862
+ plan: record.report.run.plan,
2863
+ preserveInventory: record.report.run.preserveInventory ?? [],
2864
+ rootDir,
2865
+ });
2866
+ if (dryRun.isErr()) {
2867
+ return dryRun;
2868
+ }
2869
+ if (regradeSourceHash(dryRun.value) !== regradeSourceHash(record.report)) {
2870
+ return Result.err(
2871
+ new ValidationError(
2872
+ 'Vocabulary transition record is stale for the current source tree. Re-run discovery and review the new record before applying.',
2873
+ { context: { recordPath: record.recordPath } }
2874
+ )
2875
+ );
2876
+ }
2877
+ if (input.check) {
2878
+ const checked = transitionRecordReportWithSummary(record.report, {
2879
+ path: record.recordPath,
2880
+ schemaVersion: record.schemaVersion,
2881
+ status: 'checked',
2882
+ });
2883
+ if (record.report.run.report.gate.status !== 'green') {
2884
+ return Result.err(
2885
+ new ValidationError('Vocabulary transition record gate is open.', {
2886
+ context: {
2887
+ gate: record.report.run.report.gate,
2888
+ recordPath: record.recordPath,
2889
+ },
2890
+ })
2891
+ );
2892
+ }
2893
+ return validateRegradeReport(checked);
2894
+ }
2895
+
2896
+ if (!input.apply) {
2897
+ return Result.err(
2898
+ new ValidationError(
2899
+ 'Applying a legacy vocabulary transition record requires `apply: true` or `--apply`. Use `--check` to verify the record without mutating source.',
2900
+ { context: { recordPath: record.recordPath } }
2901
+ )
2902
+ );
2903
+ }
2904
+
2905
+ const applied = runResolvedVocabularyPlan({
2906
+ apply: true,
2907
+ includeEntries: input.includeEntries,
2908
+ plan: record.report.run.plan,
2909
+ preserveInventory: record.report.run.preserveInventory ?? [],
2910
+ rootDir,
2911
+ });
2912
+ if (applied.isErr()) {
2913
+ return applied;
2914
+ }
2915
+ return persistVocabularyRecord({
2916
+ report: applied.value,
2917
+ rootDir,
2918
+ status: 'applied',
2919
+ });
2920
+ };
2921
+
2922
+ const runVocabularyCommandRegrade = async (
2923
+ input: RegradeInput,
2924
+ rootDir: string,
2925
+ configScope?: RegradeConfigScope | undefined
2926
+ ): Promise<TrailsResult<RegradeReport, Error>> => {
2927
+ if (input.apply && input.planRecord === undefined) {
2928
+ return Result.err(
2929
+ new ValidationError(
2930
+ 'Vocabulary regrade apply requires `planRecord`. Run discovery with `writeRecord` first, review the record, then apply the confirmed record.'
2931
+ )
2932
+ );
2933
+ }
2934
+ if (input.check && input.planRecord === undefined) {
2935
+ return Result.err(
2936
+ new ValidationError(
2937
+ 'Vocabulary regrade check requires `planRecord` so the gate is computed from persisted evidence.'
2938
+ )
2939
+ );
2940
+ }
2941
+ if (input.planRecord !== undefined) {
2942
+ const absoluteRecordPath = vocabularyRecordPathForInput(
2943
+ rootDir,
2944
+ input.planRecord
2945
+ );
2946
+ return runLegacyVocabularyRecordRegrade(input, rootDir, absoluteRecordPath);
2947
+ }
2948
+
2949
+ const planResult = buildVocabularyPlan(
2950
+ input,
2951
+ vocabularyScopeFromConfig(configScope),
2952
+ rootDir
2953
+ );
2954
+ if (planResult.isErr()) {
2955
+ return planResult;
2956
+ }
2957
+ if (!regradeRootIsReadable(rootDir)) {
2958
+ return regradeRootNotFound(rootDir);
2959
+ }
2960
+
2961
+ const preserveResult = await deriveLiveApiPreserveInventory(
2962
+ planResult.value,
2963
+ rootDir
2964
+ );
2965
+ if (preserveResult.isErr()) {
2966
+ return preserveResult;
2967
+ }
2968
+ const report = runResolvedVocabularyPlan({
2969
+ apply: input.apply,
2970
+ includeEntries: input.includeEntries,
2971
+ plan: planResult.value,
2972
+ preserveInventory: preserveResult.value,
2973
+ rootDir,
2974
+ });
2975
+ if (report.isErr() || !input.writeRecord) {
2976
+ return report;
2977
+ }
2978
+ return persistVocabularyRecord({
2979
+ report: report.value,
2980
+ rootDir,
2981
+ status: input.apply ? 'applied' : 'candidate',
2982
+ });
2983
+ };
2984
+
2985
+ const expansionCandidateKey = (
2986
+ candidate: RegradePlanExpansion['candidates'][number]
2987
+ ): string =>
2988
+ [candidate.kind, candidate.value, candidate.suggestedClassification].join(
2989
+ '\0'
2990
+ );
2991
+
2992
+ const expansionEvidenceKey = (
2993
+ evidence: RegradePlanExpansion['candidates'][number]['evidence'][number]
2994
+ ): string =>
2995
+ [
2996
+ evidence.path,
2997
+ evidence.line ?? '',
2998
+ evidence.column ?? '',
2999
+ evidence.detail ?? '',
3000
+ ].join('\0');
3001
+
3002
+ const mergeExpansionEvidence = (
3003
+ left: RegradePlanExpansion['candidates'][number]['evidence'],
3004
+ right: RegradePlanExpansion['candidates'][number]['evidence']
3005
+ ): RegradePlanExpansion['candidates'][number]['evidence'] => {
3006
+ const merged = new Map<
3007
+ string,
3008
+ RegradePlanExpansion['candidates'][number]['evidence'][number]
3009
+ >();
3010
+ for (const evidence of [...left, ...right]) {
3011
+ merged.set(expansionEvidenceKey(evidence), evidence);
3012
+ }
3013
+ return [...merged.values()].toSorted((a, b) =>
3014
+ a.path === b.path
3015
+ ? (a.line ?? 0) - (b.line ?? 0) ||
3016
+ (a.column ?? 0) - (b.column ?? 0) ||
3017
+ (a.detail ?? '').localeCompare(b.detail ?? '')
3018
+ : a.path.localeCompare(b.path)
3019
+ );
3020
+ };
3021
+
3022
+ const compareCandidates = (
3023
+ left: RegradePlanExpansion['candidates'][number],
3024
+ right: RegradePlanExpansion['candidates'][number]
3025
+ ): number => {
3026
+ if (left.kind !== right.kind) {
3027
+ return left.kind.localeCompare(right.kind);
3028
+ }
3029
+ if (left.value !== right.value) {
3030
+ return left.value.localeCompare(right.value);
3031
+ }
3032
+ return left.suggestedClassification.localeCompare(
3033
+ right.suggestedClassification
3034
+ );
3035
+ };
3036
+
3037
+ const expansionForReport = (
3038
+ report: RegradeReport
3039
+ ): RegradePlanArtifact['expansion'] => {
3040
+ const candidates = new Map<
3041
+ string,
3042
+ RegradePlanExpansion['candidates'][number]
3043
+ >();
3044
+ const candidateValues = new Set<string>();
3045
+ const addCandidate = (
3046
+ candidate: RegradePlanExpansion['candidates'][number]
3047
+ ): void => {
3048
+ const key = expansionCandidateKey(candidate);
3049
+ candidateValues.add(`${candidate.kind}\0${candidate.value}`);
3050
+ const current = candidates.get(key);
3051
+ if (current === undefined) {
3052
+ candidates.set(key, candidate);
3053
+ return;
3054
+ }
3055
+ candidates.set(key, {
3056
+ ...current,
3057
+ evidence: mergeExpansionEvidence(current.evidence, candidate.evidence),
3058
+ });
3059
+ };
3060
+
3061
+ for (const occurrence of report.run?.ledger.occurrences ?? []) {
3062
+ if (occurrence.verdict !== 'deferred') {
3063
+ continue;
3064
+ }
3065
+ addCandidate({
3066
+ evidence: [
3067
+ {
3068
+ column: occurrence.column,
3069
+ detail: occurrence.reason,
3070
+ line: occurrence.line,
3071
+ path: occurrence.path,
3072
+ },
3073
+ ],
3074
+ kind: 'form',
3075
+ provenance: 'derived',
3076
+ status: 'pending',
3077
+ suggestedClassification: occurrence.disposition,
3078
+ value: occurrence.form,
3079
+ });
3080
+ }
3081
+
3082
+ for (const entry of report.entries) {
3083
+ if (entry.outcome !== 'needs-review' || entry.reviewDetails === undefined) {
3084
+ continue;
3085
+ }
3086
+ for (const detail of entry.reviewDetails) {
3087
+ if (detail.symbol === undefined) {
3088
+ continue;
3089
+ }
3090
+ if (candidateValues.has(`form\0${detail.symbol}`)) {
3091
+ continue;
3092
+ }
3093
+ addCandidate({
3094
+ evidence: [
3095
+ {
3096
+ ...(detail.span === undefined
3097
+ ? {}
3098
+ : {
3099
+ column: detail.span.column,
3100
+ line: detail.span.line,
3101
+ }),
3102
+ detail: detail.reason,
3103
+ path: entry.path,
3104
+ },
3105
+ ],
3106
+ kind: 'form',
3107
+ provenance: 'derived',
3108
+ status: 'pending',
3109
+ suggestedClassification: entry.reason ?? detail.reason,
3110
+ value: detail.symbol,
3111
+ });
3112
+ }
3113
+ }
3114
+
3115
+ return { candidates: [...candidates.values()].toSorted(compareCandidates) };
3116
+ };
3117
+
3118
+ const preserveRuleCoversForm = (
3119
+ rule: VocabularyPreserveRule,
3120
+ form: string
3121
+ ): boolean =>
3122
+ (rule.forms === undefined || rule.forms.includes(form)) &&
3123
+ compileVocabularyPreservePattern(rule.pattern).test(form);
3124
+
3125
+ const preserveRuleCoversCandidateEvidence = (
3126
+ rule: VocabularyPreserveRule,
3127
+ form: string,
3128
+ evidence: RegradePlanExpansion['candidates'][number]['evidence'][number]
3129
+ ): boolean =>
3130
+ preserveRuleCoversForm(rule, form) &&
3131
+ (rule.paths === undefined || matchesAnyPathGlob(evidence.path, rule.paths));
3132
+
3133
+ const preserveRulesCoverFormCandidate = (
3134
+ preserve: readonly VocabularyPreserveRule[] | undefined,
3135
+ candidate: RegradePlanExpansion['candidates'][number]
3136
+ ): boolean => {
3137
+ if (preserve === undefined) {
3138
+ return false;
3139
+ }
3140
+ if (candidate.kind !== 'form') {
3141
+ return false;
3142
+ }
3143
+
3144
+ if (candidate.evidence.length === 0) {
3145
+ return preserve.some(
3146
+ (rule) =>
3147
+ rule.paths === undefined &&
3148
+ preserveRuleCoversForm(rule, candidate.value)
3149
+ );
3150
+ }
3151
+
3152
+ return candidate.evidence.every((evidence) =>
3153
+ preserve.some((rule) =>
3154
+ preserveRuleCoversCandidateEvidence(rule, candidate.value, evidence)
3155
+ )
3156
+ );
3157
+ };
3158
+
3159
+ const primaryPlanCoversExpansionCandidate = (
3160
+ plan: VocabularyRegradePlan,
3161
+ candidate: RegradePlanExpansion['candidates'][number]
3162
+ ): boolean => {
3163
+ if (candidate.kind !== 'form') {
3164
+ return false;
3165
+ }
3166
+ return (
3167
+ plan.deferForms?.includes(candidate.value) === true ||
3168
+ plan.overrides?.[candidate.value] !== undefined ||
3169
+ preserveRulesCoverFormCandidate(plan.preserve, candidate)
3170
+ );
3171
+ };
3172
+
3173
+ const mergeRegradePlanExpansion = (
3174
+ current: RegradePlanExpansion | undefined,
3175
+ next: RegradePlanExpansion | undefined,
3176
+ plan: VocabularyRegradePlan
3177
+ ): RegradePlanExpansion | undefined => {
3178
+ const candidates = new Map<
3179
+ string,
3180
+ RegradePlanExpansion['candidates'][number]
3181
+ >();
3182
+
3183
+ for (const candidate of current?.candidates ?? []) {
3184
+ if (primaryPlanCoversExpansionCandidate(plan, candidate)) {
3185
+ continue;
3186
+ }
3187
+ candidates.set(expansionCandidateKey(candidate), candidate);
3188
+ }
3189
+
3190
+ for (const candidate of next?.candidates ?? []) {
3191
+ if (primaryPlanCoversExpansionCandidate(plan, candidate)) {
3192
+ continue;
3193
+ }
3194
+ const key = expansionCandidateKey(candidate);
3195
+ const existing = candidates.get(key);
3196
+ if (existing?.status === 'rejected') {
3197
+ candidates.set(key, existing);
3198
+ continue;
3199
+ }
3200
+ candidates.set(key, {
3201
+ ...candidate,
3202
+ ...(existing === undefined
3203
+ ? {}
3204
+ : {
3205
+ evidence: mergeExpansionEvidence(
3206
+ existing.evidence,
3207
+ candidate.evidence
3208
+ ),
3209
+ status: existing.status,
3210
+ }),
3211
+ });
3212
+ }
3213
+
3214
+ const merged = [...candidates.values()]
3215
+ .filter(
3216
+ (candidate) => !primaryPlanCoversExpansionCandidate(plan, candidate)
3217
+ )
3218
+ .toSorted(compareCandidates);
3219
+ return merged.length === 0 ? undefined : { candidates: merged };
3220
+ };
3221
+
3222
+ const classPlanScopeForInput = (
3223
+ input: RegradePlanInput,
3224
+ configScope: RegradeConfigScope | undefined
3225
+ ): ClassRegradePlan['scope'] => {
3226
+ const exclude = input.exclude ?? configScope?.exclude;
3227
+ const extensions = input.extensions ?? configScope?.extensions;
3228
+ const include = input.include ?? configScope?.include;
3229
+ if (
3230
+ exclude === undefined &&
3231
+ extensions === undefined &&
3232
+ include === undefined
3233
+ ) {
3234
+ return undefined;
3235
+ }
3236
+ return {
3237
+ ...(exclude === undefined ? {} : { exclude: [...exclude] }),
3238
+ ...(extensions === undefined ? {} : { extensions: [...extensions] }),
3239
+ ...(include === undefined ? {} : { include: [...include] }),
3240
+ };
3241
+ };
3242
+
3243
+ const validateClassPlanInput = (
3244
+ input: RegradePlanInput
3245
+ ): ValidationError | null => {
3246
+ if (input.classIds === undefined || input.classIds.length === 0) {
3247
+ return new ValidationError(
3248
+ 'A class-mode Regrade plan requires at least one class id.'
3249
+ );
3250
+ }
3251
+ if (input.from !== undefined || input.to !== undefined) {
3252
+ return new ValidationError(
3253
+ '`classIds` selects a class-mode plan and cannot be combined with vocabulary `from`/`to`.'
3254
+ );
3255
+ }
3256
+ if (input.type === 'vocabulary') {
3257
+ return new ValidationError(
3258
+ '`type: vocabulary` cannot be combined with `classIds`.'
3259
+ );
3260
+ }
3261
+ if (input.fileRenames !== undefined && input.fileRenames.length > 0) {
3262
+ return new ValidationError(
3263
+ '`fileRenames` is not supported for class-mode plans; governed file moves require a vocabulary plan.'
3264
+ );
3265
+ }
3266
+ if (input.expand) {
3267
+ return new ValidationError(
3268
+ '`expand` stages vocabulary review candidates and is not supported for class-mode plans.'
3269
+ );
3270
+ }
3271
+ return null;
3272
+ };
3273
+
3274
+ type ClassPlanArtifact = RegradePlanArtifact & {
3275
+ readonly plan: ClassRegradePlan;
3276
+ };
3277
+
3278
+ const readCurrentClassPlanArtifact = (
3279
+ input: RegradePlanInput,
3280
+ currentPath: string
3281
+ ): TrailsResult<ClassPlanArtifact | null, Error> => {
3282
+ if (input.fresh || !existsSync(currentPath)) {
3283
+ return Result.ok(null);
3284
+ }
3285
+ const currentResult = readRegradePlanArtifact(currentPath);
3286
+ if (currentResult.isErr()) {
3287
+ return currentResult;
3288
+ }
3289
+ const candidate = currentResult.value;
3290
+ if (candidate.plan.kind !== 'class') {
3291
+ return Result.ok(null);
3292
+ }
3293
+ return Result.ok({ ...candidate, plan: candidate.plan });
3294
+ };
3295
+
3296
+ /** Carry authored intent and scope forward from the existing plan artifact. */
3297
+ const mergeAuthoredClassPlanFields = (
3298
+ plan: ClassRegradePlan,
3299
+ input: RegradePlanInput,
3300
+ authoredScope: boolean,
3301
+ current: ClassPlanArtifact | null
3302
+ ): ClassRegradePlan => {
3303
+ if (current === null) {
3304
+ return plan;
3305
+ }
3306
+ let merged = plan;
3307
+ if (
3308
+ input.intent === undefined &&
3309
+ current.provenance.fields['intent'] === 'authored' &&
3310
+ current.plan.intent !== undefined
3311
+ ) {
3312
+ merged = { ...merged, intent: current.plan.intent };
3313
+ }
3314
+ if (
3315
+ input.name === undefined &&
3316
+ current.provenance.fields['name'] === 'authored' &&
3317
+ current.plan.name !== undefined
3318
+ ) {
3319
+ merged = { ...merged, name: current.plan.name };
3320
+ }
3321
+ if (
3322
+ !authoredScope &&
3323
+ current.provenance.fields['scope'] === 'authored' &&
3324
+ current.plan.scope !== undefined
3325
+ ) {
3326
+ merged = { ...merged, scope: current.plan.scope };
3327
+ }
3328
+ return merged;
3329
+ };
3330
+
3331
+ const classPlanProvenance = (
3332
+ plan: ClassRegradePlan,
3333
+ authoredScope: boolean,
3334
+ current: ClassPlanArtifact | null
3335
+ ): RegradePlanArtifact['provenance'] => ({
3336
+ fields: {
3337
+ classIds: 'authored',
3338
+ id: 'derived',
3339
+ kind: 'derived',
3340
+ ...(plan.intent === undefined ? {} : { intent: 'authored' }),
3341
+ ...(plan.name === undefined ? {} : { name: 'authored' }),
3342
+ ...(plan.scope === undefined
3343
+ ? {}
3344
+ : {
3345
+ scope:
3346
+ authoredScope || current?.provenance.fields['scope'] === 'authored'
3347
+ ? 'authored'
3348
+ : 'derived',
3349
+ }),
3350
+ },
3351
+ });
3352
+
3353
+ /**
3354
+ * A named class plan keys its file on the name alone, so a reused name with
3355
+ * different class ids would silently overwrite an unrelated in-progress plan
3356
+ * (and later mix runs into its consolidated history). Refuse the collision;
3357
+ * unreadable or non-class artifacts keep their existing handling.
3358
+ */
3359
+ const classPlanIdentityConflict = (
3360
+ rootDir: string,
3361
+ currentPath: string,
3362
+ classIds: readonly string[]
3363
+ ): ValidationError | null => {
3364
+ if (!existsSync(currentPath)) {
3365
+ return null;
3366
+ }
3367
+ const existing = readRegradePlanArtifact(currentPath);
3368
+ if (existing.isErr() || existing.value.plan.kind !== 'class') {
3369
+ return null;
3370
+ }
3371
+ const existingIds = existing.value.plan.classIds;
3372
+ if (
3373
+ existingIds.length === classIds.length &&
3374
+ existingIds.every((id, index) => id === classIds[index])
3375
+ ) {
3376
+ return null;
3377
+ }
3378
+ return new ValidationError(
3379
+ 'An active class-mode Regrade plan with this name already runs different class ids. Pick a different `name`, or delete the existing plan file if it is abandoned.',
3380
+ {
3381
+ context: {
3382
+ existing: [...existingIds],
3383
+ path: rootRelativePath(rootDir, currentPath),
3384
+ planned: [...classIds],
3385
+ },
3386
+ }
3387
+ );
3388
+ };
3389
+
3390
+ const runClassPlanRegrade = async (
3391
+ input: RegradePlanInput,
3392
+ rootDir: string,
3393
+ configScope: RegradeConfigScope | undefined,
3394
+ shouldDryRun: boolean
3395
+ ): Promise<TrailsResult<RegradePlanArtifact, Error>> => {
3396
+ const invalid = validateClassPlanInput(input);
3397
+ if (invalid !== null) {
3398
+ return Result.err(invalid);
3399
+ }
3400
+ const classIds = input.classIds ?? [];
3401
+ if (!regradeRootIsReadable(rootDir)) {
3402
+ return regradeRootNotFound(rootDir);
3403
+ }
3404
+
3405
+ const inputScope = classPlanScopeForInput(input, configScope);
3406
+ const basePlan: ClassRegradePlan = {
3407
+ classIds: [...classIds],
3408
+ id: `class:${classIds.join('+')}`,
3409
+ ...(input.intent === undefined ? {} : { intent: input.intent }),
3410
+ kind: 'class',
3411
+ ...(input.name === undefined ? {} : { name: input.name }),
3412
+ ...(inputScope === undefined ? {} : { scope: inputScope }),
3413
+ };
3414
+ const currentPath = regradePlanPathForPlan(rootDir, basePlan);
3415
+ const conflict = classPlanIdentityConflict(rootDir, currentPath, classIds);
3416
+ if (conflict !== null) {
3417
+ return Result.err(conflict);
3418
+ }
3419
+ const currentResult = readCurrentClassPlanArtifact(input, currentPath);
3420
+ if (currentResult.isErr()) {
3421
+ return currentResult;
3422
+ }
3423
+ const current = currentResult.value;
3424
+ const authoredScope =
3425
+ input.exclude !== undefined ||
3426
+ input.extensions !== undefined ||
3427
+ input.include !== undefined;
3428
+ const plan = mergeAuthoredClassPlanFields(
3429
+ basePlan,
3430
+ input,
3431
+ authoredScope,
3432
+ current
3433
+ );
3434
+
3435
+ const report = await runClassPlanRegradeRun({
3436
+ apply: false,
3437
+ includeEntries: input.includeEntries,
3438
+ plan,
3439
+ rootDir,
3440
+ });
3441
+ if (report.isErr()) {
3442
+ return report;
3443
+ }
3444
+ if (report.value.unknownClassIds.length > 0) {
3445
+ return Result.err(
3446
+ new ValidationError('Unknown Regrade class ids.', {
3447
+ context: { unknownClassIds: report.value.unknownClassIds },
3448
+ })
3449
+ );
3450
+ }
3451
+
3452
+ const transitionId = priorTransitionId(currentPath, 'class');
3453
+ const artifact: RegradePlanArtifact = {
3454
+ kind: 'regrade-plan',
3455
+ path: rootRelativePath(rootDir, currentPath),
3456
+ plan,
3457
+ provenance: classPlanProvenance(plan, authoredScope, current),
3458
+ schemaVersion: REGRADE_PLAN_SCHEMA_VERSION,
3459
+ sourceHash: regradeSourceHash(report.value),
3460
+ ...(transitionId === undefined ? {} : { transitionId }),
3461
+ };
3462
+ if (shouldDryRun) {
3463
+ return validateRegradePlanArtifact(artifact);
3464
+ }
3465
+ return writeRegradePlanArtifact(rootDir, artifact);
3466
+ };
3467
+
3468
+ const readCurrentVocabularyPlanArtifact = (
3469
+ input: RegradePlanInput,
3470
+ currentPath: string
3471
+ ): TrailsResult<VocabularyRegradePlanArtifact | null, Error> => {
3472
+ if (input.fresh || !existsSync(currentPath)) {
3473
+ return Result.ok(null);
3474
+ }
3475
+ const currentResult = readRegradePlanArtifact(currentPath);
3476
+ if (currentResult.isErr()) {
3477
+ return currentResult;
3478
+ }
3479
+ const candidate = currentResult.value;
3480
+ if (candidate.plan.kind !== 'vocabulary') {
3481
+ return Result.ok(null);
3482
+ }
3483
+ return Result.ok({ ...candidate, plan: candidate.plan });
3484
+ };
3485
+
3486
+ const finishVocabularyPlanArtifact = (params: {
3487
+ readonly current?: VocabularyRegradePlanArtifact | undefined;
3488
+ readonly currentPath: string;
3489
+ readonly input: RegradePlanInput;
3490
+ readonly plan: VocabularyRegradePlan;
3491
+ readonly preserveInventory: readonly VocabularyPreserveInventoryEntry[];
3492
+ readonly rootDir: string;
3493
+ readonly shouldDryRun: boolean;
3494
+ }): TrailsResult<RegradePlanArtifact, Error> => {
3495
+ const initialReport = runResolvedVocabularyPlan({
3496
+ apply: false,
3497
+ includeEntries: params.input.includeEntries,
3498
+ plan: params.plan,
3499
+ preserveInventory: params.preserveInventory,
3500
+ rootDir: params.rootDir,
3501
+ });
3502
+ if (initialReport.isErr()) {
3503
+ return initialReport;
3504
+ }
3505
+ const initialProvenance = regradePlanProvenanceForInput(
3506
+ params.input,
3507
+ params.plan
3508
+ );
3509
+ const scopeIsAuthored =
3510
+ initialProvenance.fields['scope'] === 'authored' ||
3511
+ params.current?.provenance.fields['scope'] === 'authored';
3512
+ const plan = scopeIsAuthored
3513
+ ? params.plan
3514
+ : withDerivedTeachingSurfaceInventory({
3515
+ plan: params.plan,
3516
+ report: initialReport.value,
3517
+ });
3518
+ const report =
3519
+ plan === params.plan
3520
+ ? initialReport
3521
+ : runResolvedVocabularyPlan({
3522
+ apply: false,
3523
+ includeEntries: params.input.includeEntries,
3524
+ plan,
3525
+ preserveInventory: params.preserveInventory,
3526
+ rootDir: params.rootDir,
3527
+ });
3528
+ if (report.isErr()) {
3529
+ return report;
3530
+ }
3531
+ const expansion = mergeRegradePlanExpansion(
3532
+ params.current?.expansion,
3533
+ params.input.expand ? expansionForReport(report.value) : undefined,
3534
+ plan
3535
+ );
3536
+ const transitionId = priorTransitionId(params.currentPath, 'vocabulary');
3537
+ const derivedProvenance = regradePlanProvenanceForInput(params.input, plan);
3538
+ const provenance =
3539
+ params.current === undefined
3540
+ ? derivedProvenance
3541
+ : preserveAuthoredPlanProvenance(params.current, derivedProvenance);
3542
+ const artifact = buildRegradePlanArtifact({
3543
+ derivation: deriveRegradePlanDerivation({
3544
+ plan,
3545
+ preserveInventory: params.preserveInventory,
3546
+ provenance,
3547
+ report: report.value,
3548
+ rootDir: params.rootDir,
3549
+ }),
3550
+ ...(expansion === undefined ? {} : { expansion }),
3551
+ input: params.input,
3552
+ plan,
3553
+ report: report.value,
3554
+ rootDir: params.rootDir,
3555
+ ...(transitionId === undefined ? {} : { transitionId }),
3556
+ });
3557
+ const mergedArtifact =
3558
+ params.current === undefined ? artifact : { ...artifact, provenance };
3559
+ return params.shouldDryRun
3560
+ ? validateRegradePlanArtifact(mergedArtifact)
3561
+ : writeRegradePlanArtifact(params.rootDir, mergedArtifact);
3562
+ };
3563
+
3564
+ const runPlanRegrade = async (
3565
+ input: RegradePlanInput,
3566
+ rootDir: string,
3567
+ configScope?: RegradeConfigScope | undefined,
3568
+ shouldDryRun = false
3569
+ ): Promise<TrailsResult<RegradePlanArtifact, Error>> => {
3570
+ if (input.classIds !== undefined || input.type === 'class') {
3571
+ return runClassPlanRegrade(input, rootDir, configScope, shouldDryRun);
3572
+ }
3573
+ if (input.type !== undefined && input.type !== 'vocabulary') {
3574
+ return Result.err(
3575
+ new ValidationError(`Unsupported Regrade plan type "${input.type}".`)
3576
+ );
3577
+ }
3578
+ if (input.name !== undefined) {
3579
+ return Result.err(
3580
+ new ValidationError(
3581
+ '`name` names a class-mode transition; vocabulary transitions are keyed by `from`/`to`.'
3582
+ )
3583
+ );
3584
+ }
3585
+ const planInput: RegradeInput = {
3586
+ ...input,
3587
+ apply: false,
3588
+ check: false,
3589
+ writeRecord: false,
3590
+ };
3591
+ const planResult = buildVocabularyPlan(
3592
+ planInput,
3593
+ vocabularyScopeFromConfig(configScope),
3594
+ rootDir
3595
+ );
3596
+ if (planResult.isErr()) {
3597
+ return planResult;
3598
+ }
3599
+ if (!regradeRootIsReadable(rootDir)) {
3600
+ return regradeRootNotFound(rootDir);
3601
+ }
3602
+ const currentPath = regradePlanPathForPlan(rootDir, planResult.value);
3603
+ const currentResult = readCurrentVocabularyPlanArtifact(input, currentPath);
3604
+ if (currentResult.isErr()) {
3605
+ return currentResult;
3606
+ }
3607
+ const current = currentResult.value ?? undefined;
3608
+ const plan =
3609
+ current === undefined
3610
+ ? planResult.value
3611
+ : mergeAuthoredPlanFields(current, planResult.value);
3612
+ const preserveResult = await deriveLiveApiPreserveInventory(plan, rootDir);
3613
+ if (preserveResult.isErr()) {
3614
+ return preserveResult;
3615
+ }
3616
+ return finishVocabularyPlanArtifact({
3617
+ current,
3618
+ currentPath,
3619
+ input,
3620
+ plan,
3621
+ preserveInventory: preserveResult.value,
3622
+ rootDir,
3623
+ shouldDryRun,
3624
+ });
3625
+ };
3626
+
3627
+ const loadPlanForInput = async (
3628
+ input: RegradePlanReferenceInput,
3629
+ rootDir: string
3630
+ ): Promise<
3631
+ TrailsResult<
3632
+ { readonly artifact: RegradePlanArtifact; readonly path: string },
3633
+ Error
3634
+ >
3635
+ > => {
3636
+ const path = resolveRegradePlanPath(rootDir, input.plan);
3637
+ if (path.isErr()) {
3638
+ return path;
3639
+ }
3640
+ const artifact = readRegradePlanArtifact(path.value);
3641
+ if (artifact.isErr()) {
3642
+ return artifact;
3643
+ }
3644
+ return Result.ok({ artifact: artifact.value, path: path.value });
3645
+ };
3646
+
3647
+ const reportWithCheckedHistorySummary = (
3648
+ report: RegradeReport,
3649
+ historyPath: string,
3650
+ schemaVersion: number
3651
+ ): RegradeReport => ({
3652
+ ...report,
3653
+ history: {
3654
+ path: historyPath,
3655
+ schemaVersion,
3656
+ status: 'checked',
3657
+ },
3658
+ });
3659
+
3660
+ /**
3661
+ * Check a graduated transition: verify every recorded run in the
3662
+ * consolidated history at its own stamped lock. Historical runs are not
3663
+ * re-executed — per-run stamp verification is the machine acceptance.
3664
+ */
3665
+ const checkGraduatedRegradeHistory = (
3666
+ historyPath: string
3667
+ ): TrailsResult<RegradeReport, Error> => {
3668
+ const artifact = readRegradeHistoryArtifact(historyPath);
3669
+ if (artifact.isErr()) {
3670
+ return artifact;
3671
+ }
3672
+ const verified = verifyRegradeHistoryRuns(artifact.value);
3673
+ if (verified.isErr()) {
3674
+ return verified;
3675
+ }
3676
+ const lastRun = artifact.value.runs.at(-1);
3677
+ if (lastRun === undefined) {
3678
+ return Result.err(
3679
+ new ValidationError('Regrade history has no recorded runs.', {
3680
+ context: { path: artifact.value.path },
3681
+ })
3682
+ );
3683
+ }
3684
+ return validateRegradeReport(
3685
+ reportWithCheckedHistorySummary(
3686
+ lastRun.report,
3687
+ artifact.value.path,
3688
+ artifact.value.schemaVersion
3689
+ )
3690
+ );
3691
+ };
3692
+
3693
+ const runCheckRegradePlan = async (
3694
+ input: RegradePlanReferenceInput,
3695
+ rootDir: string
3696
+ ): Promise<TrailsResult<RegradeReport, Error>> => {
3697
+ const planPath = resolveRegradePlanPath(rootDir, input.plan);
3698
+ if (planPath.isErr()) {
3699
+ if (input.plan !== undefined && !isPlanPathReference(input.plan)) {
3700
+ const historyPath = resolveRegradeHistoryPath(rootDir, input.plan);
3701
+ if (historyPath.isOk()) {
3702
+ return checkGraduatedRegradeHistory(historyPath.value);
3703
+ }
3704
+ return historyPath;
3705
+ }
3706
+ return planPath;
3707
+ }
3708
+ const artifact = readRegradePlanArtifact(planPath.value);
3709
+ if (artifact.isErr()) {
3710
+ return artifact;
3711
+ }
3712
+ const loaded = {
3713
+ value: { artifact: artifact.value, path: planPath.value },
3714
+ };
3715
+ const prepared = await preparePlanArtifactRun({
3716
+ artifact: loaded.value.artifact,
3717
+ includeEntries: input.includeEntries,
3718
+ rootDir,
3719
+ });
3720
+ if (prepared.isErr()) {
3721
+ return prepared;
3722
+ }
3723
+ const { report } = prepared.value;
3724
+ const status = planStatusForReport(loaded.value.artifact, report, rootDir);
3725
+ const checked = reportWithPlanSummary(report, loaded.value.artifact, status);
3726
+ if (status === 'stale') {
3727
+ return Result.err(
3728
+ new ValidationError(
3729
+ 'Regrade plan is stale for the current source tree.',
3730
+ {
3731
+ context: { plan: loaded.value.artifact.path },
3732
+ }
3733
+ )
3734
+ );
3735
+ }
3736
+ const gateContext = regradePlanGateContext(checked);
3737
+ if (gateContext !== undefined) {
3738
+ return Result.err(
3739
+ new ValidationError('Regrade plan gate is open.', {
3740
+ context: {
3741
+ ...gateContext,
3742
+ plan: loaded.value.artifact.path,
3743
+ },
3744
+ })
3745
+ );
3746
+ }
3747
+ return validateRegradeReport(checked);
3748
+ };
3749
+
3750
+ const runPreviewRegradePlan = async (
3751
+ input: RegradePlanReferenceInput,
3752
+ rootDir: string
3753
+ ): Promise<TrailsResult<RegradeReport, Error>> => {
3754
+ const loaded = await loadPlanForInput(input, rootDir);
3755
+ if (loaded.isErr()) {
3756
+ return loaded;
3757
+ }
3758
+ const prepared = await preparePlanArtifactRun({
3759
+ artifact: loaded.value.artifact,
3760
+ includeEntries: input.includeEntries,
3761
+ rootDir,
3762
+ });
3763
+ if (prepared.isErr()) {
3764
+ return prepared;
3765
+ }
3766
+ const { report } = prepared.value;
3767
+ return validateRegradeReport(
3768
+ reportWithPlanSummary(
3769
+ report,
3770
+ loaded.value.artifact,
3771
+ planStatusForReport(loaded.value.artifact, report, rootDir)
3772
+ )
3773
+ );
3774
+ };
3775
+
3776
+ const writeRegradeHistory = (params: {
3777
+ readonly artifact: RegradePlanArtifact;
3778
+ readonly changedFiles: readonly RegradeChangedFileEvidence[];
3779
+ readonly completedReport: RegradeReport;
3780
+ readonly planPath: string;
3781
+ readonly report: RegradeReport;
3782
+ readonly rootDir: string;
3783
+ readonly sourceRevision: string;
3784
+ }): TrailsResult<RegradeHistorySummary, Error> => {
3785
+ const absolutePath = regradeHistoryPathForPlan(
3786
+ params.rootDir,
3787
+ params.artifact.plan
3788
+ );
3789
+ let priorHistoryBytes: string | undefined;
3790
+ if (existsSync(absolutePath)) {
3791
+ try {
3792
+ priorHistoryBytes = readFileSync(absolutePath, 'utf8');
3793
+ } catch (error) {
3794
+ return Result.err(
3795
+ new InternalError('Failed to read Regrade history entry.', {
3796
+ ...(error instanceof Error ? { cause: error } : {}),
3797
+ context: { path: rootRelativePath(params.rootDir, absolutePath) },
3798
+ })
3799
+ );
3800
+ }
3801
+ }
3802
+ const appended = appendRegradeHistoryRun({
3803
+ artifact: params.artifact,
3804
+ changedFiles: params.changedFiles,
3805
+ completedReport: params.completedReport,
3806
+ report: params.report,
3807
+ rootDir: params.rootDir,
3808
+ sourceRevision: params.sourceRevision,
3809
+ });
3810
+ if (appended.isErr()) {
3811
+ return appended;
3812
+ }
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;
3825
+ }
3826
+ return appended;
3827
+ };
3828
+
3829
+ const historyReportForAppliedPlan = (
3830
+ dryRunReport: RegradeReport,
3831
+ appliedReport: RegradeReport
3832
+ ): RegradeReport => ({
3833
+ ...dryRunReport,
3834
+ ...(appliedReport.apply === undefined ? {} : { apply: appliedReport.apply }),
3835
+ ...(dryRunReport.run === undefined
3836
+ ? {}
3837
+ : {
3838
+ run: {
3839
+ ...dryRunReport.run,
3840
+ report:
3841
+ appliedReport.run?.report ??
3842
+ transitionRunReportForRegradeReport(appliedReport),
3843
+ },
3844
+ }),
3845
+ });
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
+
3928
+ const runApplyRegradePlan = async (
3929
+ input: RegradeApplyPlanInput,
3930
+ rootDir: string,
3931
+ shouldDryRun: boolean
3932
+ ): Promise<TrailsResult<RegradeReport, Error>> => {
3933
+ const loaded = await loadPlanForInput(input, rootDir);
3934
+ if (loaded.isErr()) {
3935
+ return loaded;
3936
+ }
3937
+ const governedPlanValidation = validateGovernedRegradePlan(
3938
+ loaded.value.artifact
3939
+ );
3940
+ if (governedPlanValidation.isErr()) {
3941
+ return governedPlanValidation;
3942
+ }
3943
+ const preparedRun = await preparePlanArtifactRun({
3944
+ artifact: loaded.value.artifact,
3945
+ includeEntries: input.includeEntries,
3946
+ rootDir,
3947
+ });
3948
+ if (preparedRun.isErr()) {
3949
+ return preparedRun;
3950
+ }
3951
+ const dryRunReport = preparedRun.value.report;
3952
+ const status = planStatusForReport(
3953
+ loaded.value.artifact,
3954
+ dryRunReport,
3955
+ rootDir
3956
+ );
3957
+ if (status === 'stale') {
3958
+ return Result.err(
3959
+ new ValidationError(
3960
+ 'Regrade plan is stale for the current source tree.',
3961
+ {
3962
+ context: { plan: loaded.value.artifact.path },
3963
+ }
3964
+ )
3965
+ );
3966
+ }
3967
+ if (shouldDryRun) {
3968
+ return validateRegradeReport(
3969
+ reportWithPlanSummary(dryRunReport, loaded.value.artifact, status)
3970
+ );
3971
+ }
3972
+
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;
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
+ });
4026
+ if (applied.isErr()) {
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);
4035
+ }
4036
+ const completionReport = await runPlanArtifactDryRun({
4037
+ artifact: activeArtifact,
4038
+ includeEntries: input.includeEntries,
4039
+ rootDir,
4040
+ });
4041
+ if (completionReport.isErr()) {
4042
+ return rollbackApplyError(completionReport.error);
4043
+ }
4044
+ // Keep the pre-apply occurrence evidence that explains what this run changed,
4045
+ // while carrying the completed counters and a separate post-apply source
4046
+ // stamp so a later no-op apply can still be recognized as a replay.
4047
+ const history = writeRegradeHistory({
4048
+ artifact: activeArtifact,
4049
+ changedFiles: changedFiles.value,
4050
+ completedReport: completionReport.value,
4051
+ planPath: loaded.value.path,
4052
+ report: historyReportForAppliedPlan(dryRunReport, applied.value),
4053
+ rootDir,
4054
+ sourceRevision: sourceRevision.value,
4055
+ });
4056
+ if (history.isErr()) {
4057
+ return rollbackApplyError(history.error);
4058
+ }
4059
+ return validateRegradeReport(
4060
+ reportWithHistorySummary(
4061
+ reportWithPlanSummary(applied.value, activeArtifact, status),
4062
+ history.value
4063
+ )
4064
+ );
4065
+ };
4066
+
4067
+ /**
4068
+ * Pull a graduated transition back from consolidated history into an active
4069
+ * plan for adjustment. The pulled-back artifact is authored intent only —
4070
+ * plan body, provenance, and any staged expansion; the run ledger stays
4071
+ * behind in the graduated history file, which adjust never touches. The
4072
+ * transition's stable id is preserved so the re-run's apply appends to the
4073
+ * same consolidated history spine instead of forking it.
4074
+ */
4075
+ const runAdjustRegrade = async (
4076
+ input: RegradeAdjustInput,
4077
+ rootDir: string,
4078
+ shouldDryRun: boolean
4079
+ ): Promise<TrailsResult<RegradePlanArtifact, Error>> => {
4080
+ const historyPath = resolveRegradeHistoryPath(rootDir, input.transition);
4081
+ if (historyPath.isErr()) {
4082
+ return historyPath;
4083
+ }
4084
+ const history = readRegradeHistoryArtifact(historyPath.value);
4085
+ if (history.isErr()) {
4086
+ return history;
4087
+ }
4088
+ const lastRun = history.value.runs.at(-1);
4089
+ if (lastRun === undefined) {
4090
+ return Result.err(
4091
+ new ValidationError('Regrade history has no recorded runs.', {
4092
+ context: { path: history.value.path },
4093
+ })
4094
+ );
4095
+ }
4096
+ const lastPlan = lastRun.plan;
4097
+ const activePath = regradePlanPathForPlan(rootDir, lastPlan.plan);
4098
+ if (existsSync(activePath)) {
4099
+ return Result.err(
4100
+ new ValidationError(
4101
+ 'An active Regrade plan for this transition already exists; edit or apply it instead of adjusting again.',
4102
+ { context: { plan: rootRelativePath(rootDir, activePath) } }
4103
+ )
4104
+ );
4105
+ }
4106
+ const draft: RegradePlanArtifact = {
4107
+ ...(lastPlan.expansion === undefined
4108
+ ? {}
4109
+ : { expansion: lastPlan.expansion }),
4110
+ kind: 'regrade-plan',
4111
+ path: rootRelativePath(rootDir, activePath),
4112
+ plan: lastPlan.plan,
4113
+ provenance: lastPlan.provenance,
4114
+ schemaVersion: REGRADE_PLAN_SCHEMA_VERSION,
4115
+ sourceHash: lastPlan.sourceHash,
4116
+ transitionId: history.value.id,
4117
+ };
4118
+ // Re-derive the source hash against the current tree so the later apply's
4119
+ // staleness gate compares with today's occurrences, not the graduated
4120
+ // run's.
4121
+ const report = await runPlanArtifactDryRun({
4122
+ artifact: draft,
4123
+ includeEntries: 'actionable',
4124
+ rootDir,
4125
+ });
4126
+ if (report.isErr()) {
4127
+ return report;
4128
+ }
4129
+ const artifact: RegradePlanArtifact = {
4130
+ ...draft,
4131
+ ...(draft.plan.kind === 'class'
4132
+ ? {}
4133
+ : {
4134
+ derivation: deriveRegradePlanDerivation({
4135
+ plan: draft.plan,
4136
+ preserveInventory: report.value.run?.preserveInventory ?? [],
4137
+ provenance: draft.provenance,
4138
+ report: report.value,
4139
+ rootDir,
4140
+ }),
4141
+ }),
4142
+ sourceHash: regradeSourceHash(report.value),
4143
+ };
4144
+ if (shouldDryRun) {
4145
+ return validateRegradePlanArtifact(artifact);
4146
+ }
4147
+ return writeRegradePlanArtifact(rootDir, artifact);
4148
+ };
4149
+
4150
+ const listRegradePlans = async (
4151
+ rootDir: string
4152
+ ): Promise<TrailsResult<z.output<typeof regradePlansOutputSchema>, Error>> => {
4153
+ const plans: z.output<typeof regradePlanSummarySchema>[] = [];
4154
+ for (const path of collectActiveRegradePlanPaths(rootDir)) {
4155
+ const artifact = readRegradePlanArtifact(path);
4156
+ if (artifact.isErr()) {
4157
+ return artifact;
4158
+ }
4159
+ const report = await runPlanArtifactDryRun({
4160
+ artifact: artifact.value,
4161
+ includeEntries: 'actionable',
4162
+ rootDir,
4163
+ });
4164
+ if (report.isErr()) {
4165
+ return report;
4166
+ }
4167
+ const expansionPending = pendingExpansionCandidateCount(artifact.value);
4168
+ const body = artifact.value.plan;
4169
+ plans.push({
4170
+ ...(body.kind === 'class' ? { classIds: [...body.classIds] } : {}),
4171
+ ...(expansionPending === 0 ? {} : { expansionPending }),
4172
+ ...(body.kind === 'vocabulary' ? { from: body.from, to: body.to } : {}),
4173
+ kind: body.kind,
4174
+ path: artifact.value.path,
4175
+ schemaVersion: artifact.value.schemaVersion,
4176
+ status: planStatusForReport(artifact.value, report.value, rootDir),
4177
+ });
4178
+ }
4179
+ return Result.ok({ plans });
4180
+ };
4181
+
4182
+ const runClassModeRegrade = (
4183
+ input: RegradeInput,
4184
+ rootDir: string,
4185
+ configScope?: RegradeConfigScope | undefined
4186
+ ): Promise<TrailsResult<RegradeReport, Error>> => {
4187
+ const collection = classModeCollection(input, configScope);
4188
+ return runClassRegradeCore({
4189
+ apply: input.apply,
4190
+ ...(input.classIds === undefined ? {} : { classIds: input.classIds }),
4191
+ ...(collection === undefined ? {} : { collection }),
4192
+ includeEntries: input.includeEntries,
4193
+ rootDir,
4194
+ });
4195
+ };
4196
+
4197
+ export const regradeTrail = trail('regrade', {
4198
+ args: ['from', 'to'],
4199
+ description: 'Run downstream migration checks and safe rewrites',
4200
+ implementation: async (input, ctx) => {
4201
+ const rootDirResult = resolveTrailRootDir(input.rootDir, ctx.cwd);
4202
+ if (rootDirResult.isErr()) {
4203
+ return rootDirResult;
4204
+ }
4205
+
4206
+ const configResult = await loadRegradeConfig({
4207
+ ...(input.configPath === undefined
4208
+ ? {}
4209
+ : { configPath: input.configPath }),
4210
+ env: ctx.env,
4211
+ rootDir: rootDirResult.value,
4212
+ });
4213
+ if (configResult.isErr()) {
4214
+ return configResult;
4215
+ }
4216
+ const configScope = configResult.value.config?.scope;
4217
+
4218
+ const reportResult = hasVocabularyInput(input)
4219
+ ? await runVocabularyCommandRegrade(
4220
+ input,
4221
+ rootDirResult.value,
4222
+ configScope
4223
+ )
4224
+ : await runClassModeRegrade(input, rootDirResult.value, configScope);
4225
+ if (reportResult.isErr()) {
4226
+ return reportResult;
4227
+ }
4228
+ const outputResult = validateOutput(
4229
+ regradeReportOutput,
4230
+ reportResult.value
4231
+ );
4232
+ if (outputResult.isErr()) {
4233
+ return Result.err(outputResult.error);
4234
+ }
4235
+ return Result.ok(outputResult.value);
4236
+ },
4237
+ input: regradeInputSchema,
4238
+ intent: 'write',
4239
+ output: regradeReportOutput,
4240
+ permit: 'public',
4241
+ });
4242
+
4243
+ export const planRegradeTrail = trail('plan.regrade', {
4244
+ args: ['from', 'to'],
4245
+ cli: { path: ['regrade', 'plan'] },
4246
+ description: 'Write or update a reviewed Regrade plan',
4247
+ implementation: async (input, ctx) => {
4248
+ const lifecycle = new RegradeLifecycleTracker({ progress: ctx.progress });
4249
+ const rootDirResult = await lifecycle.run('resolve-root', () =>
4250
+ resolveTrailRootDir(input.rootDir, ctx.cwd)
4251
+ );
4252
+ if (rootDirResult.isErr()) {
4253
+ return Result.err(rootDirResult.error);
4254
+ }
4255
+
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
+ );
4265
+ if (configResult.isErr()) {
4266
+ return Result.err(configResult.error);
4267
+ }
4268
+
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
+ )
4276
+ );
4277
+ if (result.isErr()) {
4278
+ return Result.err(result.error);
4279
+ }
4280
+ const output = regradePlanArtifactSchema.safeParse(result.value);
4281
+ if (!output.success) {
4282
+ return Result.err(
4283
+ new ValidationError('Invalid Regrade plan output.', {
4284
+ context: { issues: output.error.issues },
4285
+ })
4286
+ );
4287
+ }
4288
+ return Result.ok({ ...output.data, lifecycle: lifecycle.summary() });
4289
+ },
4290
+ input: regradePlanInputSchema,
4291
+ intent: 'write',
4292
+ output: regradePlanCommandOutputSchema,
4293
+ permit: 'public',
4294
+ });
4295
+
4296
+ export const listRegradesTrail = trail('list.regrades', {
4297
+ cli: { path: ['regrade', 'plans'] },
4298
+ description: 'List active Regrade plans and freshness status',
4299
+ implementation: async (input, ctx) => {
4300
+ const rootDirResult = resolveTrailRootDir(input.rootDir, ctx.cwd);
4301
+ if (rootDirResult.isErr()) {
4302
+ return rootDirResult;
4303
+ }
4304
+ const result = await listRegradePlans(rootDirResult.value);
4305
+ if (result.isErr()) {
4306
+ return result;
4307
+ }
4308
+ return Result.ok(result.value);
4309
+ },
4310
+ input: z.object({
4311
+ rootDir: z.string().optional().describe('Workspace root directory'),
4312
+ }),
4313
+ intent: 'read',
4314
+ output: regradePlansOutputSchema,
4315
+ permit: 'public',
4316
+ });
4317
+
4318
+ export const auditRegradeTrail = trail('audit.regrade', {
4319
+ cli: { path: ['regrade', 'audit'] },
4320
+ description:
4321
+ 'Audit applied Regrade vocabulary transitions against current source',
4322
+ implementation: async (input, ctx) => {
4323
+ const rootDirResult = resolveTrailRootDir(input.rootDir, ctx.cwd);
4324
+ if (rootDirResult.isErr()) {
4325
+ return rootDirResult;
4326
+ }
4327
+ const result = await auditRegradeHistory(input, rootDirResult.value);
4328
+ if (result.isErr()) {
4329
+ return result;
4330
+ }
4331
+ const output = validateOutput(regradeAuditOutputSchema, result.value);
4332
+ if (output.isErr()) {
4333
+ return Result.err(output.error);
4334
+ }
4335
+ if (input.failOnOpen && output.value.gate.status === 'open') {
4336
+ return Result.err(
4337
+ new ValidationError('Regrade audit found current-tree residue.', {
4338
+ context: {
4339
+ gate: output.value.gate,
4340
+ transitions: output.value.transitions
4341
+ .filter((transition) => transition.report.status === 'open')
4342
+ .map((transition) => ({
4343
+ open: transition.report.open,
4344
+ source: transition.source,
4345
+ transitionId: transition.transitionId,
4346
+ })),
4347
+ },
4348
+ })
4349
+ );
4350
+ }
4351
+ return Result.ok(output.value);
4352
+ },
4353
+ input: regradeAuditInputSchema,
4354
+ intent: 'read',
4355
+ output: regradeAuditOutputSchema,
4356
+ permit: 'public',
4357
+ });
4358
+
4359
+ export const checkRegradeTrail = trail('check.regrade', {
4360
+ cli: { path: ['regrade', 'check'] },
4361
+ description: 'Check a saved Regrade plan gate without writing source',
4362
+ implementation: async (input, ctx) => {
4363
+ const lifecycle = new RegradeLifecycleTracker({ progress: ctx.progress });
4364
+ const rootDirResult = await lifecycle.run('resolve-root', () =>
4365
+ resolveTrailRootDir(input.rootDir, ctx.cwd)
4366
+ );
4367
+ if (rootDirResult.isErr()) {
4368
+ return Result.err(rootDirResult.error);
4369
+ }
4370
+ const result = await lifecycle.run('check-plan', () =>
4371
+ runCheckRegradePlan(input, rootDirResult.value)
4372
+ );
4373
+ if (result.isErr()) {
4374
+ return Result.err(result.error);
4375
+ }
4376
+ const checked = {
4377
+ ...result.value,
4378
+ check: {
4379
+ plan:
4380
+ result.value.plan?.path ??
4381
+ result.value.history?.path ??
4382
+ input.plan ??
4383
+ '',
4384
+ status: 'passed' as const,
4385
+ },
4386
+ lifecycle: lifecycle.summary(),
4387
+ };
4388
+ const output = validateOutput(regradeCheckOutputSchema, checked);
4389
+ if (output.isErr()) {
4390
+ return Result.err(output.error);
4391
+ }
4392
+ return Result.ok(output.value);
4393
+ },
4394
+ input: regradePlanReferenceInputSchema,
4395
+ intent: 'read',
4396
+ output: regradeCheckOutputSchema,
4397
+ permit: 'public',
4398
+ });
4399
+
4400
+ export const previewRegradeTrail = trail('preview.regrade', {
4401
+ cli: { path: ['regrade', 'preview'] },
4402
+ description: 'Preview a saved Regrade plan without writing source',
4403
+ implementation: async (input, ctx) => {
4404
+ const lifecycle = new RegradeLifecycleTracker({ progress: ctx.progress });
4405
+ const rootDirResult = await lifecycle.run('resolve-root', () =>
4406
+ resolveTrailRootDir(input.rootDir, ctx.cwd)
4407
+ );
4408
+ if (rootDirResult.isErr()) {
4409
+ return Result.err(rootDirResult.error);
4410
+ }
4411
+ const result = await lifecycle.run('preview-plan', () =>
4412
+ runPreviewRegradePlan(input, rootDirResult.value)
4413
+ );
4414
+ if (result.isErr()) {
4415
+ return Result.err(result.error);
4416
+ }
4417
+ const output = validateOutput(regradeLifecycleReportOutputSchema, {
4418
+ ...result.value,
4419
+ lifecycle: lifecycle.summary(),
4420
+ });
4421
+ if (output.isErr()) {
4422
+ return Result.err(output.error);
4423
+ }
4424
+ return Result.ok(output.value);
4425
+ },
4426
+ input: regradePlanReferenceInputSchema,
4427
+ intent: 'read',
4428
+ output: regradeLifecycleReportOutputSchema,
4429
+ permit: 'public',
4430
+ });
4431
+
4432
+ export const applyRegradeTrail = trail('apply.regrade', {
4433
+ cli: { path: ['regrade', 'apply'] },
4434
+ description: 'Apply a saved Regrade plan and move it to history',
4435
+ implementation: async (input, ctx) => {
4436
+ const lifecycle = new RegradeLifecycleTracker({ progress: ctx.progress });
4437
+ const rootDirResult = await lifecycle.run('resolve-root', () =>
4438
+ resolveTrailRootDir(input.rootDir, ctx.cwd)
4439
+ );
4440
+ if (rootDirResult.isErr()) {
4441
+ return Result.err(rootDirResult.error);
4442
+ }
4443
+ const result = await lifecycle.run('apply-plan', () =>
4444
+ runApplyRegradePlan(input, rootDirResult.value, ctx.dryRun === true)
4445
+ );
4446
+ if (result.isErr()) {
4447
+ return Result.err(result.error);
4448
+ }
4449
+ const output = validateOutput(regradeLifecycleReportOutputSchema, {
4450
+ ...result.value,
4451
+ lifecycle: lifecycle.summary(),
4452
+ });
4453
+ if (output.isErr()) {
4454
+ return Result.err(output.error);
4455
+ }
4456
+ return Result.ok(output.value);
4457
+ },
4458
+ input: regradeApplyPlanInputSchema,
4459
+ intent: 'write',
4460
+ output: regradeLifecycleReportOutputSchema,
4461
+ permit: 'public',
4462
+ });
4463
+
4464
+ export const adjustRegradeTrail = trail('adjust.regrade', {
4465
+ args: ['transition'],
4466
+ cli: { path: ['regrade', 'adjust'] },
4467
+ description:
4468
+ 'Pull a graduated Regrade transition back to an active plan for adjustment',
4469
+ implementation: async (input, ctx) => {
4470
+ const lifecycle = new RegradeLifecycleTracker({ progress: ctx.progress });
4471
+ const rootDirResult = await lifecycle.run('resolve-root', () =>
4472
+ resolveTrailRootDir(input.rootDir, ctx.cwd)
4473
+ );
4474
+ if (rootDirResult.isErr()) {
4475
+ return Result.err(rootDirResult.error);
4476
+ }
4477
+ const result = await lifecycle.run('adjust-plan', () =>
4478
+ runAdjustRegrade(input, rootDirResult.value, ctx.dryRun === true)
4479
+ );
4480
+ if (result.isErr()) {
4481
+ return Result.err(result.error);
4482
+ }
4483
+ const output = regradePlanArtifactSchema.safeParse(result.value);
4484
+ if (!output.success) {
4485
+ return Result.err(
4486
+ new ValidationError('Invalid Regrade plan output.', {
4487
+ context: { issues: output.error.issues },
4488
+ })
4489
+ );
4490
+ }
4491
+ return Result.ok({ ...output.data, lifecycle: lifecycle.summary() });
4492
+ },
4493
+ input: regradeAdjustInputSchema,
4494
+ intent: 'write',
4495
+ output: regradePlanCommandOutputSchema,
4496
+ permit: 'public',
4497
+ });