@ontrails/trails 0.2.0

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 (121) hide show
  1. package/CHANGELOG.md +1906 -0
  2. package/README.md +48 -0
  3. package/bin/trails.ts +3 -0
  4. package/package.json +57 -0
  5. package/src/app.ts +167 -0
  6. package/src/clack.ts +111 -0
  7. package/src/cli.ts +308 -0
  8. package/src/completions.ts +431 -0
  9. package/src/lifecycle-source-io.ts +33 -0
  10. package/src/load-app-mirror.ts +202 -0
  11. package/src/local-state-io.ts +129 -0
  12. package/src/mcp-app.ts +42 -0
  13. package/src/mcp-options.ts +92 -0
  14. package/src/mcp.ts +8 -0
  15. package/src/project-writes.ts +377 -0
  16. package/src/regrade/audit.ts +571 -0
  17. package/src/regrade/config.ts +152 -0
  18. package/src/regrade/history.ts +636 -0
  19. package/src/regrade/lifecycle.ts +76 -0
  20. package/src/regrade/live-api-preserve.ts +123 -0
  21. package/src/regrade/plan-artifact.ts +515 -0
  22. package/src/regrade/plan-derivation.ts +301 -0
  23. package/src/regrade/prepared-run.ts +259 -0
  24. package/src/regrade/receipt-history.ts +446 -0
  25. package/src/regrade/source-transaction.ts +185 -0
  26. package/src/release/bindings.ts +58 -0
  27. package/src/release/changeset-packages.ts +99 -0
  28. package/src/release/check.ts +1191 -0
  29. package/src/release/cli-bundle.ts +575 -0
  30. package/src/release/config.ts +73 -0
  31. package/src/release/contract-facts.ts +425 -0
  32. package/src/release/homebrew.ts +221 -0
  33. package/src/release/index.ts +180 -0
  34. package/src/release/lock-roundtrip-smoke.ts +255 -0
  35. package/src/release/lock-roundtrip-workspace.ts +107 -0
  36. package/src/release/native-bun-publish.ts +964 -0
  37. package/src/release/native-bun-registry.ts +848 -0
  38. package/src/release/notes-cli.ts +171 -0
  39. package/src/release/notes.ts +390 -0
  40. package/src/release/pack-coherence.ts +455 -0
  41. package/src/release/package-route-facts.ts +146 -0
  42. package/src/release/packed-artifacts-smoke.ts +236 -0
  43. package/src/release/policy.ts +1780 -0
  44. package/src/release/semver.ts +104 -0
  45. package/src/release/smoke.ts +56 -0
  46. package/src/release/stable-version-release.ts +80 -0
  47. package/src/release/wayfinder-dogfood-smoke.ts +762 -0
  48. package/src/release/zero-line-transition.ts +68 -0
  49. package/src/retired-topo-command.ts +36 -0
  50. package/src/run-adapter-check.ts +76 -0
  51. package/src/run-argv.ts +133 -0
  52. package/src/run-collision.ts +126 -0
  53. package/src/run-completions-install.ts +179 -0
  54. package/src/run-example.ts +149 -0
  55. package/src/run-examples.ts +148 -0
  56. package/src/run-quiet.ts +75 -0
  57. package/src/run-regrade-progress.ts +47 -0
  58. package/src/run-release-check.ts +74 -0
  59. package/src/run-schema.ts +74 -0
  60. package/src/run-trace.ts +273 -0
  61. package/src/run-warden.ts +39 -0
  62. package/src/run-watch-project.ts +52 -0
  63. package/src/run-watch.ts +381 -0
  64. package/src/run-wayfind-outline.ts +170 -0
  65. package/src/scaffold-version-sync.ts +183 -0
  66. package/src/scaffold-versions.generated.ts +12 -0
  67. package/src/trails/adapter-check.ts +244 -0
  68. package/src/trails/add-surface.ts +816 -0
  69. package/src/trails/add-trail.ts +141 -0
  70. package/src/trails/add-verify.ts +252 -0
  71. package/src/trails/compile.ts +118 -0
  72. package/src/trails/completions-complete.ts +236 -0
  73. package/src/trails/completions.ts +47 -0
  74. package/src/trails/config-explain.ts +43 -0
  75. package/src/trails/create-adapter.ts +785 -0
  76. package/src/trails/create-scaffold.ts +1215 -0
  77. package/src/trails/create-versions.ts +62 -0
  78. package/src/trails/create.ts +652 -0
  79. package/src/trails/deprecate.ts +59 -0
  80. package/src/trails/dev-clean.ts +80 -0
  81. package/src/trails/dev-reset.ts +48 -0
  82. package/src/trails/dev-stats.ts +71 -0
  83. package/src/trails/dev-support.ts +360 -0
  84. package/src/trails/doctor.ts +77 -0
  85. package/src/trails/draft-promote.ts +949 -0
  86. package/src/trails/guide.ts +106 -0
  87. package/src/trails/load-app.ts +1145 -0
  88. package/src/trails/operator-context.ts +66 -0
  89. package/src/trails/project-context-output.ts +304 -0
  90. package/src/trails/project-context.ts +613 -0
  91. package/src/trails/project.ts +65 -0
  92. package/src/trails/regrade.ts +4951 -0
  93. package/src/trails/release-check.ts +113 -0
  94. package/src/trails/release-smoke.ts +49 -0
  95. package/src/trails/revise.ts +53 -0
  96. package/src/trails/root-dir.ts +21 -0
  97. package/src/trails/run-example.ts +592 -0
  98. package/src/trails/run-examples.ts +149 -0
  99. package/src/trails/run.ts +496 -0
  100. package/src/trails/scaffold-json.ts +60 -0
  101. package/src/trails/scaffold-topo-identity.ts +479 -0
  102. package/src/trails/survey.ts +990 -0
  103. package/src/trails/topo-activation.ts +14 -0
  104. package/src/trails/topo-constants.ts +2 -0
  105. package/src/trails/topo-history.ts +47 -0
  106. package/src/trails/topo-output-schemas.ts +259 -0
  107. package/src/trails/topo-pin.ts +38 -0
  108. package/src/trails/topo-read-support.ts +368 -0
  109. package/src/trails/topo-reports.ts +809 -0
  110. package/src/trails/topo-store-support.ts +323 -0
  111. package/src/trails/topo-support.ts +247 -0
  112. package/src/trails/topo-unpin.ts +61 -0
  113. package/src/trails/topo.ts +92 -0
  114. package/src/trails/validate.ts +348 -0
  115. package/src/trails/version-lifecycle-support.ts +936 -0
  116. package/src/trails/warden-guide.ts +134 -0
  117. package/src/trails/warden.ts +598 -0
  118. package/src/trails/wayfind-diff.ts +716 -0
  119. package/src/trails/wayfind-outline.ts +876 -0
  120. package/src/trails/wayfind.ts +1319 -0
  121. package/src/versions.ts +31 -0
@@ -0,0 +1,4951 @@
1
+ /**
2
+ * `regrade` trail -- Run downstream migration checks and safe rewrites.
3
+ */
4
+
5
+ import {
6
+ ConflictError,
7
+ InternalError,
8
+ NotFoundError,
9
+ Result,
10
+ ValidationError,
11
+ matchesAnyPathGlob,
12
+ pathScopeSchema,
13
+ trail,
14
+ validateOutput,
15
+ } from '@ontrails/core';
16
+ import type { PathScope, Result as TrailsResult } from '@ontrails/core';
17
+ import {
18
+ applyPreparedRegradeRun,
19
+ applyPreparedVocabularyRegradeRun,
20
+ createGovernedAstIdentifierRenameClasses,
21
+ loadWardenRegradeClasses,
22
+ prepareRegradeRun,
23
+ prepareVocabularyRegradeRun,
24
+ readVocabularyTransitionRecord,
25
+ regradeReportOutput,
26
+ runFileRenameRegrade,
27
+ runRegrade,
28
+ runVocabularyRegrade,
29
+ transitionRecordReportWithSummary,
30
+ validatePreparedRegradeRun,
31
+ verifyDownstreamPackageSource,
32
+ vocabularyRegradeTransitionForInput,
33
+ vocabularyDispositionValues,
34
+ vocabularyRegradePlanSchema,
35
+ vocabularyRegradePlanForInput,
36
+ writeVocabularyTransitionRecord,
37
+ } from '@ontrails/regrade';
38
+ import type {
39
+ FileRenameRegradeRun,
40
+ PreparedRegradeRun,
41
+ PreparedRegradeRunIdentity,
42
+ PreparedVocabularyRegradeRun,
43
+ RegradeApplySummary,
44
+ RegradeReport,
45
+ RegradeReportEntry,
46
+ RegradePackageSourceEvidence,
47
+ RegradePackageSourceExpectation,
48
+ RegradeScanDirectoryBucket,
49
+ RegradeScanExtensionBucket,
50
+ VocabularyPreserveRule,
51
+ VocabularyRegradePlan,
52
+ VocabularyPreserveInventoryEntry,
53
+ } from '@ontrails/regrade';
54
+ import { listGovernedVocabularyTransitions } from '@ontrails/warden';
55
+ import { execFileSync } from 'node:child_process';
56
+ import {
57
+ existsSync,
58
+ mkdirSync,
59
+ readdirSync,
60
+ readFileSync,
61
+ writeFileSync,
62
+ } from 'node:fs';
63
+ import type { Dirent } from 'node:fs';
64
+ import { basename, dirname, extname, isAbsolute, join, posix } from 'node:path';
65
+ import { z } from 'zod';
66
+
67
+ import {
68
+ auditRegradeHistory,
69
+ regradeAuditInputSchema,
70
+ regradeAuditOutputSchema,
71
+ } from '../regrade/audit.js';
72
+ import { loadRegradeConfig } from '../regrade/config.js';
73
+ import {
74
+ RegradeLifecycleTracker,
75
+ regradeLifecycleSchema,
76
+ } from '../regrade/lifecycle.js';
77
+ import {
78
+ appendRegradeHistoryRun,
79
+ consumeActiveRegradePlanAfterHistoryWrite,
80
+ readRegradeHistoryArtifact,
81
+ regradeHistoryPathForPlan,
82
+ resolveRegradeHistoryPath,
83
+ validateGovernedRegradePlan,
84
+ verifyRegradeHistoryRuns,
85
+ } from '../regrade/history.js';
86
+ import type { RegradeHistorySummary } from '../regrade/history.js';
87
+ import { deriveLiveApiPreserveInventory } from '../regrade/live-api-preserve.js';
88
+ import {
89
+ captureRegradeChangedFilesBefore,
90
+ completeRegradeChangedFiles,
91
+ resolveRegradeSourceRevision,
92
+ validateRegradeReceiptPlan,
93
+ } from '../regrade/receipt-history.js';
94
+ import type { RegradeChangedFileEvidence } from '../regrade/receipt-history.js';
95
+ import { deriveRegradePlanDerivation } from '../regrade/plan-derivation.js';
96
+ import {
97
+ preparedRegradeRunIdentity,
98
+ validatePreparedRegradePlanArtifact,
99
+ } from '../regrade/prepared-run.js';
100
+ import {
101
+ regradeApplyErrorAfterRollback,
102
+ snapshotRegradeSources,
103
+ } from '../regrade/source-transaction.js';
104
+ import {
105
+ REGRADE_PLAN_SCHEMA_VERSION,
106
+ canonicalJsonStringify,
107
+ currentRegradeSourceHashMatches,
108
+ isGeneratedRegradeArtifactPath,
109
+ persistentPackageSourcePathIssue,
110
+ regradePlanArtifactSchema,
111
+ regradePlanPathForPlan,
112
+ regradePackageSourceExpectationSchema,
113
+ regradeSourceHash,
114
+ rootRelativePath,
115
+ } from '../regrade/plan-artifact.js';
116
+ import type {
117
+ ClassRegradePlan,
118
+ RegradePlanArtifact,
119
+ RegradePlanBody,
120
+ RegradePlanExpansion,
121
+ VocabularyRegradePlanArtifact,
122
+ } from '../regrade/plan-artifact.js';
123
+ import { resolveTrailRootDir } from './root-dir.js';
124
+
125
+ const regradePathScopeInputSchema = pathScopeSchema.extend({
126
+ exclude: pathScopeSchema.shape.exclude.describe(
127
+ 'Root-relative path globs to exclude during Regrade collection'
128
+ ),
129
+ extensions: pathScopeSchema.shape.extensions.describe(
130
+ 'Source file extensions to scan during Regrade collection'
131
+ ),
132
+ include: pathScopeSchema.shape.include.describe(
133
+ 'Root-relative path patterns to include in vocabulary regrade mode'
134
+ ),
135
+ policyClassified: z
136
+ .array(
137
+ z.object({
138
+ disposition: z.enum(vocabularyDispositionValues),
139
+ expectMatches: z.boolean().optional(),
140
+ paths: z.array(z.string().min(1)).min(1),
141
+ reason: z.string().min(1),
142
+ })
143
+ )
144
+ .optional()
145
+ .describe('Protected paths scanned and counted without default rewrites'),
146
+ teachingSurfaces: z
147
+ .array(z.string().min(1))
148
+ .optional()
149
+ .describe('Expected current teaching-surface path patterns'),
150
+ });
151
+
152
+ const regradePreserveRuleInputSchema = z.object({
153
+ disposition: z
154
+ .enum(vocabularyDispositionValues)
155
+ .optional()
156
+ .describe('Classification to assign to occurrences this rule preserves'),
157
+ forms: z
158
+ .array(z.string().min(1))
159
+ .optional()
160
+ .describe('Matched forms this preserve rule applies to'),
161
+ paths: z
162
+ .array(z.string())
163
+ .optional()
164
+ .describe('Root-relative path globs where this preserve rule applies'),
165
+ pattern: z.string().min(1).describe('Regex or literal pattern to preserve'),
166
+ reason: z.string().optional().describe('Why this form is preserved'),
167
+ });
168
+
169
+ const regradePreserveInputSchema = z.union([
170
+ z.string().min(1),
171
+ regradePreserveRuleInputSchema,
172
+ ]);
173
+
174
+ const regradeFileRenameInputSchema = z.object({
175
+ from: z.string().min(1).describe('Root-relative source file path'),
176
+ to: z.string().min(1).describe('Root-relative target file path'),
177
+ });
178
+
179
+ const regradeInputSchema = regradePathScopeInputSchema.extend({
180
+ apply: z
181
+ .boolean()
182
+ .default(false)
183
+ .describe('Write safe rewrites to disk; dry-run report only by default'),
184
+ check: z
185
+ .boolean()
186
+ .default(false)
187
+ .describe(
188
+ 'Legacy compatibility: check a saved transition record gate without applying rewrites; prefer `regrade check` for saved plans'
189
+ ),
190
+ classIds: z
191
+ .array(z.string())
192
+ .optional()
193
+ .describe('Regrade class ids to run (defaults to all built-in classes)'),
194
+ configPath: z
195
+ .string()
196
+ .optional()
197
+ .describe('Path to a Trails config file with regrade defaults'),
198
+ fileRenames: z
199
+ .array(regradeFileRenameInputSchema)
200
+ .optional()
201
+ .describe('Governed file moves with references derived from scope'),
202
+ from: z
203
+ .string()
204
+ .min(1)
205
+ .optional()
206
+ .describe('Source vocabulary term for a vocabulary regrade'),
207
+ includeEntries: z
208
+ .enum(['actionable', 'all'])
209
+ .default('actionable')
210
+ .describe(
211
+ 'Report entry detail to include; counts always cover the full run'
212
+ ),
213
+ intent: z
214
+ .string()
215
+ .optional()
216
+ .describe('Human-authored migration intent for a vocabulary regrade'),
217
+ overrides: z
218
+ .record(z.string().min(1), z.string().min(1))
219
+ .optional()
220
+ .describe('Explicit source-form to target-form mappings'),
221
+ packageSource: regradePackageSourceExpectationSchema
222
+ .optional()
223
+ .describe(
224
+ 'Expected source for one directly declared downstream Trails package'
225
+ ),
226
+ planRecord: z
227
+ .string()
228
+ .optional()
229
+ .describe(
230
+ 'Legacy compatibility path to a confirmed transition record; prefer `regrade check`, `regrade preview`, and `regrade apply` with saved plans'
231
+ ),
232
+ preserve: z
233
+ .array(regradePreserveInputSchema)
234
+ .optional()
235
+ .describe(
236
+ 'Regex or literal contexts, or structured preserve rules, for a vocabulary regrade'
237
+ ),
238
+ rootDir: z.string().optional().describe('Workspace root directory'),
239
+ to: z
240
+ .string()
241
+ .min(1)
242
+ .optional()
243
+ .describe('Target vocabulary term for a vocabulary regrade'),
244
+ writeRecord: z
245
+ .boolean()
246
+ .default(false)
247
+ .describe(
248
+ 'Legacy compatibility: persist dry-run or apply evidence as a transition record; prefer `regrade plan` and plan history'
249
+ ),
250
+ });
251
+
252
+ type RegradeInput = z.output<typeof regradeInputSchema>;
253
+
254
+ const regradePlanSummarySchema = z.object({
255
+ classIds: z
256
+ .array(z.string())
257
+ .optional()
258
+ .describe('Class ids for a class-mode plan'),
259
+ expansionPending: z
260
+ .number()
261
+ .optional()
262
+ .describe('Pending staged expansion candidates on this plan'),
263
+ from: z.string().optional().describe('Source term for a vocabulary plan'),
264
+ kind: z.enum(['class', 'vocabulary']).describe('Regrade plan kind'),
265
+ path: z.string(),
266
+ schemaVersion: z.number(),
267
+ status: z.enum(['active', 'stale']),
268
+ to: z.string().optional().describe('Target term for a vocabulary plan'),
269
+ });
270
+
271
+ const regradePlansOutputSchema = z.object({
272
+ plans: z.array(regradePlanSummarySchema),
273
+ });
274
+
275
+ const regradeLifecycleReportOutputSchema = regradeReportOutput.extend({
276
+ lifecycle: regradeLifecycleSchema.describe(
277
+ 'Observed phases and wall-clock timings for this lifecycle command'
278
+ ),
279
+ });
280
+
281
+ const regradePlanCommandOutputSchema = regradePlanArtifactSchema.extend({
282
+ lifecycle: regradeLifecycleSchema.describe(
283
+ 'Observed phases and wall-clock timings for this lifecycle command'
284
+ ),
285
+ });
286
+
287
+ const regradeCheckOutputSchema = regradeLifecycleReportOutputSchema.extend({
288
+ check: z
289
+ .object({
290
+ plan: z
291
+ .string()
292
+ .describe(
293
+ 'Saved Regrade plan or graduated history path that passed checks'
294
+ ),
295
+ status: z.literal('passed').describe('Check result'),
296
+ })
297
+ .describe('Saved Regrade plan check result'),
298
+ });
299
+
300
+ const regradePlanInputSchema = regradePathScopeInputSchema.extend({
301
+ classIds: z
302
+ .array(z.string().min(1))
303
+ .optional()
304
+ .describe(
305
+ 'Regrade class ids for a class-mode plan; pair with `type: class` on the CLI so the plan subcommand wins over `regrade` positionals'
306
+ ),
307
+ configPath: z
308
+ .string()
309
+ .optional()
310
+ .describe('Path to a Trails config file with regrade defaults'),
311
+ expand: z
312
+ .boolean()
313
+ .default(false)
314
+ .describe('Stage wide-net review candidates in the saved plan'),
315
+ fileRenames: z
316
+ .array(regradeFileRenameInputSchema)
317
+ .optional()
318
+ .describe('Governed file moves with references derived from scope'),
319
+ fresh: z
320
+ .boolean()
321
+ .default(false)
322
+ .describe(
323
+ 'Replace an existing active plan instead of preserving authored fields'
324
+ ),
325
+ from: z
326
+ .string()
327
+ .min(1)
328
+ .optional()
329
+ .describe('Source vocabulary term or phrase'),
330
+ include: pathScopeSchema.shape.include.describe(
331
+ 'Root-relative path globs to collect during the plan run'
332
+ ),
333
+ includeEntries: z
334
+ .enum(['actionable', 'all'])
335
+ .default('actionable')
336
+ .describe(
337
+ 'Report entry detail to inspect while deriving plan freshness and expansion'
338
+ ),
339
+ intent: z
340
+ .string()
341
+ .optional()
342
+ .describe('Human-authored migration intent for the plan'),
343
+ name: z
344
+ .string()
345
+ .min(1)
346
+ .optional()
347
+ .describe(
348
+ 'Transition name for a class-mode plan; names the plan and history files'
349
+ ),
350
+ overrides: z
351
+ .record(z.string().min(1), z.string().min(1))
352
+ .optional()
353
+ .describe('Explicit source-form to target-form mappings'),
354
+ packageSource: regradePackageSourceExpectationSchema
355
+ .optional()
356
+ .describe(
357
+ 'Expected source for one directly declared downstream Trails package'
358
+ ),
359
+ preserve: z
360
+ .array(regradePreserveInputSchema)
361
+ .optional()
362
+ .describe(
363
+ 'Regex or literal contexts, or structured preserve rules, for a vocabulary regrade'
364
+ ),
365
+ rootDir: z.string().optional().describe('Workspace root directory'),
366
+ to: z.string().min(1).optional().describe('Target vocabulary term or phrase'),
367
+ type: z
368
+ .enum(['class', 'vocabulary'])
369
+ .optional()
370
+ .describe(
371
+ 'Optional plan type qualifier when a source/target pair is ambiguous'
372
+ ),
373
+ });
374
+
375
+ const regradePlanReferenceInputSchema = z.object({
376
+ includeEntries: z
377
+ .enum(['actionable', 'all'])
378
+ .default('actionable')
379
+ .describe('Report entry detail to include while evaluating a saved plan'),
380
+ plan: z
381
+ .string()
382
+ .optional()
383
+ .describe('Plan name or path; omitted when exactly one active plan exists'),
384
+ rootDir: z.string().optional().describe('Workspace root directory'),
385
+ });
386
+
387
+ const regradeApplyPlanInputSchema = regradePlanReferenceInputSchema.extend({
388
+ packageSource: regradePackageSourceExpectationSchema
389
+ .optional()
390
+ .describe(
391
+ 'Expected source for one directly declared downstream Trails package'
392
+ ),
393
+ });
394
+
395
+ const regradeAdjustInputSchema = z.object({
396
+ rootDir: z.string().optional().describe('Workspace root directory'),
397
+ transition: z
398
+ .string()
399
+ .min(1)
400
+ .describe('Graduated transition name, e.g. <transition-name>'),
401
+ });
402
+
403
+ type RegradePlanInput = z.output<typeof regradePlanInputSchema>;
404
+ type RegradePlanReferenceInput = z.output<
405
+ typeof regradePlanReferenceInputSchema
406
+ >;
407
+ type RegradeApplyPlanInput = z.output<typeof regradeApplyPlanInputSchema>;
408
+ type RegradeAdjustInput = z.output<typeof regradeAdjustInputSchema>;
409
+
410
+ const hasVocabularyInput = (input: RegradeInput) =>
411
+ input.fileRenames !== undefined ||
412
+ input.from !== undefined ||
413
+ input.check ||
414
+ input.include !== undefined ||
415
+ input.intent !== undefined ||
416
+ input.overrides !== undefined ||
417
+ input.planRecord !== undefined ||
418
+ input.preserve !== undefined ||
419
+ input.policyClassified !== undefined ||
420
+ input.teachingSurfaces !== undefined ||
421
+ input.to !== undefined;
422
+
423
+ const classModeCollection = (
424
+ input: RegradeInput,
425
+ configScope?: RegradeConfigScope | undefined
426
+ ):
427
+ | {
428
+ readonly exclude?: readonly string[];
429
+ readonly extensions?: readonly string[];
430
+ }
431
+ | undefined => {
432
+ if (
433
+ configScope?.exclude === undefined &&
434
+ configScope?.extensions === undefined &&
435
+ input.exclude === undefined &&
436
+ input.extensions === undefined
437
+ ) {
438
+ return undefined;
439
+ }
440
+
441
+ return {
442
+ ...(configScope?.exclude === undefined
443
+ ? {}
444
+ : { exclude: configScope.exclude }),
445
+ ...(configScope?.extensions === undefined
446
+ ? {}
447
+ : { extensions: configScope.extensions }),
448
+ ...(input.exclude === undefined ? {} : { exclude: input.exclude }),
449
+ ...(input.extensions === undefined ? {} : { extensions: input.extensions }),
450
+ };
451
+ };
452
+
453
+ interface RegradeConfigScope {
454
+ readonly exclude?: PathScope['exclude'] | undefined;
455
+ readonly extensions?: PathScope['extensions'] | undefined;
456
+ readonly include?: PathScope['include'] | undefined;
457
+ }
458
+
459
+ interface RegradeCollectionScope {
460
+ readonly exclude?: readonly string[];
461
+ readonly extensions?: readonly string[];
462
+ readonly include?: readonly string[];
463
+ }
464
+
465
+ const symbolSourceExtensions: readonly string[] = [
466
+ '.cjs',
467
+ '.cts',
468
+ '.js',
469
+ '.jsx',
470
+ '.mjs',
471
+ '.mts',
472
+ '.ts',
473
+ '.tsx',
474
+ ] as const;
475
+
476
+ const vocabularyProseExtensions: readonly string[] = [
477
+ '.md',
478
+ '.mdx',
479
+ '.txt',
480
+ ] as const;
481
+
482
+ const vocabularyEvidenceExtensions: readonly string[] = [
483
+ ...symbolSourceExtensions,
484
+ ...vocabularyProseExtensions,
485
+ '.json',
486
+ '.jsonc',
487
+ '.yaml',
488
+ '.yml',
489
+ ] as const;
490
+
491
+ const normalizeExtension = (extension: string): string =>
492
+ extension === '' || extension.startsWith('.') ? extension : `.${extension}`;
493
+
494
+ const compileVocabularyPreservePattern = (pattern: string): RegExp => {
495
+ try {
496
+ return new RegExp(pattern);
497
+ } catch {
498
+ return new RegExp(pattern.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&'));
499
+ }
500
+ };
501
+
502
+ const globalVocabularyPreservePattern = (pattern: RegExp): RegExp => {
503
+ const flags = pattern.flags.includes('g')
504
+ ? pattern.flags
505
+ : `${pattern.flags}g`;
506
+ return new RegExp(pattern.source, flags);
507
+ };
508
+
509
+ const preservePatternOverlapsSpan = (
510
+ pattern: RegExp,
511
+ source: string,
512
+ start: number,
513
+ end: number
514
+ ): boolean => {
515
+ for (const match of source.matchAll(
516
+ globalVocabularyPreservePattern(pattern)
517
+ )) {
518
+ const matchStart = match.index ?? 0;
519
+ const matchEnd = matchStart + match[0].length;
520
+ if (matchStart !== matchEnd && start < matchEnd && matchStart < end) {
521
+ return true;
522
+ }
523
+ }
524
+ return false;
525
+ };
526
+
527
+ const preserveRuleMatchesSymbolOccurrence = (
528
+ rule: VocabularyPreserveRule,
529
+ occurrence: {
530
+ readonly form: string;
531
+ readonly path: string;
532
+ readonly source: string;
533
+ readonly start: number;
534
+ readonly end: number;
535
+ }
536
+ ): boolean => {
537
+ if (rule.forms !== undefined && !rule.forms.includes(occurrence.form)) {
538
+ return false;
539
+ }
540
+ if (
541
+ rule.paths !== undefined &&
542
+ !matchesAnyPathGlob(occurrence.path, rule.paths)
543
+ ) {
544
+ return false;
545
+ }
546
+ const pattern = compileVocabularyPreservePattern(rule.pattern);
547
+ return (
548
+ pattern.test(occurrence.form) ||
549
+ preservePatternOverlapsSpan(
550
+ pattern,
551
+ occurrence.source,
552
+ occurrence.start,
553
+ occurrence.end
554
+ )
555
+ );
556
+ };
557
+
558
+ const symbolOccurrenceIsPreserved = (
559
+ rules: readonly VocabularyPreserveRule[] | undefined,
560
+ occurrence: {
561
+ readonly form: string;
562
+ readonly path: string;
563
+ readonly source: string;
564
+ readonly start: number;
565
+ readonly end: number;
566
+ }
567
+ ): boolean =>
568
+ rules?.some((rule) =>
569
+ preserveRuleMatchesSymbolOccurrence(rule, occurrence)
570
+ ) ?? false;
571
+
572
+ const symbolOccurrenceIsPolicyClassified = (
573
+ scope: VocabularyRegradePlan['scope'] | undefined,
574
+ path: string
575
+ ): boolean =>
576
+ scope?.policyClassified?.some((policy) =>
577
+ matchesAnyPathGlob(path, policy.paths)
578
+ ) ?? false;
579
+
580
+ const vocabularyScopeFromConfig = (
581
+ scope: RegradeConfigScope | undefined
582
+ ): VocabularyRegradePlan['scope'] | undefined =>
583
+ scope === undefined
584
+ ? undefined
585
+ : {
586
+ ...(scope.exclude === undefined ? {} : { exclude: scope.exclude }),
587
+ ...(scope.extensions === undefined
588
+ ? {}
589
+ : { extensions: scope.extensions }),
590
+ ...(scope.include === undefined ? {} : { include: scope.include }),
591
+ };
592
+
593
+ const vocabularyPreserveFromInput = (
594
+ preserve: RegradeInput['preserve']
595
+ ): readonly VocabularyPreserveRule[] | undefined =>
596
+ preserve?.map((rule) => {
597
+ if (typeof rule === 'string') {
598
+ return { pattern: rule, reason: 'preserved-by-operator-input' };
599
+ }
600
+
601
+ return {
602
+ ...(rule.disposition === undefined
603
+ ? {}
604
+ : { disposition: rule.disposition }),
605
+ ...(rule.forms === undefined ? {} : { forms: rule.forms }),
606
+ ...(rule.paths === undefined ? {} : { paths: rule.paths }),
607
+ pattern: rule.pattern,
608
+ ...(rule.reason === undefined ? {} : { reason: rule.reason }),
609
+ };
610
+ });
611
+
612
+ const vocabularyRegistryPlanForInput = (
613
+ input: RegradeInput
614
+ ): VocabularyRegradePlan | undefined =>
615
+ input.from === undefined || input.to === undefined
616
+ ? undefined
617
+ : (vocabularyRegradePlanForInput(input.from, input.to) ?? undefined);
618
+
619
+ const uniqueSorted = (values: readonly string[]): readonly string[] =>
620
+ [...new Set(values)].toSorted((left, right) => left.localeCompare(right));
621
+
622
+ const uniqueInOrder = (values: readonly string[]): readonly string[] => [
623
+ ...new Set(values),
624
+ ];
625
+
626
+ const mergeScopeList = (
627
+ left: readonly string[] | undefined,
628
+ right: readonly string[] | undefined
629
+ ): readonly string[] | undefined => {
630
+ const merged = uniqueInOrder([...(left ?? []), ...(right ?? [])]);
631
+ return merged.length === 0 ? undefined : merged;
632
+ };
633
+
634
+ const scopePathsOverlap = (left: string, right: string): boolean =>
635
+ left === right ||
636
+ matchesAnyPathGlob(left, [right]) ||
637
+ matchesAnyPathGlob(right, [left]);
638
+
639
+ const mergeVocabularyScope = (
640
+ registryScope: VocabularyRegradePlan['scope'] | undefined,
641
+ configScope: VocabularyRegradePlan['scope'] | undefined,
642
+ input: Pick<
643
+ RegradeInput,
644
+ | 'exclude'
645
+ | 'extensions'
646
+ | 'include'
647
+ | 'policyClassified'
648
+ | 'teachingSurfaces'
649
+ >
650
+ ): VocabularyRegradePlan['scope'] | undefined => {
651
+ const callerExclude = input.exclude ?? configScope?.exclude;
652
+ const callerInclude = input.include ?? configScope?.include;
653
+ const extensions =
654
+ input.extensions ?? configScope?.extensions ?? registryScope?.extensions;
655
+ const exclude = mergeScopeList(registryScope?.exclude, callerExclude);
656
+ const include = mergeScopeList(registryScope?.include, callerInclude);
657
+ const policyClassified = [
658
+ ...(registryScope?.policyClassified ?? [])
659
+ .map((policy) => ({
660
+ ...policy,
661
+ paths: policy.paths.filter(
662
+ (path) =>
663
+ !callerExclude?.some((excludedPath) =>
664
+ scopePathsOverlap(path, excludedPath)
665
+ )
666
+ ),
667
+ }))
668
+ .filter((policy) => policy.paths.length > 0),
669
+ ...(input.policyClassified ?? []),
670
+ ];
671
+ const teachingSurfaces = mergeScopeList(
672
+ registryScope?.teachingSurfaces,
673
+ input.teachingSurfaces
674
+ );
675
+
676
+ const fields = {
677
+ exclude,
678
+ extensions,
679
+ include,
680
+ policyClassified:
681
+ policyClassified.length === 0 ? undefined : policyClassified,
682
+ teachingSurfaces,
683
+ };
684
+ const scope = Object.fromEntries(
685
+ Object.entries(fields).filter(([, value]) => value !== undefined)
686
+ ) as NonNullable<VocabularyRegradePlan['scope']>;
687
+ return Object.keys(scope).length === 0 ? undefined : scope;
688
+ };
689
+
690
+ const mergeNumericRecords = (
691
+ left: Readonly<Record<string, number>>,
692
+ right: Readonly<Record<string, number>>
693
+ ): Readonly<Record<string, number>> => {
694
+ const keys = uniqueSorted([...Object.keys(left), ...Object.keys(right)]);
695
+ return Object.fromEntries(
696
+ keys.map((key) => [key, Math.max(left[key] ?? 0, right[key] ?? 0)])
697
+ );
698
+ };
699
+
700
+ const sumNumericRecords = (
701
+ left: Readonly<Record<string, number>>,
702
+ right: Readonly<Record<string, number>>
703
+ ): Readonly<Record<string, number>> => {
704
+ const keys = uniqueSorted([...Object.keys(left), ...Object.keys(right)]);
705
+ return Object.fromEntries(
706
+ keys.map((key) => [key, (left[key] ?? 0) + (right[key] ?? 0)])
707
+ );
708
+ };
709
+
710
+ const extensionForPath = (path: string): string => {
711
+ const name = path.split('/').at(-1) ?? path;
712
+ const dot = name.lastIndexOf('.');
713
+ return dot <= 0 || dot === name.length - 1 ? '<none>' : name.slice(dot);
714
+ };
715
+
716
+ const topLevelForPath = (path: string): string => {
717
+ const [segment] = path.split('/');
718
+ return segment === undefined || segment.length === 0 ? '.' : segment;
719
+ };
720
+
721
+ const countFilesBy = (
722
+ paths: readonly string[],
723
+ keyForPath: (path: string) => string
724
+ ): Map<string, number> => {
725
+ const counts = new Map<string, number>();
726
+ for (const path of new Set(paths)) {
727
+ const key = keyForPath(path);
728
+ counts.set(key, (counts.get(key) ?? 0) + 1);
729
+ }
730
+ return counts;
731
+ };
732
+
733
+ const countOccurrencesBy = (
734
+ paths: readonly string[],
735
+ keyForPath: (path: string) => string
736
+ ): Map<string, number> => {
737
+ const counts = new Map<string, number>();
738
+ for (const path of paths) {
739
+ const key = keyForPath(path);
740
+ counts.set(key, (counts.get(key) ?? 0) + 1);
741
+ }
742
+ return counts;
743
+ };
744
+
745
+ const sortBuckets = <T extends { readonly files: number }>(
746
+ left: T & { readonly key: string },
747
+ right: T & { readonly key: string }
748
+ ): number => right.files - left.files || left.key.localeCompare(right.key);
749
+
750
+ const mergedDirectoryBuckets = (
751
+ matchedPaths: readonly string[],
752
+ occurrencePaths: readonly string[]
753
+ ): readonly RegradeScanDirectoryBucket[] => {
754
+ const fileCounts = countFilesBy(matchedPaths, topLevelForPath);
755
+ const occurrenceCounts = countOccurrencesBy(occurrencePaths, topLevelForPath);
756
+ const buckets: (RegradeScanDirectoryBucket & { readonly key: string })[] = [];
757
+ for (const [path, files] of fileCounts.entries()) {
758
+ buckets.push(
759
+ occurrencePaths.length === 0
760
+ ? { files, key: path, path }
761
+ : {
762
+ files,
763
+ key: path,
764
+ occurrences: occurrenceCounts.get(path) ?? 0,
765
+ path,
766
+ }
767
+ );
768
+ }
769
+ return buckets
770
+ .toSorted(sortBuckets)
771
+ .map(({ key: _key, ...bucket }) => bucket);
772
+ };
773
+
774
+ const mergedExtensionBuckets = (
775
+ matchedPaths: readonly string[],
776
+ occurrencePaths: readonly string[]
777
+ ): readonly RegradeScanExtensionBucket[] => {
778
+ const fileCounts = countFilesBy(matchedPaths, extensionForPath);
779
+ const occurrenceCounts = countOccurrencesBy(
780
+ occurrencePaths,
781
+ extensionForPath
782
+ );
783
+ const buckets: (RegradeScanExtensionBucket & { readonly key: string })[] = [];
784
+ for (const [extension, files] of fileCounts.entries()) {
785
+ buckets.push(
786
+ occurrencePaths.length === 0
787
+ ? { extension, files, key: extension }
788
+ : {
789
+ extension,
790
+ files,
791
+ key: extension,
792
+ occurrences: occurrenceCounts.get(extension) ?? 0,
793
+ }
794
+ );
795
+ }
796
+ return buckets
797
+ .toSorted(sortBuckets)
798
+ .map(({ key: _key, ...bucket }) => bucket);
799
+ };
800
+
801
+ const actionableEntryPaths = (
802
+ entries: readonly RegradeReportEntry[]
803
+ ): readonly string[] =>
804
+ entries.flatMap((entry) =>
805
+ entry.outcome === 'rewrite' || entry.outcome === 'needs-review'
806
+ ? [entry.path]
807
+ : []
808
+ );
809
+
810
+ const mergeApplySummary = (
811
+ left: RegradeApplySummary | undefined,
812
+ right: RegradeApplySummary | undefined
813
+ ): RegradeApplySummary | undefined => {
814
+ if (left === undefined && right === undefined) {
815
+ return undefined;
816
+ }
817
+
818
+ const leftValue = left ?? {
819
+ applied: 0,
820
+ filesChanged: 0,
821
+ review: 0,
822
+ skipped: 0,
823
+ unknown: 0,
824
+ };
825
+ const rightValue = right ?? {
826
+ applied: 0,
827
+ filesChanged: 0,
828
+ review: 0,
829
+ skipped: 0,
830
+ unknown: 0,
831
+ };
832
+
833
+ return {
834
+ applied: leftValue.applied + rightValue.applied,
835
+ filesChanged: leftValue.filesChanged + rightValue.filesChanged,
836
+ review: leftValue.review + rightValue.review,
837
+ skipped: Math.max(leftValue.skipped, rightValue.skipped),
838
+ unknown: leftValue.unknown + rightValue.unknown,
839
+ };
840
+ };
841
+
842
+ type VocabularyTransitionRunReport = NonNullable<
843
+ RegradeReport['run']
844
+ >['report'];
845
+
846
+ const transitionRunReportForRegradeReport = (
847
+ report: RegradeReport
848
+ ): VocabularyTransitionRunReport => {
849
+ const applied = report.apply?.applied ?? 0;
850
+ const filesChanged = report.apply?.filesChanged ?? 0;
851
+ const modified = report.apply === undefined ? report.rewritten : 0;
852
+ const deferred = report.review;
853
+ const open =
854
+ report.apply === undefined
855
+ ? report.rewritten + report.review
856
+ : report.review;
857
+ const remainingByDisposition =
858
+ open === 0 ? {} : { 'code-context-out-of-engine': open };
859
+ const reasons = [
860
+ ...(report.apply === undefined && report.rewritten > 0
861
+ ? ['safe-modifications-not-yet-applied']
862
+ : []),
863
+ ...(report.review > 0 ? ['deferred-forms-or-occurrences'] : []),
864
+ ];
865
+
866
+ return {
867
+ applied,
868
+ deferred,
869
+ dispositions: remainingByDisposition,
870
+ filesChanged,
871
+ gate: {
872
+ reasons,
873
+ remaining: open,
874
+ remainingByDisposition,
875
+ status: open === 0 && reasons.length === 0 ? 'green' : 'open',
876
+ },
877
+ modified,
878
+ open,
879
+ scopeTiers: {
880
+ 'in-scope': report.rewritten + report.review,
881
+ 'policy-classified': 0,
882
+ },
883
+ skipped: report.skipped,
884
+ teachingSurfaces: { expected: [], missing: [], touched: [] },
885
+ };
886
+ };
887
+
888
+ const mergeTransitionRunReportWithSymbol = (
889
+ vocabularyReport: VocabularyTransitionRunReport,
890
+ symbolReport: RegradeReport
891
+ ): VocabularyTransitionRunReport => {
892
+ const symbolRunReport = transitionRunReportForRegradeReport(symbolReport);
893
+ const modified = vocabularyReport.modified + symbolRunReport.modified;
894
+ const open = vocabularyReport.open + symbolRunReport.open;
895
+ const dispositions = sumNumericRecords(
896
+ vocabularyReport.dispositions,
897
+ symbolRunReport.dispositions
898
+ );
899
+ const remainingByDisposition = sumNumericRecords(
900
+ vocabularyReport.gate.remainingByDisposition,
901
+ symbolRunReport.gate.remainingByDisposition
902
+ );
903
+ const reasons = uniqueSorted([
904
+ ...vocabularyReport.gate.reasons,
905
+ ...symbolRunReport.gate.reasons,
906
+ ]);
907
+
908
+ return {
909
+ applied: vocabularyReport.applied + symbolRunReport.applied,
910
+ deferred: vocabularyReport.deferred + symbolRunReport.deferred,
911
+ dispositions,
912
+ filesChanged: vocabularyReport.filesChanged + symbolRunReport.filesChanged,
913
+ gate: {
914
+ reasons,
915
+ remaining: open,
916
+ remainingByDisposition,
917
+ status: open === 0 && reasons.length === 0 ? 'green' : 'open',
918
+ },
919
+ modified,
920
+ open,
921
+ scopeTiers: {
922
+ 'in-scope':
923
+ vocabularyReport.scopeTiers['in-scope'] +
924
+ symbolRunReport.scopeTiers['in-scope'],
925
+ 'policy-classified':
926
+ vocabularyReport.scopeTiers['policy-classified'] +
927
+ symbolRunReport.scopeTiers['policy-classified'],
928
+ },
929
+ skipped: vocabularyReport.skipped + symbolRunReport.skipped,
930
+ teachingSurfaces: vocabularyReport.teachingSurfaces,
931
+ };
932
+ };
933
+
934
+ const withScannedPaths = (
935
+ report: RegradeReport,
936
+ paths: readonly string[]
937
+ ): RegradeReport => {
938
+ Object.defineProperty(report, 'scannedPaths', {
939
+ configurable: false,
940
+ enumerable: false,
941
+ value: Object.freeze([...paths]),
942
+ writable: false,
943
+ });
944
+ return report;
945
+ };
946
+
947
+ const mergeRegradeReports = (
948
+ vocabularyReport: RegradeReport,
949
+ symbolReport: RegradeReport
950
+ ): RegradeReport => {
951
+ const entries = [
952
+ ...vocabularyReport.entries,
953
+ ...symbolReport.entries,
954
+ ].toSorted(
955
+ (left, right) =>
956
+ left.path.localeCompare(right.path) ||
957
+ (left.classId ?? '').localeCompare(right.classId ?? '')
958
+ );
959
+ const matchedPaths = actionableEntryPaths(entries);
960
+ const rewritten = new Set(
961
+ entries
962
+ .filter((entry) => entry.outcome === 'rewrite')
963
+ .map((entry) => entry.path)
964
+ ).size;
965
+ const review = new Set(
966
+ entries
967
+ .filter((entry) => entry.outcome === 'needs-review')
968
+ .map((entry) => entry.path)
969
+ ).size;
970
+ const matched = new Set(matchedPaths).size;
971
+ const occurrencePaths =
972
+ vocabularyReport.run?.ledger.occurrences.map(
973
+ (occurrence) => occurrence.path
974
+ ) ?? [];
975
+ const skippedByReason = mergeNumericRecords(
976
+ vocabularyReport.skipsByReason,
977
+ symbolReport.skipsByReason
978
+ );
979
+ const apply = mergeApplySummary(vocabularyReport.apply, symbolReport.apply);
980
+ const scannedPaths = uniqueSorted([
981
+ ...(vocabularyReport.scannedPaths ?? []),
982
+ ...(symbolReport.scannedPaths ?? []),
983
+ ]);
984
+ const scanned =
985
+ vocabularyReport.scannedPaths === undefined ||
986
+ symbolReport.scannedPaths === undefined
987
+ ? vocabularyReport.scanned + symbolReport.scanned
988
+ : scannedPaths.length;
989
+ const run =
990
+ vocabularyReport.run === undefined
991
+ ? undefined
992
+ : {
993
+ ...vocabularyReport.run,
994
+ report: mergeTransitionRunReportWithSymbol(
995
+ vocabularyReport.run.report,
996
+ symbolReport
997
+ ),
998
+ };
999
+
1000
+ return withScannedPaths(
1001
+ {
1002
+ ...vocabularyReport,
1003
+ ...(apply === undefined ? {} : { apply }),
1004
+ entries,
1005
+ matched,
1006
+ review,
1007
+ rewritten,
1008
+ scan: {
1009
+ byDirectory: mergedDirectoryBuckets(matchedPaths, occurrencePaths),
1010
+ byExtension: mergedExtensionBuckets(matchedPaths, occurrencePaths),
1011
+ files: {
1012
+ matched: new Set(matchedPaths).size,
1013
+ scanned,
1014
+ skipped: Math.max(vocabularyReport.skipped, symbolReport.skipped),
1015
+ },
1016
+ skippedByReason,
1017
+ },
1018
+ ...(run === undefined ? {} : { run }),
1019
+ scanned,
1020
+ selectedClassIds: uniqueSorted([
1021
+ ...vocabularyReport.selectedClassIds,
1022
+ ...symbolReport.selectedClassIds,
1023
+ ]),
1024
+ skipped: Math.max(vocabularyReport.skipped, symbolReport.skipped),
1025
+ skipsByReason: skippedByReason,
1026
+ unknownClassIds: uniqueSorted([
1027
+ ...vocabularyReport.unknownClassIds,
1028
+ ...symbolReport.unknownClassIds,
1029
+ ]),
1030
+ },
1031
+ scannedPaths
1032
+ );
1033
+ };
1034
+
1035
+ const vocabularySymbolCollection = (
1036
+ scope: VocabularyRegradePlan['scope'] | undefined
1037
+ ): RegradeCollectionScope | null | undefined => {
1038
+ const exclude = scope?.exclude;
1039
+ const extensions = scope?.extensions;
1040
+ const include = scope?.include;
1041
+ const explicitExtensions = extensions !== undefined;
1042
+ const codeExtensions =
1043
+ extensions === undefined
1044
+ ? symbolSourceExtensions
1045
+ : uniqueSorted(
1046
+ extensions
1047
+ .map(normalizeExtension)
1048
+ .filter((extension) => symbolSourceExtensions.includes(extension))
1049
+ );
1050
+ if (explicitExtensions && codeExtensions.length === 0) {
1051
+ return null;
1052
+ }
1053
+ if (
1054
+ exclude === undefined &&
1055
+ codeExtensions.length === 0 &&
1056
+ include === undefined
1057
+ ) {
1058
+ return undefined;
1059
+ }
1060
+ return {
1061
+ ...(exclude === undefined ? {} : { exclude }),
1062
+ ...(codeExtensions.length === 0 ? {} : { extensions: codeExtensions }),
1063
+ ...(include === undefined ? {} : { include }),
1064
+ };
1065
+ };
1066
+
1067
+ const vocabularyEvidenceScope = (
1068
+ scope: VocabularyRegradePlan['scope'] | undefined
1069
+ ): NonNullable<VocabularyRegradePlan['scope']> | null => {
1070
+ const explicitExtensions = scope?.extensions !== undefined;
1071
+ const extensions =
1072
+ scope?.extensions === undefined
1073
+ ? vocabularyEvidenceExtensions
1074
+ : uniqueSorted(
1075
+ scope.extensions
1076
+ .map(normalizeExtension)
1077
+ .filter((extension) =>
1078
+ vocabularyEvidenceExtensions.includes(extension)
1079
+ )
1080
+ );
1081
+
1082
+ if (explicitExtensions && extensions.length === 0) {
1083
+ return null;
1084
+ }
1085
+
1086
+ return {
1087
+ ...(scope?.exclude === undefined ? {} : { exclude: scope.exclude }),
1088
+ extensions,
1089
+ ...(scope?.ignoredDirectories === undefined
1090
+ ? {}
1091
+ : { ignoredDirectories: scope.ignoredDirectories }),
1092
+ ...(scope?.include === undefined ? {} : { include: scope.include }),
1093
+ ...(scope?.policyClassified === undefined
1094
+ ? {}
1095
+ : { policyClassified: scope.policyClassified }),
1096
+ ...(scope?.teachingSurfaces === undefined
1097
+ ? {}
1098
+ : { teachingSurfaces: scope.teachingSurfaces }),
1099
+ };
1100
+ };
1101
+
1102
+ const vocabularyEvidencePlan = (
1103
+ plan: VocabularyRegradePlan
1104
+ ): VocabularyRegradePlan | null => {
1105
+ const scope = vocabularyEvidenceScope(plan.scope);
1106
+ if (scope === null) {
1107
+ return null;
1108
+ }
1109
+
1110
+ return { ...plan, scope };
1111
+ };
1112
+
1113
+ const withoutNotSelectedSourceCount = (
1114
+ counts: Readonly<Record<string, number>>
1115
+ ): Readonly<Record<string, number>> =>
1116
+ Object.fromEntries(
1117
+ Object.entries(counts).filter(
1118
+ ([reason]) => reason !== 'not-selected-source'
1119
+ )
1120
+ );
1121
+
1122
+ const withoutVocabularySourceFilterSkips = (
1123
+ report: RegradeReport | null
1124
+ ): RegradeReport | null => {
1125
+ const rejected = report?.skipsByReason['not-selected-source'] ?? 0;
1126
+ if (report === null || rejected === 0) {
1127
+ return report;
1128
+ }
1129
+ return withScannedPaths(
1130
+ {
1131
+ ...report,
1132
+ ...(report.apply === undefined
1133
+ ? {}
1134
+ : {
1135
+ apply: {
1136
+ ...report.apply,
1137
+ skipped: Math.max(0, report.apply.skipped - rejected),
1138
+ },
1139
+ }),
1140
+ entries: report.entries.filter(
1141
+ (entry) => entry.reason !== 'not-selected-source'
1142
+ ),
1143
+ scan: {
1144
+ ...report.scan,
1145
+ files: {
1146
+ ...report.scan.files,
1147
+ skipped: Math.max(0, report.scan.files.skipped - rejected),
1148
+ },
1149
+ skippedByReason: withoutNotSelectedSourceCount(
1150
+ report.scan.skippedByReason
1151
+ ),
1152
+ },
1153
+ skipped: Math.max(0, report.skipped - rejected),
1154
+ skipsByReason: withoutNotSelectedSourceCount(report.skipsByReason),
1155
+ },
1156
+ report.scannedPaths ?? []
1157
+ );
1158
+ };
1159
+
1160
+ const vocabularyEvidenceSource = (
1161
+ path: string,
1162
+ scope: VocabularyRegradePlan['scope'] | undefined,
1163
+ includeCodeComments = false
1164
+ ): boolean =>
1165
+ vocabularyProseExtensions.includes(extname(path)) ||
1166
+ (includeCodeComments && symbolSourceExtensions.includes(extname(path))) ||
1167
+ symbolOccurrenceIsPolicyClassified(scope, path);
1168
+
1169
+ const classifiedCommentInventoryApplies = (
1170
+ plan: VocabularyRegradePlan
1171
+ ): boolean =>
1172
+ vocabularyRegradeTransitionForInput(plan.from, plan.to)?.target.kind ===
1173
+ 'classified';
1174
+
1175
+ const vocabularyEvidenceSourceKind = (
1176
+ path: string,
1177
+ plan: VocabularyRegradePlan
1178
+ ): 'all' | 'comments' =>
1179
+ classifiedCommentInventoryApplies(plan) &&
1180
+ symbolSourceExtensions.includes(extname(path)) &&
1181
+ !symbolOccurrenceIsPolicyClassified(plan.scope, path)
1182
+ ? 'comments'
1183
+ : 'all';
1184
+
1185
+ const vocabularyProseEngineApplies = (
1186
+ scope: VocabularyRegradePlan['scope'] | undefined
1187
+ ): boolean =>
1188
+ scope?.extensions === undefined ||
1189
+ scope.extensions.some((extension) =>
1190
+ vocabularyProseExtensions.includes(normalizeExtension(extension))
1191
+ );
1192
+
1193
+ const mergeVocabularyOverrides = (
1194
+ registryPlan: VocabularyRegradePlan | undefined,
1195
+ input: z.output<typeof regradeInputSchema>
1196
+ ): VocabularyRegradePlan['overrides'] | undefined => {
1197
+ const overrides = {
1198
+ ...registryPlan?.overrides,
1199
+ ...input.overrides,
1200
+ };
1201
+ return Object.keys(overrides).length === 0 ? undefined : overrides;
1202
+ };
1203
+
1204
+ const mergeVocabularyPreserveRules = (
1205
+ registryPlan: VocabularyRegradePlan | undefined,
1206
+ preserve: readonly VocabularyPreserveRule[] | undefined
1207
+ ): readonly VocabularyPreserveRule[] | undefined => {
1208
+ const rules = [...(registryPlan?.preserve ?? []), ...(preserve ?? [])];
1209
+ return rules.length === 0 ? undefined : rules;
1210
+ };
1211
+
1212
+ const vocabularyIntentForInput = (
1213
+ input: z.output<typeof regradeInputSchema>,
1214
+ registryPlan: VocabularyRegradePlan | undefined
1215
+ ): string | undefined => {
1216
+ if (input.intent !== undefined) {
1217
+ return input.intent;
1218
+ }
1219
+ return registryPlan?.intent;
1220
+ };
1221
+
1222
+ const classifiedOverrideError = (
1223
+ input: RegradeInput & { readonly from: string; readonly to: string }
1224
+ ): ValidationError | undefined => {
1225
+ const transition = vocabularyRegradeTransitionForInput(input.from, input.to);
1226
+ return transition?.target.kind === 'classified' &&
1227
+ input.overrides !== undefined
1228
+ ? new ValidationError(
1229
+ 'Classified governed vocabulary transitions are review-only and cannot accept rewrite overrides.'
1230
+ )
1231
+ : undefined;
1232
+ };
1233
+
1234
+ const governedTargetError = (
1235
+ input: RegradeInput & { readonly from: string; readonly to: string }
1236
+ ): ValidationError | undefined => {
1237
+ const governedFormTransition = listGovernedVocabularyTransitions().find(
1238
+ (candidate) =>
1239
+ candidate.from !== input.from &&
1240
+ (candidate.oldForms.includes(input.from) ||
1241
+ candidate.reviewForms.includes(input.from))
1242
+ );
1243
+ if (governedFormTransition !== undefined) {
1244
+ return new ValidationError(
1245
+ `Governed vocabulary form "${input.from}" belongs to transition "${governedFormTransition.id}". Plan from its canonical source "${governedFormTransition.from}" instead.`
1246
+ );
1247
+ }
1248
+ const transition = listGovernedVocabularyTransitions().find(
1249
+ (candidate) => candidate.from === input.from
1250
+ );
1251
+ if (
1252
+ transition === undefined ||
1253
+ vocabularyRegradeTransitionForInput(input.from, input.to) !== undefined
1254
+ ) {
1255
+ return undefined;
1256
+ }
1257
+ const expectedTargets =
1258
+ transition.target.kind === 'single'
1259
+ ? [transition.target.to]
1260
+ : transition.target.options.map((option) => option.to);
1261
+ return new ValidationError(
1262
+ `Governed vocabulary transition "${transition.id}" does not define target "${input.to}". Expected ${expectedTargets.map((target) => `"${target}"`).join(' or ')}`
1263
+ );
1264
+ };
1265
+
1266
+ const registryFileRenamesForRoot = (
1267
+ registryPlan: VocabularyRegradePlan | undefined,
1268
+ rootDir: string | undefined
1269
+ ): VocabularyRegradePlan['fileRenames'] | undefined => {
1270
+ const fileRenames = registryPlan?.fileRenames?.filter(
1271
+ (rename) =>
1272
+ rootDir === undefined ||
1273
+ existsSync(join(rootDir, rename.from)) ||
1274
+ existsSync(join(rootDir, rename.to))
1275
+ );
1276
+ return fileRenames?.length === 0 ? undefined : fileRenames;
1277
+ };
1278
+
1279
+ const buildVocabularyPlan = (
1280
+ input: RegradeInput,
1281
+ configScope?: VocabularyRegradePlan['scope'],
1282
+ rootDir?: string
1283
+ ): TrailsResult<VocabularyRegradePlan, ValidationError> => {
1284
+ if (input.from === undefined || input.to === undefined) {
1285
+ return Result.err(
1286
+ new ValidationError('A vocabulary regrade requires both `from` and `to`.')
1287
+ );
1288
+ }
1289
+ if (input.classIds !== undefined) {
1290
+ return Result.err(
1291
+ new ValidationError(
1292
+ '`classIds` selects class-mode Regrade and cannot be combined with vocabulary-regrade `from`/`to`.'
1293
+ )
1294
+ );
1295
+ }
1296
+
1297
+ const preserve = vocabularyPreserveFromInput(input.preserve);
1298
+ const targetError = governedTargetError({
1299
+ ...input,
1300
+ from: input.from,
1301
+ to: input.to,
1302
+ });
1303
+ if (targetError !== undefined) {
1304
+ return Result.err(targetError);
1305
+ }
1306
+ const registryPlan = vocabularyRegistryPlanForInput(input);
1307
+ const overrideError = classifiedOverrideError({
1308
+ ...input,
1309
+ from: input.from,
1310
+ to: input.to,
1311
+ });
1312
+ if (overrideError !== undefined) {
1313
+ return Result.err(overrideError);
1314
+ }
1315
+ const intent = vocabularyIntentForInput(input, registryPlan);
1316
+ const overrides = mergeVocabularyOverrides(registryPlan, input);
1317
+ const preserveRules = mergeVocabularyPreserveRules(registryPlan, preserve);
1318
+ const scope = mergeVocabularyScope(registryPlan?.scope, configScope, input);
1319
+ const fileRenames = (
1320
+ input.fileRenames ?? registryFileRenamesForRoot(registryPlan, rootDir)
1321
+ )?.map((rename) => ({
1322
+ from: posix.normalize(rename.from.replaceAll('\\', '/')),
1323
+ to: posix.normalize(rename.to.replaceAll('\\', '/')),
1324
+ }));
1325
+
1326
+ return Result.ok({
1327
+ ...(registryPlan?.caseSensitive === undefined
1328
+ ? {}
1329
+ : { caseSensitive: registryPlan.caseSensitive }),
1330
+ ...(registryPlan?.deferForms === undefined
1331
+ ? {}
1332
+ : { deferForms: registryPlan.deferForms }),
1333
+ ...(fileRenames === undefined ? {} : { fileRenames }),
1334
+ from: input.from,
1335
+ id: registryPlan?.id ?? `vocabulary:${input.from}->${input.to}`,
1336
+ kind: 'vocabulary',
1337
+ ...(intent === undefined ? {} : { intent }),
1338
+ ...(overrides === undefined ? {} : { overrides }),
1339
+ ...(preserveRules === undefined ? {} : { preserve: preserveRules }),
1340
+ ...(scope === undefined ? {} : { scope }),
1341
+ to: input.to,
1342
+ });
1343
+ };
1344
+
1345
+ const withDerivedTeachingSurfaceInventory = (params: {
1346
+ readonly plan: VocabularyRegradePlan;
1347
+ readonly report: RegradeReport;
1348
+ }): VocabularyRegradePlan => {
1349
+ const expected = params.plan.scope?.teachingSurfaces;
1350
+ if (expected === undefined) {
1351
+ return params.plan;
1352
+ }
1353
+ const teachingSurfaces = uniqueSorted(
1354
+ (params.report.run?.ledger.occurrences ?? [])
1355
+ .filter(
1356
+ (occurrence) =>
1357
+ occurrence.scopeTier === 'in-scope' &&
1358
+ !isGeneratedRegradeArtifactPath(occurrence.path) &&
1359
+ matchesAnyPathGlob(occurrence.path, expected)
1360
+ )
1361
+ .map((occurrence) => occurrence.path)
1362
+ );
1363
+ const scope = { ...params.plan.scope };
1364
+ if (teachingSurfaces.length === 0) {
1365
+ delete scope.teachingSurfaces;
1366
+ } else {
1367
+ scope.teachingSurfaces = teachingSurfaces;
1368
+ }
1369
+ return { ...params.plan, scope };
1370
+ };
1371
+
1372
+ const regradeRootNotFound = (rootDir: string) =>
1373
+ Result.err(
1374
+ new NotFoundError(
1375
+ `Regrade root "${rootDir}" could not be read as a directory.`
1376
+ )
1377
+ );
1378
+
1379
+ const regradeNoEngineForScope = () =>
1380
+ Result.err(
1381
+ new ValidationError(
1382
+ 'Vocabulary regrade has no prose or governed symbol engine for the selected extension scope.'
1383
+ )
1384
+ );
1385
+
1386
+ const regradeRootIsReadable = (rootDir: string): boolean => {
1387
+ try {
1388
+ readdirSync(rootDir, { withFileTypes: true });
1389
+ return true;
1390
+ } catch {
1391
+ return false;
1392
+ }
1393
+ };
1394
+
1395
+ const validateRegradeReport = (
1396
+ report: RegradeReport
1397
+ ): TrailsResult<RegradeReport, Error> => {
1398
+ const validated = validateOutput(regradeReportOutput, report);
1399
+ if (validated.isErr()) {
1400
+ return validated;
1401
+ }
1402
+ return Result.ok(report);
1403
+ };
1404
+
1405
+ const reportWithVocabularyTransitionRun = (params: {
1406
+ readonly plan: VocabularyRegradePlan;
1407
+ readonly preserveInventory: readonly VocabularyPreserveInventoryEntry[];
1408
+ readonly report: RegradeReport;
1409
+ }): RegradeReport => {
1410
+ const preserveInventory =
1411
+ params.preserveInventory.length === 0
1412
+ ? params.report.run?.preserveInventory
1413
+ : params.preserveInventory;
1414
+ const run = params.report.run ?? {
1415
+ ledger: { cycle: 1, forms: {}, occurrences: [] },
1416
+ plan: params.plan,
1417
+ report: transitionRunReportForRegradeReport(params.report),
1418
+ };
1419
+
1420
+ return withScannedPaths(
1421
+ {
1422
+ ...params.report,
1423
+ run: {
1424
+ ...run,
1425
+ plan: params.plan,
1426
+ ...(preserveInventory === undefined ? {} : { preserveInventory }),
1427
+ report:
1428
+ params.report.run === undefined
1429
+ ? transitionRunReportForRegradeReport(params.report)
1430
+ : params.report.run.report,
1431
+ },
1432
+ },
1433
+ params.report.scannedPaths ?? []
1434
+ );
1435
+ };
1436
+
1437
+ const governedSymbolRegradeConfiguration = (params: {
1438
+ readonly plan: VocabularyRegradePlan;
1439
+ readonly preserveInventory: readonly VocabularyPreserveInventoryEntry[];
1440
+ }) => {
1441
+ const transition = vocabularyRegradeTransitionForInput(
1442
+ params.plan.from,
1443
+ params.plan.to
1444
+ );
1445
+ if (transition === undefined) {
1446
+ return null;
1447
+ }
1448
+
1449
+ const symbolCollection = vocabularySymbolCollection(params.plan.scope);
1450
+ if (symbolCollection === null) {
1451
+ return null;
1452
+ }
1453
+ return {
1454
+ classes: createGovernedAstIdentifierRenameClasses(
1455
+ {
1456
+ ...transition,
1457
+ symbolRenames: transition.symbolRenames,
1458
+ },
1459
+ {
1460
+ shouldPreserve: (occurrence) =>
1461
+ symbolOccurrenceIsPolicyClassified(
1462
+ params.plan.scope,
1463
+ occurrence.path
1464
+ ) ||
1465
+ symbolOccurrenceIsPreserved(params.plan.preserve, {
1466
+ end: occurrence.end,
1467
+ form: occurrence.from,
1468
+ path: occurrence.path,
1469
+ source: occurrence.source,
1470
+ start: occurrence.start,
1471
+ }) ||
1472
+ symbolOccurrenceIsPreserved(params.preserveInventory, {
1473
+ end: occurrence.end,
1474
+ form: occurrence.from,
1475
+ path: occurrence.path,
1476
+ source: occurrence.source,
1477
+ start: occurrence.start,
1478
+ }),
1479
+ }
1480
+ ),
1481
+ ...(symbolCollection === undefined ? {} : { collection: symbolCollection }),
1482
+ };
1483
+ };
1484
+
1485
+ const runGovernedSymbolRegrade = (params: {
1486
+ readonly apply: boolean;
1487
+ readonly includeEntries: RegradeInput['includeEntries'];
1488
+ readonly plan: VocabularyRegradePlan;
1489
+ readonly preserveInventory: readonly VocabularyPreserveInventoryEntry[];
1490
+ readonly rootDir: string;
1491
+ }): TrailsResult<RegradeReport | null, Error> => {
1492
+ const configuration = governedSymbolRegradeConfiguration(params);
1493
+ if (configuration === null) {
1494
+ return Result.ok(null);
1495
+ }
1496
+ return runRegrade({
1497
+ ...configuration,
1498
+ apply: params.apply,
1499
+ includeEntries: params.includeEntries,
1500
+ root: params.rootDir,
1501
+ });
1502
+ };
1503
+
1504
+ const vocabularyRecordPathForInput = (
1505
+ rootDir: string,
1506
+ recordPath: string
1507
+ ): string => (isAbsolute(recordPath) ? recordPath : join(rootDir, recordPath));
1508
+
1509
+ const currentCommitSha = (rootDir: string): string | undefined => {
1510
+ try {
1511
+ return execFileSync(
1512
+ 'git',
1513
+ ['-C', rootDir, 'rev-parse', '--short=7', 'HEAD'],
1514
+ {
1515
+ encoding: 'utf8',
1516
+ stdio: ['ignore', 'pipe', 'ignore'],
1517
+ }
1518
+ ).trim();
1519
+ } catch {
1520
+ return undefined;
1521
+ }
1522
+ };
1523
+
1524
+ const vocabularyRecordEnvironment = (
1525
+ rootDir: string
1526
+ ): { readonly commitSha?: string; readonly root: string } => {
1527
+ const commitSha = currentCommitSha(rootDir);
1528
+ return {
1529
+ ...(commitSha === undefined ? {} : { commitSha }),
1530
+ root: rootDir,
1531
+ };
1532
+ };
1533
+
1534
+ const pendingExpansionCandidateCount = (plan: RegradePlanArtifact): number =>
1535
+ plan.expansion?.candidates.filter(
1536
+ (candidate) => candidate.status === 'pending'
1537
+ ).length ?? 0;
1538
+
1539
+ const reportWithPlanSummary = (
1540
+ report: RegradeReport,
1541
+ plan: RegradePlanArtifact,
1542
+ status: 'active' | 'stale'
1543
+ ): RegradeReport => ({
1544
+ ...report,
1545
+ plan: {
1546
+ ...(pendingExpansionCandidateCount(plan) === 0
1547
+ ? {}
1548
+ : { expansionPending: pendingExpansionCandidateCount(plan) }),
1549
+ path: plan.path,
1550
+ schemaVersion: plan.schemaVersion,
1551
+ status,
1552
+ },
1553
+ });
1554
+
1555
+ const reportWithHistorySummary = (
1556
+ report: RegradeReport,
1557
+ params: RegradeHistorySummary
1558
+ ): RegradeReport => ({
1559
+ ...report,
1560
+ history: {
1561
+ id: params.id,
1562
+ path: params.path,
1563
+ ...(params.provenance === undefined
1564
+ ? {}
1565
+ : { provenance: params.provenance }),
1566
+ schemaVersion: params.schemaVersion,
1567
+ status: params.status,
1568
+ },
1569
+ });
1570
+
1571
+ const authoredPlanFieldKeys = [
1572
+ 'caseSensitive',
1573
+ 'deferForms',
1574
+ 'fileRenames',
1575
+ 'id',
1576
+ 'intent',
1577
+ 'overrides',
1578
+ 'preserve',
1579
+ 'scope',
1580
+ ] as const;
1581
+
1582
+ const isAuthoredPlanField = (
1583
+ input: RegradePlanInput,
1584
+ key: (typeof authoredPlanFieldKeys)[number]
1585
+ ): boolean => {
1586
+ switch (key) {
1587
+ case 'intent':
1588
+ case 'fileRenames':
1589
+ case 'overrides':
1590
+ case 'preserve': {
1591
+ return input[key] !== undefined;
1592
+ }
1593
+ case 'scope': {
1594
+ return (
1595
+ input.exclude !== undefined ||
1596
+ input.extensions !== undefined ||
1597
+ input.include !== undefined ||
1598
+ input.policyClassified !== undefined ||
1599
+ input.teachingSurfaces !== undefined
1600
+ );
1601
+ }
1602
+ default: {
1603
+ return false;
1604
+ }
1605
+ }
1606
+ };
1607
+
1608
+ const regradePlanProvenanceForInput = (
1609
+ input: RegradePlanInput,
1610
+ plan: VocabularyRegradePlan
1611
+ ): RegradePlanArtifact['provenance'] => {
1612
+ const fields: Record<string, 'authored' | 'derived'> = {
1613
+ from: 'authored',
1614
+ kind: 'derived',
1615
+ to: 'authored',
1616
+ };
1617
+
1618
+ for (const key of [
1619
+ 'caseSensitive',
1620
+ 'deferForms',
1621
+ 'fileRenames',
1622
+ 'id',
1623
+ 'intent',
1624
+ 'overrides',
1625
+ 'preserve',
1626
+ 'scope',
1627
+ ] as const) {
1628
+ if (plan[key] !== undefined) {
1629
+ fields[key] = isAuthoredPlanField(input, key) ? 'authored' : 'derived';
1630
+ }
1631
+ }
1632
+
1633
+ return { fields };
1634
+ };
1635
+
1636
+ const mergeAuthoredPlanFields = (
1637
+ current: VocabularyRegradePlanArtifact,
1638
+ plan: VocabularyRegradePlan
1639
+ ): VocabularyRegradePlan => {
1640
+ const merged: Record<string, unknown> = { ...plan };
1641
+ for (const key of authoredPlanFieldKeys) {
1642
+ if (
1643
+ current.provenance.fields[key] === 'authored' &&
1644
+ current.plan[key] !== undefined
1645
+ ) {
1646
+ Object.assign(merged, { [key]: current.plan[key] });
1647
+ }
1648
+ }
1649
+ return vocabularyRegradePlanSchema.parse(merged) as VocabularyRegradePlan;
1650
+ };
1651
+
1652
+ const preserveAuthoredPlanProvenance = (
1653
+ current: VocabularyRegradePlanArtifact,
1654
+ provenance: RegradePlanArtifact['provenance']
1655
+ ): RegradePlanArtifact['provenance'] => {
1656
+ const fields = { ...provenance.fields };
1657
+ for (const key of authoredPlanFieldKeys) {
1658
+ if (
1659
+ current.provenance.fields[key] === 'authored' &&
1660
+ current.plan[key] !== undefined
1661
+ ) {
1662
+ fields[key] = 'authored';
1663
+ }
1664
+ }
1665
+ return { fields };
1666
+ };
1667
+
1668
+ const buildRegradePlanArtifact = (params: {
1669
+ readonly derivation?: RegradePlanArtifact['derivation'];
1670
+ readonly expansion?: RegradePlanArtifact['expansion'];
1671
+ readonly input: RegradePlanInput;
1672
+ readonly plan: VocabularyRegradePlan;
1673
+ readonly report: RegradeReport;
1674
+ readonly rootDir: string;
1675
+ readonly transitionId?: string | undefined;
1676
+ }): RegradePlanArtifact => {
1677
+ const absolutePath = regradePlanPathForPlan(params.rootDir, params.plan);
1678
+ return {
1679
+ ...(params.derivation === undefined
1680
+ ? {}
1681
+ : { derivation: params.derivation }),
1682
+ ...(params.expansion === undefined ? {} : { expansion: params.expansion }),
1683
+ kind: 'regrade-plan',
1684
+ path: rootRelativePath(params.rootDir, absolutePath),
1685
+ plan: params.plan,
1686
+ provenance: regradePlanProvenanceForInput(params.input, params.plan),
1687
+ schemaVersion: REGRADE_PLAN_SCHEMA_VERSION,
1688
+ sourceHash: regradeSourceHash(params.report),
1689
+ ...(params.transitionId === undefined
1690
+ ? {}
1691
+ : { transitionId: params.transitionId }),
1692
+ };
1693
+ };
1694
+
1695
+ const normalizeAuthoredPlanPath = (path: string): string =>
1696
+ posix.normalize(path.replaceAll('\\', '/'));
1697
+
1698
+ const normalizeRegradePlanBodyPaths = (
1699
+ plan: RegradePlanBody
1700
+ ): RegradePlanBody => {
1701
+ const scope =
1702
+ plan.scope === undefined
1703
+ ? undefined
1704
+ : {
1705
+ ...plan.scope,
1706
+ ...(plan.scope.exclude === undefined
1707
+ ? {}
1708
+ : {
1709
+ exclude: plan.scope.exclude.map(normalizeAuthoredPlanPath),
1710
+ }),
1711
+ ...(plan.scope.include === undefined
1712
+ ? {}
1713
+ : {
1714
+ include: plan.scope.include.map(normalizeAuthoredPlanPath),
1715
+ }),
1716
+ };
1717
+ if (plan.kind === 'class') {
1718
+ return {
1719
+ ...plan,
1720
+ ...(scope === undefined ? {} : { scope }),
1721
+ } as RegradePlanBody;
1722
+ }
1723
+ return {
1724
+ ...plan,
1725
+ ...(plan.fileRenames === undefined
1726
+ ? {}
1727
+ : {
1728
+ fileRenames: plan.fileRenames.map((rename) => ({
1729
+ ...rename,
1730
+ from: normalizeAuthoredPlanPath(rename.from),
1731
+ to: normalizeAuthoredPlanPath(rename.to),
1732
+ })),
1733
+ }),
1734
+ ...(plan.preserve === undefined
1735
+ ? {}
1736
+ : {
1737
+ preserve: plan.preserve.map((preserve) => ({
1738
+ ...preserve,
1739
+ ...(preserve.paths === undefined
1740
+ ? {}
1741
+ : {
1742
+ paths: preserve.paths.map(normalizeAuthoredPlanPath),
1743
+ }),
1744
+ })),
1745
+ }),
1746
+ ...(scope === undefined
1747
+ ? {}
1748
+ : {
1749
+ scope: {
1750
+ ...scope,
1751
+ ...(plan.scope?.ignoredDirectories === undefined
1752
+ ? {}
1753
+ : {
1754
+ ignoredDirectories: plan.scope.ignoredDirectories.map(
1755
+ normalizeAuthoredPlanPath
1756
+ ),
1757
+ }),
1758
+ ...(plan.scope?.policyClassified === undefined
1759
+ ? {}
1760
+ : {
1761
+ policyClassified: plan.scope.policyClassified.map(
1762
+ (policy) => ({
1763
+ ...policy,
1764
+ paths: policy.paths.map(normalizeAuthoredPlanPath),
1765
+ })
1766
+ ),
1767
+ }),
1768
+ ...(plan.scope?.teachingSurfaces === undefined
1769
+ ? {}
1770
+ : {
1771
+ teachingSurfaces: plan.scope.teachingSurfaces.map(
1772
+ normalizeAuthoredPlanPath
1773
+ ),
1774
+ }),
1775
+ },
1776
+ }),
1777
+ } as RegradePlanBody;
1778
+ };
1779
+
1780
+ const normalizeRegradePlanArtifactPaths = (
1781
+ artifact: RegradePlanArtifact
1782
+ ): RegradePlanArtifact => ({
1783
+ ...artifact,
1784
+ plan: normalizeRegradePlanBodyPaths(artifact.plan),
1785
+ });
1786
+
1787
+ const writeRegradePlanArtifact = (
1788
+ rootDir: string,
1789
+ artifact: RegradePlanArtifact
1790
+ ): TrailsResult<RegradePlanArtifact, InternalError | ValidationError> => {
1791
+ const normalizedArtifact = normalizeRegradePlanArtifactPaths(artifact);
1792
+ const parsed = regradePlanArtifactSchema.safeParse(normalizedArtifact);
1793
+ if (!parsed.success) {
1794
+ return Result.err(
1795
+ new ValidationError('Invalid Regrade plan artifact.', {
1796
+ context: { issues: parsed.error.issues },
1797
+ })
1798
+ );
1799
+ }
1800
+ const absolutePath = join(rootDir, artifact.path);
1801
+ try {
1802
+ mkdirSync(dirname(absolutePath), { recursive: true });
1803
+ writeFileSync(absolutePath, `${JSON.stringify(parsed.data, null, 2)}\n`);
1804
+ } catch (error) {
1805
+ return Result.err(
1806
+ new InternalError('Failed to write Regrade plan artifact.', {
1807
+ ...(error instanceof Error ? { cause: error } : {}),
1808
+ context: { path: artifact.path },
1809
+ })
1810
+ );
1811
+ }
1812
+ return Result.ok(parsed.data as unknown as RegradePlanArtifact);
1813
+ };
1814
+
1815
+ const validateRegradePlanArtifact = (
1816
+ artifact: RegradePlanArtifact
1817
+ ): TrailsResult<RegradePlanArtifact, ValidationError> => {
1818
+ const parsed = regradePlanArtifactSchema.safeParse(
1819
+ normalizeRegradePlanArtifactPaths(artifact)
1820
+ );
1821
+ if (!parsed.success) {
1822
+ return Result.err(
1823
+ new ValidationError('Invalid Regrade plan artifact.', {
1824
+ context: { issues: parsed.error.issues },
1825
+ })
1826
+ );
1827
+ }
1828
+ return Result.ok(parsed.data as unknown as RegradePlanArtifact);
1829
+ };
1830
+
1831
+ const readRegradePlanArtifact = (
1832
+ path: string
1833
+ ): TrailsResult<RegradePlanArtifact, InternalError | ValidationError> => {
1834
+ if (!existsSync(path)) {
1835
+ return Result.err(new ValidationError(`Regrade plan "${path}" not found.`));
1836
+ }
1837
+ let parsedJson: unknown;
1838
+ try {
1839
+ parsedJson = JSON.parse(readFileSync(path, 'utf8'));
1840
+ } catch (error) {
1841
+ return Result.err(
1842
+ new InternalError('Failed to read Regrade plan artifact.', {
1843
+ ...(error instanceof Error ? { cause: error } : {}),
1844
+ context: { path },
1845
+ })
1846
+ );
1847
+ }
1848
+ const parsed = regradePlanArtifactSchema.safeParse(parsedJson);
1849
+ if (!parsed.success) {
1850
+ return Result.err(
1851
+ new ValidationError('Invalid Regrade plan artifact.', {
1852
+ context: { issues: parsed.error.issues, path },
1853
+ })
1854
+ );
1855
+ }
1856
+ return Result.ok(
1857
+ normalizeRegradePlanArtifactPaths(
1858
+ parsed.data as unknown as RegradePlanArtifact
1859
+ )
1860
+ );
1861
+ };
1862
+
1863
+ /**
1864
+ * Transition identity is not an authored plan field — it follows the
1865
+ * transition. Plan re-derivation (including `--fresh`) carries it forward
1866
+ * from the existing active plan of the same kind so a subsequent apply
1867
+ * appends to the same consolidated history spine instead of forking it.
1868
+ */
1869
+ const priorTransitionId = (
1870
+ currentPath: string,
1871
+ kind: RegradePlanBody['kind']
1872
+ ): string | undefined => {
1873
+ if (!existsSync(currentPath)) {
1874
+ return undefined;
1875
+ }
1876
+ const existing = readRegradePlanArtifact(currentPath);
1877
+ if (existing.isErr() || existing.value.plan.kind !== kind) {
1878
+ return undefined;
1879
+ }
1880
+ return existing.value.transitionId;
1881
+ };
1882
+
1883
+ const hasPathSeparator = (value: string): boolean =>
1884
+ value.includes('/') || value.includes('\\');
1885
+
1886
+ const isPlanPathReference = (value: string): boolean =>
1887
+ hasPathSeparator(value) ||
1888
+ value.startsWith('.') ||
1889
+ value.startsWith('~') ||
1890
+ isAbsolute(value);
1891
+
1892
+ const collectActiveRegradePlanPaths = (rootDir: string): string[] => {
1893
+ const results: string[] = [];
1894
+ const skipDirectories = new Set([
1895
+ '.git',
1896
+ '.next',
1897
+ '.turbo',
1898
+ 'dist',
1899
+ 'node_modules',
1900
+ ]);
1901
+
1902
+ const visit = (dir: string): void => {
1903
+ let entries: Dirent[] | undefined;
1904
+ try {
1905
+ entries = readdirSync(dir, { withFileTypes: true });
1906
+ } catch {
1907
+ return;
1908
+ }
1909
+ if (entries === undefined) {
1910
+ return;
1911
+ }
1912
+
1913
+ if (
1914
+ entries.some((entry) => entry.isDirectory() && entry.name === '.trails')
1915
+ ) {
1916
+ const regradeDir = join(dir, '.trails', 'regrade');
1917
+ try {
1918
+ for (const entry of readdirSync(regradeDir, { withFileTypes: true })) {
1919
+ if (entry.isFile() && entry.name.endsWith('.json')) {
1920
+ results.push(join(regradeDir, entry.name));
1921
+ }
1922
+ }
1923
+ } catch {
1924
+ // Not every `.trails` directory has Regrade plans.
1925
+ }
1926
+ }
1927
+
1928
+ for (const entry of entries) {
1929
+ if (!entry.isDirectory() || skipDirectories.has(entry.name)) {
1930
+ continue;
1931
+ }
1932
+ visit(join(dir, entry.name));
1933
+ }
1934
+ };
1935
+
1936
+ visit(rootDir);
1937
+ return results.toSorted((left, right) => left.localeCompare(right));
1938
+ };
1939
+
1940
+ const resolveRegradePlanPath = (
1941
+ rootDir: string,
1942
+ planRef?: string | undefined
1943
+ ): TrailsResult<string, ValidationError> => {
1944
+ if (planRef !== undefined) {
1945
+ if (isPlanPathReference(planRef)) {
1946
+ const normalized = planRef.startsWith('~/')
1947
+ ? join(process.env['HOME'] ?? '', planRef.slice(2))
1948
+ : planRef;
1949
+ return Result.ok(
1950
+ isAbsolute(normalized) ? normalized : join(rootDir, normalized)
1951
+ );
1952
+ }
1953
+ const normalizedRef = planRef.endsWith('.json')
1954
+ ? planRef.slice(0, -'.json'.length)
1955
+ : planRef;
1956
+ const matches = collectActiveRegradePlanPaths(rootDir).filter(
1957
+ (candidate) => basename(candidate, '.json') === normalizedRef
1958
+ );
1959
+ if (matches.length === 1) {
1960
+ return Result.ok(matches[0] as string);
1961
+ }
1962
+ if (matches.length === 0) {
1963
+ return Result.err(
1964
+ new ValidationError(`No active Regrade plan named "${planRef}" found.`)
1965
+ );
1966
+ }
1967
+ return Result.err(
1968
+ new ValidationError(
1969
+ `Multiple active Regrade plans named "${planRef}" found.`,
1970
+ {
1971
+ context: {
1972
+ matches: matches.map((match) => rootRelativePath(rootDir, match)),
1973
+ },
1974
+ }
1975
+ )
1976
+ );
1977
+ }
1978
+
1979
+ const plans = collectActiveRegradePlanPaths(rootDir);
1980
+ if (plans.length === 1) {
1981
+ return Result.ok(plans[0] as string);
1982
+ }
1983
+ if (plans.length === 0) {
1984
+ return Result.err(new ValidationError('No active Regrade plans found.'));
1985
+ }
1986
+ return Result.err(
1987
+ new ValidationError('Multiple active Regrade plans found; pass `--plan`.', {
1988
+ context: { plans: plans.map((plan) => rootRelativePath(rootDir, plan)) },
1989
+ })
1990
+ );
1991
+ };
1992
+
1993
+ const planStatusForReport = (
1994
+ artifact: RegradePlanArtifact,
1995
+ report: RegradeReport,
1996
+ rootDir: string
1997
+ ): 'active' | 'stale' => {
1998
+ if (!currentRegradeSourceHashMatches(artifact.sourceHash, report)) {
1999
+ return 'stale';
2000
+ }
2001
+ if (artifact.plan.kind === 'class' || artifact.derivation === undefined) {
2002
+ return 'active';
2003
+ }
2004
+ const current = deriveRegradePlanDerivation({
2005
+ plan: artifact.plan,
2006
+ preserveInventory: report.run?.preserveInventory ?? [],
2007
+ provenance: artifact.provenance,
2008
+ report,
2009
+ rootDir,
2010
+ });
2011
+ return canonicalJsonStringify(current) ===
2012
+ canonicalJsonStringify(artifact.derivation)
2013
+ ? 'active'
2014
+ : 'stale';
2015
+ };
2016
+
2017
+ const regradePlanGateContext = (
2018
+ report: RegradeReport
2019
+ ):
2020
+ | {
2021
+ readonly gate?: unknown;
2022
+ readonly modified?: number;
2023
+ readonly review?: number;
2024
+ }
2025
+ | undefined => {
2026
+ const { apply, review, rewritten } = report;
2027
+ const modified = apply === undefined ? rewritten : 0;
2028
+ const counts = {
2029
+ ...(modified === 0 ? {} : { modified }),
2030
+ ...(review === 0 ? {} : { review }),
2031
+ };
2032
+ // Class-mode reports carry no vocabulary run: the gate is derived from the
2033
+ // outstanding rewrite and review counts alone.
2034
+ const gateStatus = report.run?.report.gate.status;
2035
+ if (gateStatus !== undefined && gateStatus !== 'green') {
2036
+ return { gate: report.run?.report.gate, ...counts };
2037
+ }
2038
+ if (modified > 0 || review > 0) {
2039
+ return { gate: report.run?.report.gate, ...counts };
2040
+ }
2041
+ return undefined;
2042
+ };
2043
+
2044
+ const persistVocabularyRecord = (params: {
2045
+ readonly report: RegradeReport;
2046
+ readonly rootDir: string;
2047
+ readonly status: 'applied' | 'candidate' | 'checked';
2048
+ }): TrailsResult<RegradeReport, Error> => {
2049
+ const recordResult = writeVocabularyTransitionRecord({
2050
+ environment: vocabularyRecordEnvironment(params.rootDir),
2051
+ report: params.report,
2052
+ root: params.rootDir,
2053
+ status: params.status,
2054
+ });
2055
+ if (recordResult.isErr()) {
2056
+ return recordResult;
2057
+ }
2058
+ return validateRegradeReport(
2059
+ transitionRecordReportWithSummary(params.report, recordResult.value.summary)
2060
+ );
2061
+ };
2062
+
2063
+ const withFileRenameEvidence = (params: {
2064
+ readonly plan: VocabularyRegradePlan;
2065
+ readonly report: RegradeReport & {
2066
+ readonly run: NonNullable<RegradeReport['run']>;
2067
+ };
2068
+ readonly run: FileRenameRegradeRun;
2069
+ }): RegradeReport => {
2070
+ const vocabularyPaths = params.report.run.ledger.occurrences
2071
+ .filter((occurrence) => occurrence.scopeTier === 'in-scope')
2072
+ .map((occurrence) => occurrence.path);
2073
+ const remainingPolicyPaths = new Map<string, number>();
2074
+ for (const path of params.run.policyOccurrencePaths) {
2075
+ remainingPolicyPaths.set(path, (remainingPolicyPaths.get(path) ?? 0) + 1);
2076
+ }
2077
+ const fileInScopePaths = params.run.occurrencePaths.filter((path) => {
2078
+ const remaining = remainingPolicyPaths.get(path) ?? 0;
2079
+ if (remaining === 0) {
2080
+ return true;
2081
+ }
2082
+ remainingPolicyPaths.set(path, remaining - 1);
2083
+ return false;
2084
+ });
2085
+ const evidencePaths = [...vocabularyPaths, ...fileInScopePaths];
2086
+ const expected = uniqueSorted(params.plan.scope?.teachingSurfaces ?? []);
2087
+ const touched = expected.filter((pattern) =>
2088
+ evidencePaths.some((path) => matchesAnyPathGlob(path, [pattern]))
2089
+ );
2090
+ const missing = expected.filter((pattern) => !touched.includes(pattern));
2091
+ const vocabularyPolicyPaths = params.report.run.ledger.occurrences
2092
+ .filter((occurrence) => occurrence.scopeTier === 'policy-classified')
2093
+ .map((occurrence) => occurrence.path);
2094
+ const policyPaths = [
2095
+ ...vocabularyPolicyPaths,
2096
+ ...params.run.policyOccurrencePaths,
2097
+ ];
2098
+ const policyEvidenceMissing =
2099
+ params.plan.scope?.policyClassified?.some(
2100
+ (policy) =>
2101
+ policy.expectMatches === true &&
2102
+ !policyPaths.some((path) => matchesAnyPathGlob(path, policy.paths))
2103
+ ) ?? false;
2104
+ const evidenceReasons = [
2105
+ ...(policyEvidenceMissing
2106
+ ? ['expected-policy-classified-evidence-missing']
2107
+ : []),
2108
+ ...(missing.length === 0 ? [] : ['expected-teaching-surfaces-missing']),
2109
+ ];
2110
+ const reasons = uniqueSorted([
2111
+ ...params.report.run.report.gate.reasons.filter(
2112
+ (reason) =>
2113
+ reason !== 'expected-policy-classified-evidence-missing' &&
2114
+ reason !== 'expected-teaching-surfaces-missing'
2115
+ ),
2116
+ ...evidenceReasons,
2117
+ ]);
2118
+ const filePolicyCount = params.run.policyOccurrencePaths.length;
2119
+ const fileInScopeCount = params.run.occurrencePaths.length - filePolicyCount;
2120
+ const derivedFileInScopeCount =
2121
+ params.run.report.rewritten + params.run.report.review;
2122
+ return {
2123
+ ...params.report,
2124
+ run: {
2125
+ ...params.report.run,
2126
+ report: {
2127
+ ...params.report.run.report,
2128
+ fileRenames: params.run.evidence,
2129
+ gate: {
2130
+ ...params.report.run.report.gate,
2131
+ reasons,
2132
+ status: reasons.length === 0 ? 'green' : 'open',
2133
+ },
2134
+ scopeTiers: {
2135
+ 'in-scope':
2136
+ params.report.run.report.scopeTiers['in-scope'] -
2137
+ derivedFileInScopeCount +
2138
+ fileInScopeCount,
2139
+ 'policy-classified':
2140
+ params.report.run.report.scopeTiers['policy-classified'] +
2141
+ filePolicyCount,
2142
+ },
2143
+ teachingSurfaces: { expected, missing, touched },
2144
+ },
2145
+ },
2146
+ };
2147
+ };
2148
+
2149
+ const combineVocabularyReports = (params: {
2150
+ readonly fileRenameRun: FileRenameRegradeRun | null;
2151
+ readonly plan: VocabularyRegradePlan;
2152
+ readonly preserveInventory: readonly VocabularyPreserveInventoryEntry[];
2153
+ readonly proseReport: RegradeReport | null;
2154
+ readonly symbolReport: RegradeReport | null;
2155
+ }): TrailsResult<RegradeReport, Error> => {
2156
+ const baseReport =
2157
+ params.proseReport ?? params.symbolReport ?? params.fileRenameRun?.report;
2158
+ if (baseReport === undefined) {
2159
+ return regradeNoEngineForScope();
2160
+ }
2161
+ let combined = reportWithVocabularyTransitionRun({
2162
+ plan: params.plan,
2163
+ preserveInventory: params.preserveInventory,
2164
+ report: baseReport,
2165
+ });
2166
+ if (params.proseReport !== null && params.symbolReport !== null) {
2167
+ combined = mergeRegradeReports(combined, params.symbolReport);
2168
+ }
2169
+ if (
2170
+ params.fileRenameRun !== null &&
2171
+ baseReport !== params.fileRenameRun.report
2172
+ ) {
2173
+ combined = mergeRegradeReports(combined, params.fileRenameRun.report);
2174
+ }
2175
+ if (params.fileRenameRun !== null && combined.run !== undefined) {
2176
+ combined = withFileRenameEvidence({
2177
+ plan: params.plan,
2178
+ report: { ...combined, run: combined.run },
2179
+ run: params.fileRenameRun,
2180
+ });
2181
+ }
2182
+ if (combined.apply !== undefined && params.fileRenameRun !== null) {
2183
+ const movedPaths = new Map(
2184
+ (params.plan.fileRenames ?? []).map((rename) => [
2185
+ posix.normalize(rename.from.replaceAll('\\', '/')),
2186
+ posix.normalize(rename.to.replaceAll('\\', '/')),
2187
+ ])
2188
+ );
2189
+ const changedPaths = new Set(params.fileRenameRun.changedPaths);
2190
+ for (const entry of combined.entries) {
2191
+ if (entry.outcome !== 'rewrite') {
2192
+ continue;
2193
+ }
2194
+ const normalizedEntryPath = posix.normalize(entry.path);
2195
+ const movedPath = movedPaths.get(normalizedEntryPath);
2196
+ changedPaths.add(
2197
+ movedPath !== undefined && changedPaths.has(movedPath)
2198
+ ? movedPath
2199
+ : normalizedEntryPath
2200
+ );
2201
+ }
2202
+ const filesChanged = changedPaths.size;
2203
+ combined = {
2204
+ ...combined,
2205
+ apply: { ...combined.apply, filesChanged },
2206
+ ...(combined.run === undefined
2207
+ ? {}
2208
+ : {
2209
+ run: {
2210
+ ...combined.run,
2211
+ report: { ...combined.run.report, filesChanged },
2212
+ },
2213
+ }),
2214
+ };
2215
+ }
2216
+ return validateRegradeReport(combined);
2217
+ };
2218
+
2219
+ const runPlanFileRenames = (
2220
+ plan: VocabularyRegradePlan,
2221
+ params: {
2222
+ readonly apply: boolean;
2223
+ readonly includeEntries: RegradeInput['includeEntries'];
2224
+ readonly rootDir: string;
2225
+ }
2226
+ ): TrailsResult<FileRenameRegradeRun | null, Error> =>
2227
+ plan.fileRenames === undefined || plan.fileRenames.length === 0
2228
+ ? Result.ok(null)
2229
+ : runFileRenameRegrade({
2230
+ apply: params.apply,
2231
+ excludeGeneratedArtifacts: true,
2232
+ includeEntries: params.includeEntries,
2233
+ renames: plan.fileRenames,
2234
+ root: params.rootDir,
2235
+ ...(plan.scope === undefined ? {} : { scope: plan.scope }),
2236
+ vocabularyPlan: plan,
2237
+ });
2238
+
2239
+ interface PreparedVocabularyPlanRun {
2240
+ readonly fileRenamePreflight: FileRenameRegradeRun | null;
2241
+ readonly identity: PreparedRegradeRunIdentity;
2242
+ readonly prose: PreparedVocabularyRegradeRun | null;
2243
+ readonly proseReport: RegradeReport | null;
2244
+ readonly preserveInventory: readonly VocabularyPreserveInventoryEntry[];
2245
+ readonly symbol: PreparedRegradeRun | null;
2246
+ }
2247
+
2248
+ const validatePreparedPlanIdentity = (
2249
+ expected: PreparedRegradeRunIdentity,
2250
+ actual: PreparedRegradeRunIdentity
2251
+ ): TrailsResult<void, ValidationError> => {
2252
+ for (const field of [
2253
+ 'planContentHash',
2254
+ 'policyHash',
2255
+ 'scopeHash',
2256
+ 'lockStateHash',
2257
+ 'toolVersion',
2258
+ ] as const) {
2259
+ if (expected[field] !== actual[field]) {
2260
+ return Result.err(
2261
+ new ValidationError(
2262
+ `Prepared Regrade identity field \`${field}\` is stale.`,
2263
+ {
2264
+ context: {
2265
+ actual: actual[field],
2266
+ expected: expected[field],
2267
+ field,
2268
+ },
2269
+ }
2270
+ )
2271
+ );
2272
+ }
2273
+ }
2274
+ return Result.ok();
2275
+ };
2276
+
2277
+ interface ResolvedVocabularyPlanParams {
2278
+ readonly apply: boolean;
2279
+ readonly currentIdentity?: PreparedRegradeRunIdentity | undefined;
2280
+ readonly includeEntries: RegradeInput['includeEntries'];
2281
+ readonly plan: VocabularyRegradePlan;
2282
+ readonly prepareIdentity?: PreparedRegradeRunIdentity | undefined;
2283
+ readonly prepared?: PreparedVocabularyPlanRun | undefined;
2284
+ readonly preparedResult?:
2285
+ | { value?: PreparedVocabularyPlanRun | undefined }
2286
+ | undefined;
2287
+ readonly preserveInventory: readonly VocabularyPreserveInventoryEntry[];
2288
+ readonly rootDir: string;
2289
+ }
2290
+
2291
+ interface VocabularyPlanExecution {
2292
+ readonly fileRenamePreflight: FileRenameRegradeRun | null;
2293
+ readonly prosePrepared: PreparedVocabularyRegradeRun | null;
2294
+ readonly prosePreviewReport: RegradeReport | null;
2295
+ readonly runProse: (
2296
+ apply: boolean
2297
+ ) => TrailsResult<RegradeReport | null, Error>;
2298
+ readonly runSymbols: (
2299
+ apply: boolean
2300
+ ) => TrailsResult<RegradeReport | null, Error>;
2301
+ readonly symbolPrepared: PreparedRegradeRun | null;
2302
+ readonly symbolPreviewReport: RegradeReport | null;
2303
+ }
2304
+
2305
+ const vocabularyPlanExecution = (
2306
+ params: ResolvedVocabularyPlanParams
2307
+ ): TrailsResult<VocabularyPlanExecution, Error> => {
2308
+ const fileRenamePreflight =
2309
+ params.prepared === undefined
2310
+ ? runPlanFileRenames(params.plan, {
2311
+ apply: false,
2312
+ includeEntries: params.includeEntries,
2313
+ rootDir: params.rootDir,
2314
+ })
2315
+ : Result.ok(params.prepared.fileRenamePreflight);
2316
+ if (fileRenamePreflight.isErr()) {
2317
+ return fileRenamePreflight;
2318
+ }
2319
+ const evidencePlan = vocabularyEvidencePlan(params.plan);
2320
+ const includeCodeComments = classifiedCommentInventoryApplies(params.plan);
2321
+ const runProse = (
2322
+ apply: boolean
2323
+ ): TrailsResult<RegradeReport | null, Error> =>
2324
+ evidencePlan === null
2325
+ ? Result.ok(null)
2326
+ : runVocabularyRegrade({
2327
+ apply,
2328
+ includeEntries: params.includeEntries,
2329
+ plan: evidencePlan,
2330
+ ...(params.preserveInventory.length === 0
2331
+ ? {}
2332
+ : { preserveInventory: params.preserveInventory }),
2333
+ root: params.rootDir,
2334
+ sourceFilter: (path) =>
2335
+ vocabularyEvidenceSource(
2336
+ path,
2337
+ evidencePlan.scope,
2338
+ includeCodeComments
2339
+ ),
2340
+ sourceKindForPath: (path) =>
2341
+ vocabularyEvidenceSourceKind(path, evidencePlan),
2342
+ });
2343
+ const runSymbols = (
2344
+ apply: boolean
2345
+ ): TrailsResult<RegradeReport | null, Error> =>
2346
+ runGovernedSymbolRegrade({
2347
+ apply,
2348
+ includeEntries: params.includeEntries,
2349
+ plan: params.plan,
2350
+ preserveInventory: params.preserveInventory,
2351
+ rootDir: params.rootDir,
2352
+ });
2353
+ const prosePrepared =
2354
+ params.prepareIdentity === undefined || evidencePlan === null
2355
+ ? Result.ok(null)
2356
+ : prepareVocabularyRegradeRun({
2357
+ identity: params.prepareIdentity,
2358
+ includeEntries: params.includeEntries,
2359
+ plan: evidencePlan,
2360
+ ...(params.preserveInventory.length === 0
2361
+ ? {}
2362
+ : { preserveInventory: params.preserveInventory }),
2363
+ root: params.rootDir,
2364
+ sourceFilter: (path) =>
2365
+ vocabularyEvidenceSource(
2366
+ path,
2367
+ evidencePlan.scope,
2368
+ includeCodeComments
2369
+ ),
2370
+ sourceKindForPath: (path) =>
2371
+ vocabularyEvidenceSourceKind(path, evidencePlan),
2372
+ });
2373
+ if (prosePrepared.isErr()) {
2374
+ return prosePrepared;
2375
+ }
2376
+ let prosePreview: TrailsResult<RegradeReport | null, Error>;
2377
+ if (params.prepared !== undefined) {
2378
+ prosePreview = Result.ok(params.prepared.proseReport);
2379
+ } else if (prosePrepared.value === null) {
2380
+ prosePreview = runProse(false);
2381
+ } else {
2382
+ prosePreview = Result.ok(prosePrepared.value.report);
2383
+ }
2384
+ if (prosePreview.isErr()) {
2385
+ return prosePreview;
2386
+ }
2387
+ const prosePreviewReport = withoutVocabularySourceFilterSkips(
2388
+ prosePreview.value
2389
+ );
2390
+ const symbolConfiguration = governedSymbolRegradeConfiguration({
2391
+ plan: params.plan,
2392
+ preserveInventory: params.preserveInventory,
2393
+ });
2394
+ const symbolPrepared =
2395
+ params.prepareIdentity === undefined || symbolConfiguration === null
2396
+ ? Result.ok(null)
2397
+ : prepareRegradeRun({
2398
+ ...symbolConfiguration,
2399
+ identity: params.prepareIdentity,
2400
+ includeEntries: params.includeEntries,
2401
+ root: params.rootDir,
2402
+ });
2403
+ if (symbolPrepared.isErr()) {
2404
+ return symbolPrepared;
2405
+ }
2406
+ // Apply reevaluates symbol work after prose because shared code comments can
2407
+ // change source bytes and offsets. Initial preparation retains the original
2408
+ // symbol source state solely for pre-mutation freshness validation.
2409
+ const symbolPreview =
2410
+ params.prepared !== undefined || symbolPrepared.value === null
2411
+ ? runSymbols(false)
2412
+ : Result.ok(symbolPrepared.value.report);
2413
+ if (symbolPreview.isErr()) {
2414
+ return symbolPreview;
2415
+ }
2416
+ return Result.ok({
2417
+ fileRenamePreflight: fileRenamePreflight.value,
2418
+ prosePrepared: prosePrepared.value,
2419
+ prosePreviewReport,
2420
+ runProse,
2421
+ runSymbols,
2422
+ symbolPrepared: symbolPrepared.value,
2423
+ symbolPreviewReport: symbolPreview.value,
2424
+ });
2425
+ };
2426
+
2427
+ const previewResolvedVocabularyPlan = (
2428
+ params: ResolvedVocabularyPlanParams,
2429
+ execution: VocabularyPlanExecution
2430
+ ): TrailsResult<RegradeReport, Error> => {
2431
+ if (
2432
+ execution.prosePreviewReport?.scanned === 0 &&
2433
+ execution.symbolPreviewReport === null &&
2434
+ execution.fileRenamePreflight === null &&
2435
+ !vocabularyProseEngineApplies(params.plan.scope)
2436
+ ) {
2437
+ return regradeNoEngineForScope();
2438
+ }
2439
+ const report = combineVocabularyReports({
2440
+ fileRenameRun: execution.fileRenamePreflight,
2441
+ plan: params.plan,
2442
+ preserveInventory: params.preserveInventory,
2443
+ proseReport: execution.prosePreviewReport,
2444
+ symbolReport: execution.symbolPreviewReport,
2445
+ });
2446
+ if (
2447
+ report.isOk() &&
2448
+ params.prepareIdentity !== undefined &&
2449
+ params.preparedResult !== undefined
2450
+ ) {
2451
+ params.preparedResult.value = {
2452
+ fileRenamePreflight: execution.fileRenamePreflight,
2453
+ identity: params.prepareIdentity,
2454
+ preserveInventory: params.preserveInventory,
2455
+ prose: execution.prosePrepared,
2456
+ proseReport: execution.prosePreviewReport,
2457
+ symbol: execution.symbolPrepared,
2458
+ };
2459
+ }
2460
+ return report;
2461
+ };
2462
+
2463
+ export const validatePreparedFileRenameSourceState = (params: {
2464
+ readonly includeEntries: RegradeInput['includeEntries'];
2465
+ readonly plan: VocabularyRegradePlan;
2466
+ readonly prepared: FileRenameRegradeRun | null;
2467
+ readonly rootDir: string;
2468
+ }): TrailsResult<void, Error> => {
2469
+ if (params.prepared === null) {
2470
+ return Result.ok();
2471
+ }
2472
+ const current = runPlanFileRenames(params.plan, {
2473
+ apply: false,
2474
+ includeEntries: params.includeEntries,
2475
+ rootDir: params.rootDir,
2476
+ });
2477
+ if (current.isErr()) {
2478
+ return current;
2479
+ }
2480
+ if (current.value === null) {
2481
+ return Result.err(
2482
+ new InternalError('Prepared Regrade file rename set changed.')
2483
+ );
2484
+ }
2485
+ if (current.value.sourceStateHash !== params.prepared.sourceStateHash) {
2486
+ return Result.err(
2487
+ new ValidationError(
2488
+ 'Prepared Regrade file rename source state is stale.',
2489
+ {
2490
+ context: {
2491
+ actual: current.value.sourceStateHash,
2492
+ expected: params.prepared.sourceStateHash,
2493
+ },
2494
+ }
2495
+ )
2496
+ );
2497
+ }
2498
+ return Result.ok();
2499
+ };
2500
+
2501
+ const validatePreparedVocabularyPlanState = (params: {
2502
+ readonly currentIdentity: PreparedRegradeRunIdentity;
2503
+ readonly execution: VocabularyPlanExecution;
2504
+ readonly includeEntries: RegradeInput['includeEntries'];
2505
+ readonly plan: VocabularyRegradePlan;
2506
+ readonly prepared: PreparedVocabularyPlanRun;
2507
+ readonly rootDir: string;
2508
+ }): TrailsResult<void, Error> => {
2509
+ const identity = validatePreparedPlanIdentity(
2510
+ params.prepared.identity,
2511
+ params.currentIdentity
2512
+ );
2513
+ if (identity.isErr()) {
2514
+ return identity;
2515
+ }
2516
+ const fileRenameState = validatePreparedFileRenameSourceState({
2517
+ includeEntries: params.includeEntries,
2518
+ plan: params.plan,
2519
+ prepared: params.execution.fileRenamePreflight,
2520
+ rootDir: params.rootDir,
2521
+ });
2522
+ if (fileRenameState.isErr() || params.prepared.symbol === null) {
2523
+ return fileRenameState;
2524
+ }
2525
+ return validatePreparedRegradeRun(
2526
+ params.prepared.symbol,
2527
+ params.currentIdentity
2528
+ );
2529
+ };
2530
+
2531
+ /**
2532
+ * Prepared vocabulary lifecycle seam for focused conformance tests.
2533
+ *
2534
+ * @internal
2535
+ */
2536
+ export const runResolvedVocabularyPlan = (
2537
+ params: ResolvedVocabularyPlanParams
2538
+ ): TrailsResult<RegradeReport, Error> => {
2539
+ const execution = vocabularyPlanExecution(params);
2540
+ if (execution.isErr()) {
2541
+ return execution;
2542
+ }
2543
+ if (!params.apply) {
2544
+ return previewResolvedVocabularyPlan(params, execution.value);
2545
+ }
2546
+
2547
+ let { currentIdentity } = params;
2548
+ if (params.prepared !== undefined) {
2549
+ if (currentIdentity === undefined) {
2550
+ return Result.err(
2551
+ new InternalError('Prepared Regrade apply is missing current identity.')
2552
+ );
2553
+ }
2554
+ const preparedState = validatePreparedVocabularyPlanState({
2555
+ currentIdentity,
2556
+ execution: execution.value,
2557
+ includeEntries: params.includeEntries,
2558
+ plan: params.plan,
2559
+ prepared: params.prepared,
2560
+ rootDir: params.rootDir,
2561
+ });
2562
+ if (preparedState.isErr()) {
2563
+ return preparedState;
2564
+ }
2565
+ }
2566
+
2567
+ const snapshots = snapshotRegradeSources({
2568
+ reports: [
2569
+ execution.value.prosePreviewReport,
2570
+ execution.value.symbolPreviewReport,
2571
+ ],
2572
+ rootDir: params.rootDir,
2573
+ });
2574
+ if (snapshots.isErr()) {
2575
+ return snapshots;
2576
+ }
2577
+ let reportResult: TrailsResult<RegradeReport | null, Error>;
2578
+ if (params.prepared?.prose === undefined || params.prepared.prose === null) {
2579
+ reportResult = execution.value.runProse(true);
2580
+ } else {
2581
+ currentIdentity ??= params.prepared.identity;
2582
+ reportResult = applyPreparedVocabularyRegradeRun(
2583
+ params.prepared.prose,
2584
+ currentIdentity
2585
+ );
2586
+ }
2587
+ if (reportResult.isErr()) {
2588
+ return regradeApplyErrorAfterRollback(reportResult.error, snapshots.value);
2589
+ }
2590
+ const symbolReportResult = execution.value.runSymbols(true);
2591
+ if (symbolReportResult.isErr()) {
2592
+ return regradeApplyErrorAfterRollback(
2593
+ symbolReportResult.error,
2594
+ snapshots.value
2595
+ );
2596
+ }
2597
+ const fileRenameResult = runPlanFileRenames(params.plan, {
2598
+ apply: true,
2599
+ includeEntries: params.includeEntries,
2600
+ rootDir: params.rootDir,
2601
+ });
2602
+ if (fileRenameResult.isErr()) {
2603
+ return regradeApplyErrorAfterRollback(
2604
+ fileRenameResult.error,
2605
+ snapshots.value
2606
+ );
2607
+ }
2608
+
2609
+ const report = withoutVocabularySourceFilterSkips(reportResult.value);
2610
+ const symbolReport = symbolReportResult.value;
2611
+ if (
2612
+ report?.scanned === 0 &&
2613
+ symbolReport === null &&
2614
+ fileRenameResult.value === null &&
2615
+ !vocabularyProseEngineApplies(params.plan.scope)
2616
+ ) {
2617
+ return regradeNoEngineForScope();
2618
+ }
2619
+ return combineVocabularyReports({
2620
+ fileRenameRun: fileRenameResult.value,
2621
+ plan: params.plan,
2622
+ preserveInventory: params.preserveInventory,
2623
+ proseReport: report,
2624
+ symbolReport,
2625
+ });
2626
+ };
2627
+
2628
+ interface ClassRegradeCoreParams {
2629
+ readonly apply: boolean;
2630
+ readonly classIds?: readonly string[] | undefined;
2631
+ readonly collection?:
2632
+ | {
2633
+ readonly exclude?: readonly string[] | undefined;
2634
+ readonly extensions?: readonly string[] | undefined;
2635
+ readonly include?: readonly string[] | undefined;
2636
+ }
2637
+ | undefined;
2638
+ readonly includeEntries: RegradeInput['includeEntries'];
2639
+ readonly packageSource?: RegradePackageSourceExpectation | undefined;
2640
+ readonly rootDir: string;
2641
+ }
2642
+
2643
+ type ClassRegradeCollection = NonNullable<
2644
+ Parameters<typeof runRegrade>[0]['collection']
2645
+ >;
2646
+
2647
+ const stablePackageSourceEvidence = (
2648
+ evidence: RegradePackageSourceEvidence
2649
+ ): Omit<RegradePackageSourceEvidence, 'resolvedPackagePath'> => ({
2650
+ artifactSha256: evidence.artifactSha256,
2651
+ contentSha256: evidence.contentSha256,
2652
+ declaredSpecifier: evidence.declaredSpecifier,
2653
+ kind: evidence.kind,
2654
+ name: evidence.name,
2655
+ version: evidence.version,
2656
+ });
2657
+
2658
+ const packageSourceEvidenceMatches = (
2659
+ left: RegradePackageSourceEvidence,
2660
+ right: RegradePackageSourceEvidence
2661
+ ): boolean =>
2662
+ JSON.stringify(stablePackageSourceEvidence(left)) ===
2663
+ JSON.stringify(stablePackageSourceEvidence(right));
2664
+
2665
+ const packageSourceExpectationMatches = (
2666
+ left: RegradePackageSourceExpectation,
2667
+ right: RegradePackageSourceExpectation
2668
+ ): boolean =>
2669
+ left.kind === right.kind &&
2670
+ left.name === right.name &&
2671
+ (left.kind === 'published' && right.kind === 'published'
2672
+ ? left.version === right.version
2673
+ : left.kind === 'tarball' &&
2674
+ right.kind === 'tarball' &&
2675
+ left.path === right.path &&
2676
+ left.sha256 === right.sha256);
2677
+
2678
+ interface VerifiedPackageSource {
2679
+ readonly evidence: RegradePackageSourceEvidence;
2680
+ readonly expectation: RegradePackageSourceExpectation;
2681
+ }
2682
+
2683
+ const verifyExpectedPackageSource = async (
2684
+ expectation: RegradePackageSourceExpectation | undefined,
2685
+ rootDir: string
2686
+ ): Promise<TrailsResult<VerifiedPackageSource | undefined, Error>> => {
2687
+ if (expectation === undefined) {
2688
+ return Result.ok();
2689
+ }
2690
+ const proof = await verifyDownstreamPackageSource({
2691
+ expected: expectation,
2692
+ root: rootDir,
2693
+ });
2694
+ return proof.isErr()
2695
+ ? proof
2696
+ : Result.ok({ evidence: proof.value, expectation });
2697
+ };
2698
+
2699
+ type LoadedWardenRegradeClasses = Awaited<
2700
+ ReturnType<typeof loadWardenRegradeClasses>
2701
+ >;
2702
+
2703
+ const loadVerifiedWardenRegradeClasses = async (params: {
2704
+ readonly initialProof?: VerifiedPackageSource | undefined;
2705
+ readonly packageSource?: RegradePackageSourceExpectation | undefined;
2706
+ readonly rootDir: string;
2707
+ }): Promise<
2708
+ TrailsResult<
2709
+ {
2710
+ readonly classSet: LoadedWardenRegradeClasses;
2711
+ readonly packageSource: VerifiedPackageSource | undefined;
2712
+ },
2713
+ Error
2714
+ >
2715
+ > => {
2716
+ const expectation = params.packageSource ?? params.initialProof?.expectation;
2717
+ const preloadPackageSource = await verifyExpectedPackageSource(
2718
+ expectation,
2719
+ params.rootDir
2720
+ );
2721
+ if (preloadPackageSource.isErr()) {
2722
+ return preloadPackageSource;
2723
+ }
2724
+ if (
2725
+ params.initialProof !== undefined &&
2726
+ (preloadPackageSource.value === undefined ||
2727
+ !packageSourceEvidenceMatches(
2728
+ params.initialProof.evidence,
2729
+ preloadPackageSource.value.evidence
2730
+ ))
2731
+ ) {
2732
+ return Result.err(
2733
+ new ConflictError(
2734
+ 'Prepared Regrade package-source evidence changed before loading migration classes.'
2735
+ )
2736
+ );
2737
+ }
2738
+ const classSet = await loadWardenRegradeClasses(params.rootDir);
2739
+ if (classSet.diagnostics.length > 0) {
2740
+ return Result.err(
2741
+ new InternalError('Failed to load Regrade project Warden rules.', {
2742
+ context: {
2743
+ diagnostics: classSet.diagnostics,
2744
+ rootDir: params.rootDir,
2745
+ },
2746
+ })
2747
+ );
2748
+ }
2749
+ const packageSource = await verifyExpectedPackageSource(
2750
+ expectation,
2751
+ params.rootDir
2752
+ );
2753
+ if (packageSource.isErr()) {
2754
+ return packageSource;
2755
+ }
2756
+ if (
2757
+ preloadPackageSource.value !== undefined &&
2758
+ (packageSource.value === undefined ||
2759
+ !packageSourceEvidenceMatches(
2760
+ preloadPackageSource.value.evidence,
2761
+ packageSource.value.evidence
2762
+ ))
2763
+ ) {
2764
+ return Result.err(
2765
+ new ConflictError(
2766
+ 'Regrade package-source evidence changed while loading migration classes.'
2767
+ )
2768
+ );
2769
+ }
2770
+ return Result.ok({ classSet, packageSource: packageSource.value });
2771
+ };
2772
+
2773
+ const runVerifiedClassRegradeCore = async (
2774
+ params: ClassRegradeCoreParams & {
2775
+ readonly packageSource: RegradePackageSourceExpectation;
2776
+ },
2777
+ collection: ClassRegradeCollection | undefined
2778
+ ): Promise<TrailsResult<RegradeReport, Error>> => {
2779
+ const verifiedClassSet = await loadVerifiedWardenRegradeClasses({
2780
+ packageSource: params.packageSource,
2781
+ rootDir: params.rootDir,
2782
+ });
2783
+ if (verifiedClassSet.isErr()) {
2784
+ return verifiedClassSet;
2785
+ }
2786
+ const identity: PreparedRegradeRunIdentity = {
2787
+ lockStateHash: 'direct-class-regrade',
2788
+ planContentHash: 'direct-class-regrade',
2789
+ policyHash: 'direct-class-regrade',
2790
+ scopeHash: 'direct-class-regrade',
2791
+ toolVersion: 'direct-class-regrade',
2792
+ };
2793
+ const prepared = prepareRegradeRun({
2794
+ classes: verifiedClassSet.value.classSet.classes,
2795
+ ...(collection === undefined ? {} : { collection }),
2796
+ identity,
2797
+ includeEntries: params.includeEntries,
2798
+ root: params.rootDir,
2799
+ ...(params.classIds === undefined
2800
+ ? {}
2801
+ : { selection: { classIds: params.classIds } }),
2802
+ });
2803
+ if (prepared.isErr()) {
2804
+ return prepared;
2805
+ }
2806
+ if (prepared.value === null) {
2807
+ return regradeRootNotFound(params.rootDir);
2808
+ }
2809
+ const finalPackageSource = await verifyExpectedPackageSource(
2810
+ params.packageSource,
2811
+ params.rootDir
2812
+ );
2813
+ if (finalPackageSource.isErr()) {
2814
+ return finalPackageSource;
2815
+ }
2816
+ if (
2817
+ finalPackageSource.value === undefined ||
2818
+ verifiedClassSet.value.packageSource === undefined ||
2819
+ !packageSourceEvidenceMatches(
2820
+ finalPackageSource.value.evidence,
2821
+ verifiedClassSet.value.packageSource.evidence
2822
+ )
2823
+ ) {
2824
+ return Result.err(
2825
+ new ConflictError(
2826
+ 'Regrade package-source evidence changed while evaluating migration classes.'
2827
+ )
2828
+ );
2829
+ }
2830
+ const reportResult = params.apply
2831
+ ? applyPreparedRegradeRun(prepared.value, identity)
2832
+ : Result.ok(prepared.value.report);
2833
+ if (reportResult.isErr()) {
2834
+ return reportResult;
2835
+ }
2836
+ return validateRegradeReport({
2837
+ ...reportResult.value,
2838
+ packageSource: finalPackageSource.value.evidence,
2839
+ });
2840
+ };
2841
+
2842
+ const runClassRegradeCore = async (
2843
+ params: ClassRegradeCoreParams
2844
+ ): Promise<TrailsResult<RegradeReport, Error>> => {
2845
+ const collection =
2846
+ params.collection === undefined
2847
+ ? undefined
2848
+ : {
2849
+ ...(params.collection.exclude === undefined
2850
+ ? {}
2851
+ : { exclude: params.collection.exclude }),
2852
+ ...(params.collection.extensions === undefined
2853
+ ? {}
2854
+ : { extensions: params.collection.extensions }),
2855
+ ...(params.collection.include === undefined
2856
+ ? {}
2857
+ : { include: params.collection.include }),
2858
+ };
2859
+ if (params.packageSource !== undefined) {
2860
+ return runVerifiedClassRegradeCore(
2861
+ { ...params, packageSource: params.packageSource },
2862
+ collection
2863
+ );
2864
+ }
2865
+ const classSet = await loadWardenRegradeClasses(params.rootDir);
2866
+ if (classSet.diagnostics.length > 0) {
2867
+ return Result.err(
2868
+ new InternalError('Failed to load Regrade project Warden rules.', {
2869
+ context: {
2870
+ diagnostics: classSet.diagnostics,
2871
+ rootDir: params.rootDir,
2872
+ },
2873
+ })
2874
+ );
2875
+ }
2876
+ const reportResult = runRegrade({
2877
+ apply: params.apply,
2878
+ classes: classSet.classes,
2879
+ ...(collection === undefined ? {} : { collection }),
2880
+ includeEntries: params.includeEntries,
2881
+ root: params.rootDir,
2882
+ ...(params.classIds === undefined
2883
+ ? {}
2884
+ : { selection: { classIds: params.classIds } }),
2885
+ });
2886
+ if (reportResult.isErr()) {
2887
+ return reportResult;
2888
+ }
2889
+ return reportResult.value === null
2890
+ ? regradeRootNotFound(params.rootDir)
2891
+ : validateRegradeReport(reportResult.value);
2892
+ };
2893
+
2894
+ const runClassPlanRegradeRun = (params: {
2895
+ readonly apply: boolean;
2896
+ readonly includeEntries: RegradePlanReferenceInput['includeEntries'];
2897
+ readonly plan: ClassRegradePlan;
2898
+ readonly rootDir: string;
2899
+ }): Promise<TrailsResult<RegradeReport, Error>> =>
2900
+ runClassRegradeCore({
2901
+ apply: params.apply,
2902
+ classIds: params.plan.classIds,
2903
+ ...(params.plan.scope === undefined
2904
+ ? {}
2905
+ : { collection: params.plan.scope }),
2906
+ includeEntries: params.includeEntries,
2907
+ ...(params.plan.packageSource === undefined
2908
+ ? {}
2909
+ : { packageSource: params.plan.packageSource }),
2910
+ rootDir: params.rootDir,
2911
+ });
2912
+
2913
+ const runPlanArtifactDryRun = async (params: {
2914
+ readonly artifact: RegradePlanArtifact;
2915
+ readonly includeEntries: RegradePlanReferenceInput['includeEntries'];
2916
+ readonly packageSource?: RegradePackageSourceExpectation | undefined;
2917
+ readonly rootDir: string;
2918
+ }): Promise<TrailsResult<RegradeReport, Error>> => {
2919
+ const planBody = params.artifact.plan;
2920
+ if (planBody.kind === 'class') {
2921
+ const packageSource = params.packageSource ?? planBody.packageSource;
2922
+ return runClassRegradeCore({
2923
+ apply: false,
2924
+ classIds: planBody.classIds,
2925
+ ...(planBody.scope === undefined ? {} : { collection: planBody.scope }),
2926
+ includeEntries: params.includeEntries,
2927
+ ...(packageSource === undefined ? {} : { packageSource }),
2928
+ rootDir: params.rootDir,
2929
+ });
2930
+ }
2931
+ const preserveResult = await deriveLiveApiPreserveInventory(
2932
+ planBody,
2933
+ params.rootDir
2934
+ );
2935
+ if (preserveResult.isErr()) {
2936
+ return preserveResult;
2937
+ }
2938
+ return runResolvedVocabularyPlan({
2939
+ apply: false,
2940
+ includeEntries: params.includeEntries,
2941
+ plan: planBody,
2942
+ preserveInventory: preserveResult.value,
2943
+ rootDir: params.rootDir,
2944
+ });
2945
+ };
2946
+
2947
+ interface PreparedClassPlanRun {
2948
+ readonly identity: PreparedRegradeRunIdentity;
2949
+ readonly run: PreparedRegradeRun;
2950
+ }
2951
+
2952
+ type PreparedPlanRun =
2953
+ | { readonly kind: 'class'; readonly prepared: PreparedClassPlanRun }
2954
+ | {
2955
+ readonly kind: 'vocabulary';
2956
+ readonly prepared: PreparedVocabularyPlanRun;
2957
+ };
2958
+
2959
+ interface PreparedPlanArtifactRun {
2960
+ readonly prepared: PreparedPlanRun;
2961
+ readonly report: RegradeReport;
2962
+ }
2963
+
2964
+ const prepareClassPlanArtifactRun = async (params: {
2965
+ readonly artifact: RegradePlanArtifact;
2966
+ readonly includeEntries: RegradePlanReferenceInput['includeEntries'];
2967
+ readonly packageSource?: VerifiedPackageSource | undefined;
2968
+ readonly plan: ClassRegradePlan;
2969
+ readonly rootDir: string;
2970
+ }): Promise<TrailsResult<PreparedPlanArtifactRun, Error>> => {
2971
+ const expectation =
2972
+ params.packageSource?.expectation ?? params.plan.packageSource;
2973
+ const verifiedClassSet = await loadVerifiedWardenRegradeClasses({
2974
+ ...(params.packageSource === undefined
2975
+ ? {}
2976
+ : { initialProof: params.packageSource }),
2977
+ ...(expectation === undefined ? {} : { packageSource: expectation }),
2978
+ rootDir: params.rootDir,
2979
+ });
2980
+ if (verifiedClassSet.isErr()) {
2981
+ return verifiedClassSet;
2982
+ }
2983
+ const { classSet, packageSource } = verifiedClassSet.value;
2984
+ const identity = preparedRegradeRunIdentity({
2985
+ artifact: params.artifact,
2986
+ classIds: classSet.classes.map((regradeClass) => regradeClass.id),
2987
+ classes: classSet.classes,
2988
+ includeEntries: params.includeEntries,
2989
+ rootDir: params.rootDir,
2990
+ });
2991
+ if (identity.isErr()) {
2992
+ return identity;
2993
+ }
2994
+ const prepared = prepareRegradeRun({
2995
+ classes: classSet.classes,
2996
+ ...(params.plan.scope === undefined
2997
+ ? {}
2998
+ : {
2999
+ collection: {
3000
+ ...(params.plan.scope.exclude === undefined
3001
+ ? {}
3002
+ : { exclude: params.plan.scope.exclude }),
3003
+ ...(params.plan.scope.extensions === undefined
3004
+ ? {}
3005
+ : { extensions: params.plan.scope.extensions }),
3006
+ ...(params.plan.scope.include === undefined
3007
+ ? {}
3008
+ : { include: params.plan.scope.include }),
3009
+ },
3010
+ }),
3011
+ identity: identity.value,
3012
+ includeEntries: params.includeEntries,
3013
+ root: params.rootDir,
3014
+ selection: { classIds: params.plan.classIds },
3015
+ });
3016
+ if (prepared.isErr()) {
3017
+ return prepared;
3018
+ }
3019
+ if (prepared.value === null) {
3020
+ return regradeRootNotFound(params.rootDir);
3021
+ }
3022
+ const finalPackageSource = await verifyExpectedPackageSource(
3023
+ expectation,
3024
+ params.rootDir
3025
+ );
3026
+ if (finalPackageSource.isErr()) {
3027
+ return finalPackageSource;
3028
+ }
3029
+ if (
3030
+ packageSource !== undefined &&
3031
+ (finalPackageSource.value === undefined ||
3032
+ !packageSourceEvidenceMatches(
3033
+ packageSource.evidence,
3034
+ finalPackageSource.value.evidence
3035
+ ))
3036
+ ) {
3037
+ return Result.err(
3038
+ new ConflictError(
3039
+ 'Regrade package-source evidence changed while evaluating migration classes.'
3040
+ )
3041
+ );
3042
+ }
3043
+ const report = validateRegradeReport(
3044
+ finalPackageSource.value === undefined
3045
+ ? prepared.value.report
3046
+ : {
3047
+ ...prepared.value.report,
3048
+ packageSource: finalPackageSource.value.evidence,
3049
+ }
3050
+ );
3051
+ if (report.isErr()) {
3052
+ return report;
3053
+ }
3054
+ return Result.ok({
3055
+ prepared: {
3056
+ kind: 'class',
3057
+ prepared: { identity: identity.value, run: prepared.value },
3058
+ },
3059
+ report: report.value,
3060
+ });
3061
+ };
3062
+
3063
+ const preparePlanArtifactRun = async (params: {
3064
+ readonly artifact: RegradePlanArtifact;
3065
+ readonly includeEntries: RegradePlanReferenceInput['includeEntries'];
3066
+ readonly packageSource?: VerifiedPackageSource | undefined;
3067
+ readonly rootDir: string;
3068
+ }): Promise<TrailsResult<PreparedPlanArtifactRun, Error>> => {
3069
+ const planBody = params.artifact.plan;
3070
+ if (planBody.kind === 'class') {
3071
+ return prepareClassPlanArtifactRun({
3072
+ artifact: params.artifact,
3073
+ includeEntries: params.includeEntries,
3074
+ ...(params.packageSource === undefined
3075
+ ? {}
3076
+ : { packageSource: params.packageSource }),
3077
+ plan: planBody,
3078
+ rootDir: params.rootDir,
3079
+ });
3080
+ }
3081
+
3082
+ const preserveResult = await deriveLiveApiPreserveInventory(
3083
+ planBody,
3084
+ params.rootDir
3085
+ );
3086
+ if (preserveResult.isErr()) {
3087
+ return preserveResult;
3088
+ }
3089
+ const identity = preparedRegradeRunIdentity({
3090
+ artifact: params.artifact,
3091
+ includeEntries: params.includeEntries,
3092
+ rootDir: params.rootDir,
3093
+ });
3094
+ if (identity.isErr()) {
3095
+ return identity;
3096
+ }
3097
+ const preparedResult: { value?: PreparedVocabularyPlanRun } = {};
3098
+ const report = runResolvedVocabularyPlan({
3099
+ apply: false,
3100
+ includeEntries: params.includeEntries,
3101
+ plan: planBody,
3102
+ prepareIdentity: identity.value,
3103
+ preparedResult,
3104
+ preserveInventory: preserveResult.value,
3105
+ rootDir: params.rootDir,
3106
+ });
3107
+ if (report.isErr()) {
3108
+ return report;
3109
+ }
3110
+ if (preparedResult.value === undefined) {
3111
+ return Result.err(
3112
+ new InternalError('Regrade vocabulary run was not prepared.')
3113
+ );
3114
+ }
3115
+ return Result.ok({
3116
+ prepared: { kind: 'vocabulary', prepared: preparedResult.value },
3117
+ report: report.value,
3118
+ });
3119
+ };
3120
+
3121
+ const runLegacyVocabularyRecordRegrade = (
3122
+ input: RegradeInput,
3123
+ rootDir: string,
3124
+ absoluteRecordPath: string
3125
+ ): TrailsResult<RegradeReport, Error> => {
3126
+ const recordResult = readVocabularyTransitionRecord(absoluteRecordPath);
3127
+ if (recordResult.isErr()) {
3128
+ return recordResult;
3129
+ }
3130
+ const record = recordResult.value;
3131
+ if (record.report.run === undefined) {
3132
+ return Result.err(
3133
+ new ValidationError(
3134
+ 'Transition record does not contain a vocabulary run.'
3135
+ )
3136
+ );
3137
+ }
3138
+ const dryRun = runResolvedVocabularyPlan({
3139
+ apply: false,
3140
+ includeEntries: input.includeEntries,
3141
+ plan: record.report.run.plan,
3142
+ preserveInventory: record.report.run.preserveInventory ?? [],
3143
+ rootDir,
3144
+ });
3145
+ if (dryRun.isErr()) {
3146
+ return dryRun;
3147
+ }
3148
+ if (regradeSourceHash(dryRun.value) !== regradeSourceHash(record.report)) {
3149
+ return Result.err(
3150
+ new ValidationError(
3151
+ 'Vocabulary transition record is stale for the current source tree. Re-run discovery and review the new record before applying.',
3152
+ { context: { recordPath: record.recordPath } }
3153
+ )
3154
+ );
3155
+ }
3156
+ if (input.check) {
3157
+ const checked = transitionRecordReportWithSummary(record.report, {
3158
+ path: record.recordPath,
3159
+ schemaVersion: record.schemaVersion,
3160
+ status: 'checked',
3161
+ });
3162
+ if (record.report.run.report.gate.status !== 'green') {
3163
+ return Result.err(
3164
+ new ValidationError('Vocabulary transition record gate is open.', {
3165
+ context: {
3166
+ gate: record.report.run.report.gate,
3167
+ recordPath: record.recordPath,
3168
+ },
3169
+ })
3170
+ );
3171
+ }
3172
+ return validateRegradeReport(checked);
3173
+ }
3174
+
3175
+ if (!input.apply) {
3176
+ return Result.err(
3177
+ new ValidationError(
3178
+ 'Applying a legacy vocabulary transition record requires `apply: true` or `--apply`. Use `--check` to verify the record without mutating source.',
3179
+ { context: { recordPath: record.recordPath } }
3180
+ )
3181
+ );
3182
+ }
3183
+
3184
+ const applied = runResolvedVocabularyPlan({
3185
+ apply: true,
3186
+ includeEntries: input.includeEntries,
3187
+ plan: record.report.run.plan,
3188
+ preserveInventory: record.report.run.preserveInventory ?? [],
3189
+ rootDir,
3190
+ });
3191
+ if (applied.isErr()) {
3192
+ return applied;
3193
+ }
3194
+ return persistVocabularyRecord({
3195
+ report: applied.value,
3196
+ rootDir,
3197
+ status: 'applied',
3198
+ });
3199
+ };
3200
+
3201
+ const runVocabularyCommandRegrade = async (
3202
+ input: RegradeInput,
3203
+ rootDir: string,
3204
+ configScope?: RegradeConfigScope | undefined
3205
+ ): Promise<TrailsResult<RegradeReport, Error>> => {
3206
+ if (input.apply && input.planRecord === undefined) {
3207
+ return Result.err(
3208
+ new ValidationError(
3209
+ 'Vocabulary regrade apply requires `planRecord`. Run discovery with `writeRecord` first, review the record, then apply the confirmed record.'
3210
+ )
3211
+ );
3212
+ }
3213
+ if (input.check && input.planRecord === undefined) {
3214
+ return Result.err(
3215
+ new ValidationError(
3216
+ 'Vocabulary regrade check requires `planRecord` so the gate is computed from persisted evidence.'
3217
+ )
3218
+ );
3219
+ }
3220
+ if (input.planRecord !== undefined) {
3221
+ const absoluteRecordPath = vocabularyRecordPathForInput(
3222
+ rootDir,
3223
+ input.planRecord
3224
+ );
3225
+ return runLegacyVocabularyRecordRegrade(input, rootDir, absoluteRecordPath);
3226
+ }
3227
+
3228
+ const planResult = buildVocabularyPlan(
3229
+ input,
3230
+ vocabularyScopeFromConfig(configScope),
3231
+ rootDir
3232
+ );
3233
+ if (planResult.isErr()) {
3234
+ return planResult;
3235
+ }
3236
+ if (!regradeRootIsReadable(rootDir)) {
3237
+ return regradeRootNotFound(rootDir);
3238
+ }
3239
+
3240
+ const preserveResult = await deriveLiveApiPreserveInventory(
3241
+ planResult.value,
3242
+ rootDir
3243
+ );
3244
+ if (preserveResult.isErr()) {
3245
+ return preserveResult;
3246
+ }
3247
+ const report = runResolvedVocabularyPlan({
3248
+ apply: input.apply,
3249
+ includeEntries: input.includeEntries,
3250
+ plan: planResult.value,
3251
+ preserveInventory: preserveResult.value,
3252
+ rootDir,
3253
+ });
3254
+ if (report.isErr() || !input.writeRecord) {
3255
+ return report;
3256
+ }
3257
+ return persistVocabularyRecord({
3258
+ report: report.value,
3259
+ rootDir,
3260
+ status: input.apply ? 'applied' : 'candidate',
3261
+ });
3262
+ };
3263
+
3264
+ const expansionCandidateKey = (
3265
+ candidate: RegradePlanExpansion['candidates'][number]
3266
+ ): string =>
3267
+ [candidate.kind, candidate.value, candidate.suggestedClassification].join(
3268
+ '\0'
3269
+ );
3270
+
3271
+ const expansionEvidenceKey = (
3272
+ evidence: RegradePlanExpansion['candidates'][number]['evidence'][number]
3273
+ ): string =>
3274
+ [
3275
+ evidence.path,
3276
+ evidence.line ?? '',
3277
+ evidence.column ?? '',
3278
+ evidence.detail ?? '',
3279
+ ].join('\0');
3280
+
3281
+ const mergeExpansionEvidence = (
3282
+ left: RegradePlanExpansion['candidates'][number]['evidence'],
3283
+ right: RegradePlanExpansion['candidates'][number]['evidence']
3284
+ ): RegradePlanExpansion['candidates'][number]['evidence'] => {
3285
+ const merged = new Map<
3286
+ string,
3287
+ RegradePlanExpansion['candidates'][number]['evidence'][number]
3288
+ >();
3289
+ for (const evidence of [...left, ...right]) {
3290
+ merged.set(expansionEvidenceKey(evidence), evidence);
3291
+ }
3292
+ return [...merged.values()].toSorted((a, b) =>
3293
+ a.path === b.path
3294
+ ? (a.line ?? 0) - (b.line ?? 0) ||
3295
+ (a.column ?? 0) - (b.column ?? 0) ||
3296
+ (a.detail ?? '').localeCompare(b.detail ?? '')
3297
+ : a.path.localeCompare(b.path)
3298
+ );
3299
+ };
3300
+
3301
+ const compareCandidates = (
3302
+ left: RegradePlanExpansion['candidates'][number],
3303
+ right: RegradePlanExpansion['candidates'][number]
3304
+ ): number => {
3305
+ if (left.kind !== right.kind) {
3306
+ return left.kind.localeCompare(right.kind);
3307
+ }
3308
+ if (left.value !== right.value) {
3309
+ return left.value.localeCompare(right.value);
3310
+ }
3311
+ return left.suggestedClassification.localeCompare(
3312
+ right.suggestedClassification
3313
+ );
3314
+ };
3315
+
3316
+ const expansionForReport = (
3317
+ report: RegradeReport
3318
+ ): RegradePlanArtifact['expansion'] => {
3319
+ const candidates = new Map<
3320
+ string,
3321
+ RegradePlanExpansion['candidates'][number]
3322
+ >();
3323
+ const candidateValues = new Set<string>();
3324
+ const addCandidate = (
3325
+ candidate: RegradePlanExpansion['candidates'][number]
3326
+ ): void => {
3327
+ const key = expansionCandidateKey(candidate);
3328
+ candidateValues.add(`${candidate.kind}\0${candidate.value}`);
3329
+ const current = candidates.get(key);
3330
+ if (current === undefined) {
3331
+ candidates.set(key, candidate);
3332
+ return;
3333
+ }
3334
+ candidates.set(key, {
3335
+ ...current,
3336
+ evidence: mergeExpansionEvidence(current.evidence, candidate.evidence),
3337
+ });
3338
+ };
3339
+
3340
+ for (const occurrence of report.run?.ledger.occurrences ?? []) {
3341
+ if (occurrence.verdict !== 'deferred') {
3342
+ continue;
3343
+ }
3344
+ addCandidate({
3345
+ evidence: [
3346
+ {
3347
+ column: occurrence.column,
3348
+ detail: occurrence.reason,
3349
+ line: occurrence.line,
3350
+ path: occurrence.path,
3351
+ },
3352
+ ],
3353
+ kind: 'form',
3354
+ provenance: 'derived',
3355
+ status: 'pending',
3356
+ suggestedClassification: occurrence.disposition,
3357
+ value: occurrence.form,
3358
+ });
3359
+ }
3360
+
3361
+ for (const entry of report.entries) {
3362
+ if (entry.outcome !== 'needs-review' || entry.reviewDetails === undefined) {
3363
+ continue;
3364
+ }
3365
+ for (const detail of entry.reviewDetails) {
3366
+ if (detail.symbol === undefined) {
3367
+ continue;
3368
+ }
3369
+ if (candidateValues.has(`form\0${detail.symbol}`)) {
3370
+ continue;
3371
+ }
3372
+ addCandidate({
3373
+ evidence: [
3374
+ {
3375
+ ...(detail.span === undefined
3376
+ ? {}
3377
+ : {
3378
+ column: detail.span.column,
3379
+ line: detail.span.line,
3380
+ }),
3381
+ detail: detail.reason,
3382
+ path: entry.path,
3383
+ },
3384
+ ],
3385
+ kind: 'form',
3386
+ provenance: 'derived',
3387
+ status: 'pending',
3388
+ suggestedClassification: entry.reason ?? detail.reason,
3389
+ value: detail.symbol,
3390
+ });
3391
+ }
3392
+ }
3393
+
3394
+ return { candidates: [...candidates.values()].toSorted(compareCandidates) };
3395
+ };
3396
+
3397
+ const preserveRuleCoversForm = (
3398
+ rule: VocabularyPreserveRule,
3399
+ form: string
3400
+ ): boolean =>
3401
+ (rule.forms === undefined || rule.forms.includes(form)) &&
3402
+ compileVocabularyPreservePattern(rule.pattern).test(form);
3403
+
3404
+ const preserveRuleCoversCandidateEvidence = (
3405
+ rule: VocabularyPreserveRule,
3406
+ form: string,
3407
+ evidence: RegradePlanExpansion['candidates'][number]['evidence'][number]
3408
+ ): boolean =>
3409
+ preserveRuleCoversForm(rule, form) &&
3410
+ (rule.paths === undefined || matchesAnyPathGlob(evidence.path, rule.paths));
3411
+
3412
+ const preserveRulesCoverFormCandidate = (
3413
+ preserve: readonly VocabularyPreserveRule[] | undefined,
3414
+ candidate: RegradePlanExpansion['candidates'][number]
3415
+ ): boolean => {
3416
+ if (preserve === undefined) {
3417
+ return false;
3418
+ }
3419
+ if (candidate.kind !== 'form') {
3420
+ return false;
3421
+ }
3422
+
3423
+ if (candidate.evidence.length === 0) {
3424
+ return preserve.some(
3425
+ (rule) =>
3426
+ rule.paths === undefined &&
3427
+ preserveRuleCoversForm(rule, candidate.value)
3428
+ );
3429
+ }
3430
+
3431
+ return candidate.evidence.every((evidence) =>
3432
+ preserve.some((rule) =>
3433
+ preserveRuleCoversCandidateEvidence(rule, candidate.value, evidence)
3434
+ )
3435
+ );
3436
+ };
3437
+
3438
+ const primaryPlanCoversExpansionCandidate = (
3439
+ plan: VocabularyRegradePlan,
3440
+ candidate: RegradePlanExpansion['candidates'][number]
3441
+ ): boolean => {
3442
+ if (candidate.kind !== 'form') {
3443
+ return false;
3444
+ }
3445
+ return (
3446
+ plan.deferForms?.includes(candidate.value) === true ||
3447
+ plan.overrides?.[candidate.value] !== undefined ||
3448
+ preserveRulesCoverFormCandidate(plan.preserve, candidate)
3449
+ );
3450
+ };
3451
+
3452
+ const mergeRegradePlanExpansion = (
3453
+ current: RegradePlanExpansion | undefined,
3454
+ next: RegradePlanExpansion | undefined,
3455
+ plan: VocabularyRegradePlan
3456
+ ): RegradePlanExpansion | undefined => {
3457
+ const candidates = new Map<
3458
+ string,
3459
+ RegradePlanExpansion['candidates'][number]
3460
+ >();
3461
+
3462
+ for (const candidate of current?.candidates ?? []) {
3463
+ if (primaryPlanCoversExpansionCandidate(plan, candidate)) {
3464
+ continue;
3465
+ }
3466
+ candidates.set(expansionCandidateKey(candidate), candidate);
3467
+ }
3468
+
3469
+ for (const candidate of next?.candidates ?? []) {
3470
+ if (primaryPlanCoversExpansionCandidate(plan, candidate)) {
3471
+ continue;
3472
+ }
3473
+ const key = expansionCandidateKey(candidate);
3474
+ const existing = candidates.get(key);
3475
+ if (existing?.status === 'rejected') {
3476
+ candidates.set(key, existing);
3477
+ continue;
3478
+ }
3479
+ candidates.set(key, {
3480
+ ...candidate,
3481
+ ...(existing === undefined
3482
+ ? {}
3483
+ : {
3484
+ evidence: mergeExpansionEvidence(
3485
+ existing.evidence,
3486
+ candidate.evidence
3487
+ ),
3488
+ status: existing.status,
3489
+ }),
3490
+ });
3491
+ }
3492
+
3493
+ const merged = [...candidates.values()]
3494
+ .filter(
3495
+ (candidate) => !primaryPlanCoversExpansionCandidate(plan, candidate)
3496
+ )
3497
+ .toSorted(compareCandidates);
3498
+ return merged.length === 0 ? undefined : { candidates: merged };
3499
+ };
3500
+
3501
+ const classPlanScopeForInput = (
3502
+ input: RegradePlanInput,
3503
+ configScope: RegradeConfigScope | undefined
3504
+ ): ClassRegradePlan['scope'] => {
3505
+ const exclude = input.exclude ?? configScope?.exclude;
3506
+ const extensions = input.extensions ?? configScope?.extensions;
3507
+ const include = input.include ?? configScope?.include;
3508
+ if (
3509
+ exclude === undefined &&
3510
+ extensions === undefined &&
3511
+ include === undefined
3512
+ ) {
3513
+ return undefined;
3514
+ }
3515
+ return {
3516
+ ...(exclude === undefined ? {} : { exclude: [...exclude] }),
3517
+ ...(extensions === undefined ? {} : { extensions: [...extensions] }),
3518
+ ...(include === undefined ? {} : { include: [...include] }),
3519
+ };
3520
+ };
3521
+
3522
+ const validateClassPlanInput = (
3523
+ input: RegradePlanInput
3524
+ ): ValidationError | null => {
3525
+ if (input.classIds === undefined || input.classIds.length === 0) {
3526
+ return new ValidationError(
3527
+ 'A class-mode Regrade plan requires at least one class id.'
3528
+ );
3529
+ }
3530
+ if (input.from !== undefined || input.to !== undefined) {
3531
+ return new ValidationError(
3532
+ '`classIds` selects a class-mode plan and cannot be combined with vocabulary `from`/`to`.'
3533
+ );
3534
+ }
3535
+ if (input.type === 'vocabulary') {
3536
+ return new ValidationError(
3537
+ '`type: vocabulary` cannot be combined with `classIds`.'
3538
+ );
3539
+ }
3540
+ if (input.fileRenames !== undefined && input.fileRenames.length > 0) {
3541
+ return new ValidationError(
3542
+ '`fileRenames` is not supported for class-mode plans; governed file moves require a vocabulary plan.'
3543
+ );
3544
+ }
3545
+ if (input.expand) {
3546
+ return new ValidationError(
3547
+ '`expand` stages vocabulary review candidates and is not supported for class-mode plans.'
3548
+ );
3549
+ }
3550
+ if (input.packageSource !== undefined) {
3551
+ const pathIssue = persistentPackageSourcePathIssue(input.packageSource);
3552
+ if (pathIssue !== undefined) {
3553
+ return new ValidationError(pathIssue, {
3554
+ context: {
3555
+ path:
3556
+ input.packageSource.kind === 'tarball'
3557
+ ? input.packageSource.path
3558
+ : undefined,
3559
+ },
3560
+ });
3561
+ }
3562
+ }
3563
+ return null;
3564
+ };
3565
+
3566
+ type ClassPlanArtifact = RegradePlanArtifact & {
3567
+ readonly plan: ClassRegradePlan;
3568
+ };
3569
+
3570
+ const readCurrentClassPlanArtifact = (
3571
+ input: RegradePlanInput,
3572
+ currentPath: string
3573
+ ): TrailsResult<ClassPlanArtifact | null, Error> => {
3574
+ if (input.fresh || !existsSync(currentPath)) {
3575
+ return Result.ok(null);
3576
+ }
3577
+ const currentResult = readRegradePlanArtifact(currentPath);
3578
+ if (currentResult.isErr()) {
3579
+ return currentResult;
3580
+ }
3581
+ const candidate = currentResult.value;
3582
+ if (candidate.plan.kind !== 'class') {
3583
+ return Result.ok(null);
3584
+ }
3585
+ return Result.ok({ ...candidate, plan: candidate.plan });
3586
+ };
3587
+
3588
+ /** Carry authored inputs forward from the existing plan artifact. */
3589
+ const mergeAuthoredClassPlanFields = (
3590
+ plan: ClassRegradePlan,
3591
+ input: RegradePlanInput,
3592
+ authoredScope: boolean,
3593
+ current: ClassPlanArtifact | null
3594
+ ): ClassRegradePlan => {
3595
+ if (current === null) {
3596
+ return plan;
3597
+ }
3598
+ let merged = plan;
3599
+ if (
3600
+ input.intent === undefined &&
3601
+ current.provenance.fields['intent'] === 'authored' &&
3602
+ current.plan.intent !== undefined
3603
+ ) {
3604
+ merged = { ...merged, intent: current.plan.intent };
3605
+ }
3606
+ if (
3607
+ input.name === undefined &&
3608
+ current.provenance.fields['name'] === 'authored' &&
3609
+ current.plan.name !== undefined
3610
+ ) {
3611
+ merged = { ...merged, name: current.plan.name };
3612
+ }
3613
+ if (
3614
+ input.packageSource === undefined &&
3615
+ current.provenance.fields['packageSource'] === 'authored' &&
3616
+ current.plan.packageSource !== undefined
3617
+ ) {
3618
+ merged = { ...merged, packageSource: current.plan.packageSource };
3619
+ }
3620
+ if (
3621
+ !authoredScope &&
3622
+ current.provenance.fields['scope'] === 'authored' &&
3623
+ current.plan.scope !== undefined
3624
+ ) {
3625
+ merged = { ...merged, scope: current.plan.scope };
3626
+ }
3627
+ return merged;
3628
+ };
3629
+
3630
+ const classPlanProvenance = (
3631
+ plan: ClassRegradePlan,
3632
+ authoredScope: boolean,
3633
+ current: ClassPlanArtifact | null
3634
+ ): RegradePlanArtifact['provenance'] => ({
3635
+ fields: {
3636
+ classIds: 'authored',
3637
+ id: 'derived',
3638
+ kind: 'derived',
3639
+ ...(plan.intent === undefined ? {} : { intent: 'authored' }),
3640
+ ...(plan.name === undefined ? {} : { name: 'authored' }),
3641
+ ...(plan.packageSource === undefined ? {} : { packageSource: 'authored' }),
3642
+ ...(plan.scope === undefined
3643
+ ? {}
3644
+ : {
3645
+ scope:
3646
+ authoredScope || current?.provenance.fields['scope'] === 'authored'
3647
+ ? 'authored'
3648
+ : 'derived',
3649
+ }),
3650
+ },
3651
+ });
3652
+
3653
+ /**
3654
+ * A named class plan keys its file on the name alone, so a reused name with
3655
+ * different class ids would silently overwrite an unrelated in-progress plan
3656
+ * (and later mix runs into its consolidated history). Refuse the collision;
3657
+ * unreadable or non-class artifacts keep their existing handling.
3658
+ */
3659
+ const classPlanIdentityConflict = (
3660
+ rootDir: string,
3661
+ currentPath: string,
3662
+ classIds: readonly string[]
3663
+ ): ValidationError | null => {
3664
+ if (!existsSync(currentPath)) {
3665
+ return null;
3666
+ }
3667
+ const existing = readRegradePlanArtifact(currentPath);
3668
+ if (existing.isErr() || existing.value.plan.kind !== 'class') {
3669
+ return null;
3670
+ }
3671
+ const existingIds = existing.value.plan.classIds;
3672
+ if (
3673
+ existingIds.length === classIds.length &&
3674
+ existingIds.every((id, index) => id === classIds[index])
3675
+ ) {
3676
+ return null;
3677
+ }
3678
+ return new ValidationError(
3679
+ '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.',
3680
+ {
3681
+ context: {
3682
+ existing: [...existingIds],
3683
+ path: rootRelativePath(rootDir, currentPath),
3684
+ planned: [...classIds],
3685
+ },
3686
+ }
3687
+ );
3688
+ };
3689
+
3690
+ const runClassPlanRegrade = async (
3691
+ input: RegradePlanInput,
3692
+ rootDir: string,
3693
+ configScope: RegradeConfigScope | undefined,
3694
+ shouldDryRun: boolean
3695
+ ): Promise<TrailsResult<RegradePlanArtifact, Error>> => {
3696
+ const invalid = validateClassPlanInput(input);
3697
+ if (invalid !== null) {
3698
+ return Result.err(invalid);
3699
+ }
3700
+ const classIds = input.classIds ?? [];
3701
+ if (!regradeRootIsReadable(rootDir)) {
3702
+ return regradeRootNotFound(rootDir);
3703
+ }
3704
+
3705
+ const inputScope = classPlanScopeForInput(input, configScope);
3706
+ const basePlan: ClassRegradePlan = {
3707
+ classIds: [...classIds],
3708
+ id: `class:${classIds.join('+')}`,
3709
+ ...(input.intent === undefined ? {} : { intent: input.intent }),
3710
+ kind: 'class',
3711
+ ...(input.name === undefined ? {} : { name: input.name }),
3712
+ ...(input.packageSource === undefined
3713
+ ? {}
3714
+ : { packageSource: input.packageSource }),
3715
+ ...(inputScope === undefined ? {} : { scope: inputScope }),
3716
+ };
3717
+ const currentPath = regradePlanPathForPlan(rootDir, basePlan);
3718
+ const conflict = classPlanIdentityConflict(rootDir, currentPath, classIds);
3719
+ if (conflict !== null) {
3720
+ return Result.err(conflict);
3721
+ }
3722
+ const currentResult = readCurrentClassPlanArtifact(input, currentPath);
3723
+ if (currentResult.isErr()) {
3724
+ return currentResult;
3725
+ }
3726
+ const current = currentResult.value;
3727
+ const authoredScope =
3728
+ input.exclude !== undefined ||
3729
+ input.extensions !== undefined ||
3730
+ input.include !== undefined;
3731
+ const plan = mergeAuthoredClassPlanFields(
3732
+ basePlan,
3733
+ input,
3734
+ authoredScope,
3735
+ current
3736
+ );
3737
+
3738
+ const report = await runClassPlanRegradeRun({
3739
+ apply: false,
3740
+ includeEntries: input.includeEntries,
3741
+ plan,
3742
+ rootDir,
3743
+ });
3744
+ if (report.isErr()) {
3745
+ return report;
3746
+ }
3747
+ if (report.value.unknownClassIds.length > 0) {
3748
+ return Result.err(
3749
+ new ValidationError('Unknown Regrade class ids.', {
3750
+ context: { unknownClassIds: report.value.unknownClassIds },
3751
+ })
3752
+ );
3753
+ }
3754
+
3755
+ const transitionId = priorTransitionId(currentPath, 'class');
3756
+ const artifact: RegradePlanArtifact = {
3757
+ kind: 'regrade-plan',
3758
+ path: rootRelativePath(rootDir, currentPath),
3759
+ plan,
3760
+ provenance: classPlanProvenance(plan, authoredScope, current),
3761
+ schemaVersion: REGRADE_PLAN_SCHEMA_VERSION,
3762
+ sourceHash: regradeSourceHash(report.value),
3763
+ ...(transitionId === undefined ? {} : { transitionId }),
3764
+ };
3765
+ if (shouldDryRun) {
3766
+ return validateRegradePlanArtifact(artifact);
3767
+ }
3768
+ return writeRegradePlanArtifact(rootDir, artifact);
3769
+ };
3770
+
3771
+ const readCurrentVocabularyPlanArtifact = (
3772
+ input: RegradePlanInput,
3773
+ currentPath: string
3774
+ ): TrailsResult<VocabularyRegradePlanArtifact | null, Error> => {
3775
+ if (input.fresh || !existsSync(currentPath)) {
3776
+ return Result.ok(null);
3777
+ }
3778
+ const currentResult = readRegradePlanArtifact(currentPath);
3779
+ if (currentResult.isErr()) {
3780
+ return currentResult;
3781
+ }
3782
+ const candidate = currentResult.value;
3783
+ if (candidate.plan.kind !== 'vocabulary') {
3784
+ return Result.ok(null);
3785
+ }
3786
+ return Result.ok({ ...candidate, plan: candidate.plan });
3787
+ };
3788
+
3789
+ const finishVocabularyPlanArtifact = (params: {
3790
+ readonly current?: VocabularyRegradePlanArtifact | undefined;
3791
+ readonly currentPath: string;
3792
+ readonly input: RegradePlanInput;
3793
+ readonly plan: VocabularyRegradePlan;
3794
+ readonly preserveInventory: readonly VocabularyPreserveInventoryEntry[];
3795
+ readonly rootDir: string;
3796
+ readonly shouldDryRun: boolean;
3797
+ }): TrailsResult<RegradePlanArtifact, Error> => {
3798
+ const initialReport = runResolvedVocabularyPlan({
3799
+ apply: false,
3800
+ includeEntries: params.input.includeEntries,
3801
+ plan: params.plan,
3802
+ preserveInventory: params.preserveInventory,
3803
+ rootDir: params.rootDir,
3804
+ });
3805
+ if (initialReport.isErr()) {
3806
+ return initialReport;
3807
+ }
3808
+ const initialProvenance = regradePlanProvenanceForInput(
3809
+ params.input,
3810
+ params.plan
3811
+ );
3812
+ const scopeIsAuthored =
3813
+ initialProvenance.fields['scope'] === 'authored' ||
3814
+ params.current?.provenance.fields['scope'] === 'authored';
3815
+ const plan = scopeIsAuthored
3816
+ ? params.plan
3817
+ : withDerivedTeachingSurfaceInventory({
3818
+ plan: params.plan,
3819
+ report: initialReport.value,
3820
+ });
3821
+ const report =
3822
+ plan === params.plan
3823
+ ? initialReport
3824
+ : runResolvedVocabularyPlan({
3825
+ apply: false,
3826
+ includeEntries: params.input.includeEntries,
3827
+ plan,
3828
+ preserveInventory: params.preserveInventory,
3829
+ rootDir: params.rootDir,
3830
+ });
3831
+ if (report.isErr()) {
3832
+ return report;
3833
+ }
3834
+ const expansion = mergeRegradePlanExpansion(
3835
+ params.current?.expansion,
3836
+ params.input.expand ? expansionForReport(report.value) : undefined,
3837
+ plan
3838
+ );
3839
+ const transitionId = priorTransitionId(params.currentPath, 'vocabulary');
3840
+ const derivedProvenance = regradePlanProvenanceForInput(params.input, plan);
3841
+ const provenance =
3842
+ params.current === undefined
3843
+ ? derivedProvenance
3844
+ : preserveAuthoredPlanProvenance(params.current, derivedProvenance);
3845
+ const artifact = buildRegradePlanArtifact({
3846
+ derivation: deriveRegradePlanDerivation({
3847
+ plan,
3848
+ preserveInventory: params.preserveInventory,
3849
+ provenance,
3850
+ report: report.value,
3851
+ rootDir: params.rootDir,
3852
+ }),
3853
+ ...(expansion === undefined ? {} : { expansion }),
3854
+ input: params.input,
3855
+ plan,
3856
+ report: report.value,
3857
+ rootDir: params.rootDir,
3858
+ ...(transitionId === undefined ? {} : { transitionId }),
3859
+ });
3860
+ const mergedArtifact =
3861
+ params.current === undefined ? artifact : { ...artifact, provenance };
3862
+ return params.shouldDryRun
3863
+ ? validateRegradePlanArtifact(mergedArtifact)
3864
+ : writeRegradePlanArtifact(params.rootDir, mergedArtifact);
3865
+ };
3866
+
3867
+ const runPlanRegrade = async (
3868
+ input: RegradePlanInput,
3869
+ rootDir: string,
3870
+ configScope?: RegradeConfigScope | undefined,
3871
+ shouldDryRun = false
3872
+ ): Promise<TrailsResult<RegradePlanArtifact, Error>> => {
3873
+ if (input.classIds !== undefined || input.type === 'class') {
3874
+ return runClassPlanRegrade(input, rootDir, configScope, shouldDryRun);
3875
+ }
3876
+ if (input.type !== undefined && input.type !== 'vocabulary') {
3877
+ return Result.err(
3878
+ new ValidationError(`Unsupported Regrade plan type "${input.type}".`)
3879
+ );
3880
+ }
3881
+ if (input.name !== undefined) {
3882
+ return Result.err(
3883
+ new ValidationError(
3884
+ '`name` names a class-mode transition; vocabulary transitions are keyed by `from`/`to`.'
3885
+ )
3886
+ );
3887
+ }
3888
+ if (input.packageSource !== undefined) {
3889
+ return Result.err(
3890
+ new ValidationError(
3891
+ '`packageSource` is available only for class-mode Regrade plans.'
3892
+ )
3893
+ );
3894
+ }
3895
+ const planInput: RegradeInput = {
3896
+ ...input,
3897
+ apply: false,
3898
+ check: false,
3899
+ writeRecord: false,
3900
+ };
3901
+ const planResult = buildVocabularyPlan(
3902
+ planInput,
3903
+ vocabularyScopeFromConfig(configScope),
3904
+ rootDir
3905
+ );
3906
+ if (planResult.isErr()) {
3907
+ return planResult;
3908
+ }
3909
+ if (!regradeRootIsReadable(rootDir)) {
3910
+ return regradeRootNotFound(rootDir);
3911
+ }
3912
+ const currentPath = regradePlanPathForPlan(rootDir, planResult.value);
3913
+ const currentResult = readCurrentVocabularyPlanArtifact(input, currentPath);
3914
+ if (currentResult.isErr()) {
3915
+ return currentResult;
3916
+ }
3917
+ const current = currentResult.value ?? undefined;
3918
+ const plan =
3919
+ current === undefined
3920
+ ? planResult.value
3921
+ : mergeAuthoredPlanFields(current, planResult.value);
3922
+ const preserveResult = await deriveLiveApiPreserveInventory(plan, rootDir);
3923
+ if (preserveResult.isErr()) {
3924
+ return preserveResult;
3925
+ }
3926
+ return finishVocabularyPlanArtifact({
3927
+ current,
3928
+ currentPath,
3929
+ input,
3930
+ plan,
3931
+ preserveInventory: preserveResult.value,
3932
+ rootDir,
3933
+ shouldDryRun,
3934
+ });
3935
+ };
3936
+
3937
+ const loadPlanForInput = async (
3938
+ input: RegradePlanReferenceInput,
3939
+ rootDir: string
3940
+ ): Promise<
3941
+ TrailsResult<
3942
+ { readonly artifact: RegradePlanArtifact; readonly path: string },
3943
+ Error
3944
+ >
3945
+ > => {
3946
+ const path = resolveRegradePlanPath(rootDir, input.plan);
3947
+ if (path.isErr()) {
3948
+ return path;
3949
+ }
3950
+ const artifact = readRegradePlanArtifact(path.value);
3951
+ if (artifact.isErr()) {
3952
+ return artifact;
3953
+ }
3954
+ return Result.ok({ artifact: artifact.value, path: path.value });
3955
+ };
3956
+
3957
+ const reportWithCheckedHistorySummary = (
3958
+ report: RegradeReport,
3959
+ historyPath: string,
3960
+ schemaVersion: number
3961
+ ): RegradeReport => ({
3962
+ ...report,
3963
+ history: {
3964
+ path: historyPath,
3965
+ schemaVersion,
3966
+ status: 'checked',
3967
+ },
3968
+ });
3969
+
3970
+ /**
3971
+ * Check a graduated transition: verify every recorded run in the
3972
+ * consolidated history at its own stamped lock. Historical runs are not
3973
+ * re-executed — per-run stamp verification is the machine acceptance.
3974
+ */
3975
+ const checkGraduatedRegradeHistory = (
3976
+ historyPath: string
3977
+ ): TrailsResult<RegradeReport, Error> => {
3978
+ const artifact = readRegradeHistoryArtifact(historyPath);
3979
+ if (artifact.isErr()) {
3980
+ return artifact;
3981
+ }
3982
+ const verified = verifyRegradeHistoryRuns(artifact.value);
3983
+ if (verified.isErr()) {
3984
+ return verified;
3985
+ }
3986
+ const lastRun = artifact.value.runs.at(-1);
3987
+ if (lastRun === undefined) {
3988
+ return Result.err(
3989
+ new ValidationError('Regrade history has no recorded runs.', {
3990
+ context: { path: artifact.value.path },
3991
+ })
3992
+ );
3993
+ }
3994
+ return validateRegradeReport(
3995
+ reportWithCheckedHistorySummary(
3996
+ lastRun.report,
3997
+ artifact.value.path,
3998
+ artifact.value.schemaVersion
3999
+ )
4000
+ );
4001
+ };
4002
+
4003
+ const runCheckRegradePlan = async (
4004
+ input: RegradePlanReferenceInput,
4005
+ rootDir: string
4006
+ ): Promise<TrailsResult<RegradeReport, Error>> => {
4007
+ const planPath = resolveRegradePlanPath(rootDir, input.plan);
4008
+ if (planPath.isErr()) {
4009
+ if (input.plan !== undefined && !isPlanPathReference(input.plan)) {
4010
+ const historyPath = resolveRegradeHistoryPath(rootDir, input.plan);
4011
+ if (historyPath.isOk()) {
4012
+ return checkGraduatedRegradeHistory(historyPath.value);
4013
+ }
4014
+ return historyPath;
4015
+ }
4016
+ return planPath;
4017
+ }
4018
+ const artifact = readRegradePlanArtifact(planPath.value);
4019
+ if (artifact.isErr()) {
4020
+ return artifact;
4021
+ }
4022
+ const loaded = {
4023
+ value: { artifact: artifact.value, path: planPath.value },
4024
+ };
4025
+ const prepared = await preparePlanArtifactRun({
4026
+ artifact: loaded.value.artifact,
4027
+ includeEntries: input.includeEntries,
4028
+ rootDir,
4029
+ });
4030
+ if (prepared.isErr()) {
4031
+ return prepared;
4032
+ }
4033
+ const { report } = prepared.value;
4034
+ const status = planStatusForReport(loaded.value.artifact, report, rootDir);
4035
+ const checked = reportWithPlanSummary(report, loaded.value.artifact, status);
4036
+ if (status === 'stale') {
4037
+ return Result.err(
4038
+ new ValidationError(
4039
+ 'Regrade plan is stale for the current source tree.',
4040
+ {
4041
+ context: { plan: loaded.value.artifact.path },
4042
+ }
4043
+ )
4044
+ );
4045
+ }
4046
+ const gateContext = regradePlanGateContext(checked);
4047
+ if (gateContext !== undefined) {
4048
+ return Result.err(
4049
+ new ValidationError('Regrade plan gate is open.', {
4050
+ context: {
4051
+ ...gateContext,
4052
+ plan: loaded.value.artifact.path,
4053
+ },
4054
+ })
4055
+ );
4056
+ }
4057
+ return validateRegradeReport(checked);
4058
+ };
4059
+
4060
+ const runPreviewRegradePlan = async (
4061
+ input: RegradePlanReferenceInput,
4062
+ rootDir: string
4063
+ ): Promise<TrailsResult<RegradeReport, Error>> => {
4064
+ const loaded = await loadPlanForInput(input, rootDir);
4065
+ if (loaded.isErr()) {
4066
+ return loaded;
4067
+ }
4068
+ const prepared = await preparePlanArtifactRun({
4069
+ artifact: loaded.value.artifact,
4070
+ includeEntries: input.includeEntries,
4071
+ rootDir,
4072
+ });
4073
+ if (prepared.isErr()) {
4074
+ return prepared;
4075
+ }
4076
+ const { report } = prepared.value;
4077
+ return validateRegradeReport(
4078
+ reportWithPlanSummary(
4079
+ report,
4080
+ loaded.value.artifact,
4081
+ planStatusForReport(loaded.value.artifact, report, rootDir)
4082
+ )
4083
+ );
4084
+ };
4085
+
4086
+ const writeRegradeHistory = (params: {
4087
+ readonly artifact: RegradePlanArtifact;
4088
+ readonly changedFiles: readonly RegradeChangedFileEvidence[];
4089
+ readonly completedReport: RegradeReport;
4090
+ readonly planPath: string;
4091
+ readonly report: RegradeReport;
4092
+ readonly rootDir: string;
4093
+ readonly sourceRevision: string;
4094
+ }): TrailsResult<RegradeHistorySummary, Error> => {
4095
+ const absolutePath = regradeHistoryPathForPlan(
4096
+ params.rootDir,
4097
+ params.artifact.plan
4098
+ );
4099
+ let priorHistoryBytes: string | undefined;
4100
+ if (existsSync(absolutePath)) {
4101
+ try {
4102
+ priorHistoryBytes = readFileSync(absolutePath, 'utf8');
4103
+ } catch (error) {
4104
+ return Result.err(
4105
+ new InternalError('Failed to read Regrade history entry.', {
4106
+ ...(error instanceof Error ? { cause: error } : {}),
4107
+ context: { path: rootRelativePath(params.rootDir, absolutePath) },
4108
+ })
4109
+ );
4110
+ }
4111
+ }
4112
+ const appended = appendRegradeHistoryRun({
4113
+ artifact: params.artifact,
4114
+ changedFiles: params.changedFiles,
4115
+ completedReport: params.completedReport,
4116
+ report: params.report,
4117
+ rootDir: params.rootDir,
4118
+ sourceRevision: params.sourceRevision,
4119
+ });
4120
+ if (appended.isErr()) {
4121
+ return appended;
4122
+ }
4123
+ // Apply always consumes the active plan, replay included: the plan is a
4124
+ // single-use apply intent, and the consolidated history already records
4125
+ // the run the replay repeats.
4126
+ const consumed = consumeActiveRegradePlanAfterHistoryWrite({
4127
+ absoluteHistoryPath: absolutePath,
4128
+ absolutePlanPath: params.planPath,
4129
+ historyPath: appended.value.path,
4130
+ planPath: rootRelativePath(params.rootDir, params.planPath),
4131
+ priorHistoryBytes,
4132
+ });
4133
+ if (consumed.isErr()) {
4134
+ return consumed;
4135
+ }
4136
+ return appended;
4137
+ };
4138
+
4139
+ const historyReportForAppliedPlan = (
4140
+ dryRunReport: RegradeReport,
4141
+ appliedReport: RegradeReport
4142
+ ): RegradeReport => ({
4143
+ ...dryRunReport,
4144
+ ...(appliedReport.apply === undefined ? {} : { apply: appliedReport.apply }),
4145
+ ...(dryRunReport.run === undefined
4146
+ ? {}
4147
+ : {
4148
+ run: {
4149
+ ...dryRunReport.run,
4150
+ report:
4151
+ appliedReport.run?.report ??
4152
+ transitionRunReportForRegradeReport(appliedReport),
4153
+ },
4154
+ }),
4155
+ });
4156
+
4157
+ /**
4158
+ * Re-read the active plan after preparation and before mutation.
4159
+ *
4160
+ * @internal
4161
+ */
4162
+ export const reloadPreparedRegradePlan = async (params: {
4163
+ readonly input: RegradeApplyPlanInput;
4164
+ readonly loaded: {
4165
+ readonly artifact: RegradePlanArtifact;
4166
+ readonly path: string;
4167
+ };
4168
+ readonly rootDir: string;
4169
+ }): Promise<TrailsResult<RegradePlanArtifact, Error>> => {
4170
+ const current = await loadPlanForInput(params.input, params.rootDir);
4171
+ if (current.isErr()) {
4172
+ return current;
4173
+ }
4174
+ const unchanged = validatePreparedRegradePlanArtifact({
4175
+ current: current.value.artifact,
4176
+ currentPath: current.value.path,
4177
+ expected: params.loaded.artifact,
4178
+ expectedPath: params.loaded.path,
4179
+ });
4180
+ return unchanged.isErr() ? unchanged : Result.ok(current.value.artifact);
4181
+ };
4182
+
4183
+ const applyPreparedPlanRun = async (params: {
4184
+ readonly artifact: RegradePlanArtifact;
4185
+ readonly includeEntries: RegradeInput['includeEntries'];
4186
+ readonly prepared: PreparedPlanRun;
4187
+ readonly packageSource?:
4188
+ | {
4189
+ readonly evidence: RegradePackageSourceEvidence;
4190
+ readonly expectation: RegradePackageSourceExpectation;
4191
+ }
4192
+ | undefined;
4193
+ readonly rootDir: string;
4194
+ }): Promise<TrailsResult<RegradeReport, Error>> => {
4195
+ if (params.artifact.plan.kind === 'class') {
4196
+ if (params.prepared.kind !== 'class') {
4197
+ return Result.err(new InternalError('Prepared Regrade kind changed.'));
4198
+ }
4199
+ const verifiedClassSet = await loadVerifiedWardenRegradeClasses({
4200
+ ...(params.packageSource === undefined
4201
+ ? {}
4202
+ : {
4203
+ initialProof: params.packageSource,
4204
+ packageSource: params.packageSource.expectation,
4205
+ }),
4206
+ rootDir: params.rootDir,
4207
+ });
4208
+ if (verifiedClassSet.isErr()) {
4209
+ return verifiedClassSet;
4210
+ }
4211
+ const { classSet, packageSource } = verifiedClassSet.value;
4212
+ const identity = preparedRegradeRunIdentity({
4213
+ artifact: params.artifact,
4214
+ classIds: classSet.classes.map((regradeClass) => regradeClass.id),
4215
+ classes: classSet.classes,
4216
+ includeEntries: params.includeEntries,
4217
+ rootDir: params.rootDir,
4218
+ });
4219
+ if (identity.isErr()) {
4220
+ return identity;
4221
+ }
4222
+ const finalPackageSource = await verifyExpectedPackageSource(
4223
+ packageSource?.expectation,
4224
+ params.rootDir
4225
+ );
4226
+ if (finalPackageSource.isErr()) {
4227
+ return finalPackageSource;
4228
+ }
4229
+ if (
4230
+ packageSource !== undefined &&
4231
+ (finalPackageSource.value === undefined ||
4232
+ !packageSourceEvidenceMatches(
4233
+ packageSource.evidence,
4234
+ finalPackageSource.value.evidence
4235
+ ))
4236
+ ) {
4237
+ return Result.err(
4238
+ new ConflictError(
4239
+ 'Prepared Regrade package-source evidence changed before apply.'
4240
+ )
4241
+ );
4242
+ }
4243
+ const currentPackageSource = finalPackageSource.value?.evidence;
4244
+ const applied = applyPreparedRegradeRun(
4245
+ params.prepared.prepared.run,
4246
+ identity.value
4247
+ );
4248
+ return applied.isErr() || currentPackageSource === undefined
4249
+ ? applied
4250
+ : Result.ok({ ...applied.value, packageSource: currentPackageSource });
4251
+ }
4252
+ if (params.prepared.kind !== 'vocabulary') {
4253
+ return Result.err(new InternalError('Prepared Regrade kind changed.'));
4254
+ }
4255
+ const identity = preparedRegradeRunIdentity({
4256
+ artifact: params.artifact,
4257
+ includeEntries: params.includeEntries,
4258
+ rootDir: params.rootDir,
4259
+ });
4260
+ if (identity.isErr()) {
4261
+ return identity;
4262
+ }
4263
+ const { prepared } = params.prepared;
4264
+ return runResolvedVocabularyPlan({
4265
+ apply: true,
4266
+ currentIdentity: identity.value,
4267
+ includeEntries: params.includeEntries,
4268
+ plan: params.artifact.plan,
4269
+ prepared,
4270
+ preserveInventory: prepared.preserveInventory,
4271
+ rootDir: params.rootDir,
4272
+ });
4273
+ };
4274
+
4275
+ const verifyInitialPlanPackageSource = async (
4276
+ input: RegradeApplyPlanInput,
4277
+ artifact: RegradePlanArtifact,
4278
+ rootDir: string
4279
+ ): Promise<TrailsResult<VerifiedPackageSource | undefined, Error>> => {
4280
+ if (artifact.plan.kind !== 'class') {
4281
+ return input.packageSource === undefined
4282
+ ? Result.ok()
4283
+ : Result.err(
4284
+ new ValidationError(
4285
+ 'Package-source verification is available only for class-mode Regrade runs.'
4286
+ )
4287
+ );
4288
+ }
4289
+ const storedExpectation = artifact.plan.packageSource;
4290
+ if (
4291
+ storedExpectation !== undefined &&
4292
+ input.packageSource !== undefined &&
4293
+ !packageSourceExpectationMatches(storedExpectation, input.packageSource)
4294
+ ) {
4295
+ return Result.err(
4296
+ new ConflictError(
4297
+ 'Apply package-source expectation does not match the saved Regrade plan.'
4298
+ )
4299
+ );
4300
+ }
4301
+ return verifyExpectedPackageSource(
4302
+ storedExpectation ?? input.packageSource,
4303
+ rootDir
4304
+ );
4305
+ };
4306
+
4307
+ const withoutPackageSourceEvidence = (report: RegradeReport): RegradeReport => {
4308
+ const { packageSource, ...reportWithoutPackageSource } = report;
4309
+ return packageSource === undefined ? report : reportWithoutPackageSource;
4310
+ };
4311
+
4312
+ const planFreshnessReportForApply = (
4313
+ artifact: RegradePlanArtifact,
4314
+ input: RegradeApplyPlanInput,
4315
+ report: RegradeReport
4316
+ ): RegradeReport =>
4317
+ artifact.plan.kind === 'class' &&
4318
+ artifact.plan.packageSource === undefined &&
4319
+ input.packageSource !== undefined
4320
+ ? withoutPackageSourceEvidence(report)
4321
+ : report;
4322
+
4323
+ const runApplyRegradePlan = async (
4324
+ input: RegradeApplyPlanInput,
4325
+ rootDir: string,
4326
+ shouldDryRun: boolean
4327
+ ): Promise<TrailsResult<RegradeReport, Error>> => {
4328
+ const loaded = await loadPlanForInput(input, rootDir);
4329
+ if (loaded.isErr()) {
4330
+ return loaded;
4331
+ }
4332
+ const governedPlanValidation = validateGovernedRegradePlan(
4333
+ loaded.value.artifact
4334
+ );
4335
+ if (governedPlanValidation.isErr()) {
4336
+ return governedPlanValidation;
4337
+ }
4338
+ const initialPackageSource = await verifyInitialPlanPackageSource(
4339
+ input,
4340
+ loaded.value.artifact,
4341
+ rootDir
4342
+ );
4343
+ if (initialPackageSource.isErr()) {
4344
+ return initialPackageSource;
4345
+ }
4346
+ const packageSource = initialPackageSource.value;
4347
+ const receiptArtifact: RegradePlanArtifact = (() =>
4348
+ loaded.value.artifact.plan.kind === 'class' &&
4349
+ loaded.value.artifact.plan.packageSource === undefined &&
4350
+ packageSource !== undefined
4351
+ ? {
4352
+ ...loaded.value.artifact,
4353
+ plan: {
4354
+ ...loaded.value.artifact.plan,
4355
+ packageSource: packageSource.expectation,
4356
+ },
4357
+ provenance: {
4358
+ ...loaded.value.artifact.provenance,
4359
+ fields: {
4360
+ ...loaded.value.artifact.provenance.fields,
4361
+ packageSource: 'authored',
4362
+ },
4363
+ },
4364
+ }
4365
+ : loaded.value.artifact)();
4366
+ // Receipt persistence is mandatory for apply. Validate its authored intent
4367
+ // before class preparation can evaluate configured callbacks.
4368
+ const receiptPlan = validateRegradeReceiptPlan(receiptArtifact);
4369
+ if (receiptPlan.isErr()) {
4370
+ return receiptPlan;
4371
+ }
4372
+ const preparedRun = await preparePlanArtifactRun({
4373
+ artifact: loaded.value.artifact,
4374
+ includeEntries: input.includeEntries,
4375
+ ...(initialPackageSource.value === undefined
4376
+ ? {}
4377
+ : { packageSource: initialPackageSource.value }),
4378
+ rootDir,
4379
+ });
4380
+ if (preparedRun.isErr()) {
4381
+ return preparedRun;
4382
+ }
4383
+ const preparedReport = preparedRun.value.report;
4384
+ const statusReport = planFreshnessReportForApply(
4385
+ loaded.value.artifact,
4386
+ input,
4387
+ preparedReport
4388
+ );
4389
+ const status = planStatusForReport(
4390
+ loaded.value.artifact,
4391
+ statusReport,
4392
+ rootDir
4393
+ );
4394
+ const dryRunReport = preparedReport;
4395
+ if (status === 'stale') {
4396
+ return Result.err(
4397
+ new ValidationError(
4398
+ 'Regrade plan is stale for the current source tree.',
4399
+ {
4400
+ context: { plan: loaded.value.artifact.path },
4401
+ }
4402
+ )
4403
+ );
4404
+ }
4405
+ if (shouldDryRun) {
4406
+ return validateRegradeReport(
4407
+ reportWithPlanSummary(dryRunReport, loaded.value.artifact, status)
4408
+ );
4409
+ }
4410
+
4411
+ const currentPlan = await reloadPreparedRegradePlan({
4412
+ input,
4413
+ loaded: loaded.value,
4414
+ rootDir,
4415
+ });
4416
+ if (currentPlan.isErr()) {
4417
+ return currentPlan;
4418
+ }
4419
+ const activeArtifact = currentPlan.value;
4420
+
4421
+ // Resolve Git-owned source identity before mutating source so failure leaves
4422
+ // the tree untouched.
4423
+ const sourceRevision = resolveRegradeSourceRevision(rootDir);
4424
+ if (sourceRevision.isErr()) {
4425
+ return sourceRevision;
4426
+ }
4427
+
4428
+ const beforeChangedFiles = captureRegradeChangedFilesBefore({
4429
+ artifact: activeArtifact,
4430
+ report: dryRunReport,
4431
+ rootDir,
4432
+ });
4433
+ if (beforeChangedFiles.isErr()) {
4434
+ return beforeChangedFiles;
4435
+ }
4436
+
4437
+ const planBody = activeArtifact.plan;
4438
+ const sourceSnapshots = snapshotRegradeSources({
4439
+ optionalPaths:
4440
+ planBody.kind === 'vocabulary'
4441
+ ? (planBody.fileRenames ?? []).flatMap((rename) => [
4442
+ rename.from,
4443
+ rename.to,
4444
+ ])
4445
+ : [],
4446
+ reports: [dryRunReport],
4447
+ rootDir,
4448
+ });
4449
+ if (sourceSnapshots.isErr()) {
4450
+ return sourceSnapshots;
4451
+ }
4452
+ const rollbackApplyError = (error: Error): TrailsResult<never, Error> =>
4453
+ regradeApplyErrorAfterRollback(error, sourceSnapshots.value);
4454
+ const applied = await applyPreparedPlanRun({
4455
+ artifact: activeArtifact,
4456
+ includeEntries: input.includeEntries,
4457
+ packageSource,
4458
+ prepared: preparedRun.value.prepared,
4459
+ rootDir,
4460
+ });
4461
+ if (applied.isErr()) {
4462
+ return rollbackApplyError(applied.error);
4463
+ }
4464
+ const appliedReport = applied.value;
4465
+ const changedFiles = completeRegradeChangedFiles({
4466
+ before: beforeChangedFiles.value,
4467
+ rootDir,
4468
+ });
4469
+ if (changedFiles.isErr()) {
4470
+ return rollbackApplyError(changedFiles.error);
4471
+ }
4472
+ const completionReport = await runPlanArtifactDryRun({
4473
+ artifact: activeArtifact,
4474
+ includeEntries: input.includeEntries,
4475
+ packageSource: packageSource?.expectation,
4476
+ rootDir,
4477
+ });
4478
+ if (completionReport.isErr()) {
4479
+ return rollbackApplyError(completionReport.error);
4480
+ }
4481
+ // Keep the pre-apply occurrence evidence that explains what this run changed,
4482
+ // while carrying the completed counters and a separate post-apply source
4483
+ // stamp so a later no-op apply can still be recognized as a replay.
4484
+ const history = writeRegradeHistory({
4485
+ artifact: receiptArtifact,
4486
+ changedFiles: changedFiles.value,
4487
+ completedReport: completionReport.value,
4488
+ planPath: loaded.value.path,
4489
+ report: historyReportForAppliedPlan(dryRunReport, appliedReport),
4490
+ rootDir,
4491
+ sourceRevision: sourceRevision.value,
4492
+ });
4493
+ if (history.isErr()) {
4494
+ return rollbackApplyError(history.error);
4495
+ }
4496
+ return validateRegradeReport(
4497
+ reportWithHistorySummary(
4498
+ reportWithPlanSummary(appliedReport, receiptArtifact, status),
4499
+ history.value
4500
+ )
4501
+ );
4502
+ };
4503
+
4504
+ /**
4505
+ * Pull a graduated transition back from consolidated history into an active
4506
+ * plan for adjustment. The pulled-back artifact is authored intent only —
4507
+ * plan body, provenance, and any staged expansion; the run ledger stays
4508
+ * behind in the graduated history file, which adjust never touches. The
4509
+ * transition's stable id is preserved so the re-run's apply appends to the
4510
+ * same consolidated history spine instead of forking it.
4511
+ */
4512
+ const runAdjustRegrade = async (
4513
+ input: RegradeAdjustInput,
4514
+ rootDir: string,
4515
+ shouldDryRun: boolean
4516
+ ): Promise<TrailsResult<RegradePlanArtifact, Error>> => {
4517
+ const historyPath = resolveRegradeHistoryPath(rootDir, input.transition);
4518
+ if (historyPath.isErr()) {
4519
+ return historyPath;
4520
+ }
4521
+ const history = readRegradeHistoryArtifact(historyPath.value);
4522
+ if (history.isErr()) {
4523
+ return history;
4524
+ }
4525
+ const lastRun = history.value.runs.at(-1);
4526
+ if (lastRun === undefined) {
4527
+ return Result.err(
4528
+ new ValidationError('Regrade history has no recorded runs.', {
4529
+ context: { path: history.value.path },
4530
+ })
4531
+ );
4532
+ }
4533
+ const lastPlan = lastRun.plan;
4534
+ const activePath = regradePlanPathForPlan(rootDir, lastPlan.plan);
4535
+ if (existsSync(activePath)) {
4536
+ return Result.err(
4537
+ new ValidationError(
4538
+ 'An active Regrade plan for this transition already exists; edit or apply it instead of adjusting again.',
4539
+ { context: { plan: rootRelativePath(rootDir, activePath) } }
4540
+ )
4541
+ );
4542
+ }
4543
+ const draft: RegradePlanArtifact = {
4544
+ ...(lastPlan.expansion === undefined
4545
+ ? {}
4546
+ : { expansion: lastPlan.expansion }),
4547
+ kind: 'regrade-plan',
4548
+ path: rootRelativePath(rootDir, activePath),
4549
+ plan: lastPlan.plan,
4550
+ provenance: lastPlan.provenance,
4551
+ schemaVersion: REGRADE_PLAN_SCHEMA_VERSION,
4552
+ sourceHash: lastPlan.sourceHash,
4553
+ transitionId: history.value.id,
4554
+ };
4555
+ // Re-derive the source hash against the current tree so the later apply's
4556
+ // staleness gate compares with today's occurrences, not the graduated
4557
+ // run's.
4558
+ const report = await runPlanArtifactDryRun({
4559
+ artifact: draft,
4560
+ includeEntries: 'actionable',
4561
+ rootDir,
4562
+ });
4563
+ if (report.isErr()) {
4564
+ return report;
4565
+ }
4566
+ const artifact: RegradePlanArtifact = {
4567
+ ...draft,
4568
+ ...(draft.plan.kind === 'class'
4569
+ ? {}
4570
+ : {
4571
+ derivation: deriveRegradePlanDerivation({
4572
+ plan: draft.plan,
4573
+ preserveInventory: report.value.run?.preserveInventory ?? [],
4574
+ provenance: draft.provenance,
4575
+ report: report.value,
4576
+ rootDir,
4577
+ }),
4578
+ }),
4579
+ sourceHash: regradeSourceHash(report.value),
4580
+ };
4581
+ if (shouldDryRun) {
4582
+ return validateRegradePlanArtifact(artifact);
4583
+ }
4584
+ return writeRegradePlanArtifact(rootDir, artifact);
4585
+ };
4586
+
4587
+ const listRegradePlans = async (
4588
+ rootDir: string
4589
+ ): Promise<TrailsResult<z.output<typeof regradePlansOutputSchema>, Error>> => {
4590
+ const plans: z.output<typeof regradePlanSummarySchema>[] = [];
4591
+ for (const path of collectActiveRegradePlanPaths(rootDir)) {
4592
+ const artifact = readRegradePlanArtifact(path);
4593
+ if (artifact.isErr()) {
4594
+ return artifact;
4595
+ }
4596
+ const report = await runPlanArtifactDryRun({
4597
+ artifact: artifact.value,
4598
+ includeEntries: 'actionable',
4599
+ rootDir,
4600
+ });
4601
+ if (report.isErr()) {
4602
+ return report;
4603
+ }
4604
+ const expansionPending = pendingExpansionCandidateCount(artifact.value);
4605
+ const body = artifact.value.plan;
4606
+ plans.push({
4607
+ ...(body.kind === 'class' ? { classIds: [...body.classIds] } : {}),
4608
+ ...(expansionPending === 0 ? {} : { expansionPending }),
4609
+ ...(body.kind === 'vocabulary' ? { from: body.from, to: body.to } : {}),
4610
+ kind: body.kind,
4611
+ path: artifact.value.path,
4612
+ schemaVersion: artifact.value.schemaVersion,
4613
+ status: planStatusForReport(artifact.value, report.value, rootDir),
4614
+ });
4615
+ }
4616
+ return Result.ok({ plans });
4617
+ };
4618
+
4619
+ const runClassModeRegrade = (
4620
+ input: RegradeInput,
4621
+ rootDir: string,
4622
+ configScope?: RegradeConfigScope | undefined
4623
+ ): Promise<TrailsResult<RegradeReport, Error>> => {
4624
+ const collection = classModeCollection(input, configScope);
4625
+ return runClassRegradeCore({
4626
+ apply: input.apply,
4627
+ ...(input.classIds === undefined ? {} : { classIds: input.classIds }),
4628
+ ...(collection === undefined ? {} : { collection }),
4629
+ includeEntries: input.includeEntries,
4630
+ ...(input.packageSource === undefined
4631
+ ? {}
4632
+ : { packageSource: input.packageSource }),
4633
+ rootDir,
4634
+ });
4635
+ };
4636
+
4637
+ export const regradeTrail = trail('regrade', {
4638
+ args: ['from', 'to'],
4639
+ description: 'Run downstream migration checks and safe rewrites',
4640
+ implementation: async (input, ctx) => {
4641
+ const rootDirResult = resolveTrailRootDir(input.rootDir, ctx.cwd);
4642
+ if (rootDirResult.isErr()) {
4643
+ return rootDirResult;
4644
+ }
4645
+
4646
+ const configResult = await loadRegradeConfig({
4647
+ ...(input.configPath === undefined
4648
+ ? {}
4649
+ : { configPath: input.configPath }),
4650
+ env: ctx.env,
4651
+ rootDir: rootDirResult.value,
4652
+ });
4653
+ if (configResult.isErr()) {
4654
+ return configResult;
4655
+ }
4656
+ const configScope = configResult.value.config?.scope;
4657
+
4658
+ let reportResult: TrailsResult<RegradeReport | null, Error>;
4659
+ if (hasVocabularyInput(input)) {
4660
+ reportResult =
4661
+ input.packageSource === undefined
4662
+ ? await runVocabularyCommandRegrade(
4663
+ input,
4664
+ rootDirResult.value,
4665
+ configScope
4666
+ )
4667
+ : Result.err(
4668
+ new ValidationError(
4669
+ 'Package-source verification is available only for class-mode Regrade runs.'
4670
+ )
4671
+ );
4672
+ } else {
4673
+ reportResult = await runClassModeRegrade(
4674
+ input,
4675
+ rootDirResult.value,
4676
+ configScope
4677
+ );
4678
+ }
4679
+ if (reportResult.isErr()) {
4680
+ return Result.err(reportResult.error);
4681
+ }
4682
+ const outputResult = validateOutput(
4683
+ regradeReportOutput,
4684
+ reportResult.value
4685
+ );
4686
+ if (outputResult.isErr()) {
4687
+ return Result.err(outputResult.error);
4688
+ }
4689
+ return Result.ok(outputResult.value);
4690
+ },
4691
+ input: regradeInputSchema,
4692
+ intent: 'write',
4693
+ output: regradeReportOutput,
4694
+ permit: 'public',
4695
+ });
4696
+
4697
+ export const planRegradeTrail = trail('plan.regrade', {
4698
+ args: ['from', 'to'],
4699
+ cli: { path: ['regrade', 'plan'] },
4700
+ description: 'Write or update a reviewed Regrade plan',
4701
+ implementation: async (input, ctx) => {
4702
+ const lifecycle = new RegradeLifecycleTracker({ progress: ctx.progress });
4703
+ const rootDirResult = await lifecycle.run('resolve-root', () =>
4704
+ resolveTrailRootDir(input.rootDir, ctx.cwd)
4705
+ );
4706
+ if (rootDirResult.isErr()) {
4707
+ return Result.err(rootDirResult.error);
4708
+ }
4709
+
4710
+ const configResult = await lifecycle.run('load-config', () =>
4711
+ loadRegradeConfig({
4712
+ ...(input.configPath === undefined
4713
+ ? {}
4714
+ : { configPath: input.configPath }),
4715
+ env: ctx.env,
4716
+ rootDir: rootDirResult.value,
4717
+ })
4718
+ );
4719
+ if (configResult.isErr()) {
4720
+ return Result.err(configResult.error);
4721
+ }
4722
+
4723
+ const result = await lifecycle.run('derive-plan', () =>
4724
+ runPlanRegrade(
4725
+ input,
4726
+ rootDirResult.value,
4727
+ configResult.value.config?.scope,
4728
+ ctx.dryRun === true
4729
+ )
4730
+ );
4731
+ if (result.isErr()) {
4732
+ return Result.err(result.error);
4733
+ }
4734
+ const output = regradePlanArtifactSchema.safeParse(result.value);
4735
+ if (!output.success) {
4736
+ return Result.err(
4737
+ new ValidationError('Invalid Regrade plan output.', {
4738
+ context: { issues: output.error.issues },
4739
+ })
4740
+ );
4741
+ }
4742
+ return Result.ok({ ...output.data, lifecycle: lifecycle.summary() });
4743
+ },
4744
+ input: regradePlanInputSchema,
4745
+ intent: 'write',
4746
+ output: regradePlanCommandOutputSchema,
4747
+ permit: 'public',
4748
+ });
4749
+
4750
+ export const listRegradesTrail = trail('list.regrades', {
4751
+ cli: { path: ['regrade', 'plans'] },
4752
+ description: 'List active Regrade plans and freshness status',
4753
+ implementation: async (input, ctx) => {
4754
+ const rootDirResult = resolveTrailRootDir(input.rootDir, ctx.cwd);
4755
+ if (rootDirResult.isErr()) {
4756
+ return rootDirResult;
4757
+ }
4758
+ const result = await listRegradePlans(rootDirResult.value);
4759
+ if (result.isErr()) {
4760
+ return result;
4761
+ }
4762
+ return Result.ok(result.value);
4763
+ },
4764
+ input: z.object({
4765
+ rootDir: z.string().optional().describe('Workspace root directory'),
4766
+ }),
4767
+ intent: 'read',
4768
+ output: regradePlansOutputSchema,
4769
+ permit: 'public',
4770
+ });
4771
+
4772
+ export const auditRegradeTrail = trail('audit.regrade', {
4773
+ cli: { path: ['regrade', 'audit'] },
4774
+ description:
4775
+ 'Audit applied Regrade vocabulary transitions against current source',
4776
+ implementation: async (input, ctx) => {
4777
+ const rootDirResult = resolveTrailRootDir(input.rootDir, ctx.cwd);
4778
+ if (rootDirResult.isErr()) {
4779
+ return rootDirResult;
4780
+ }
4781
+ const result = await auditRegradeHistory(input, rootDirResult.value);
4782
+ if (result.isErr()) {
4783
+ return result;
4784
+ }
4785
+ const output = validateOutput(regradeAuditOutputSchema, result.value);
4786
+ if (output.isErr()) {
4787
+ return Result.err(output.error);
4788
+ }
4789
+ if (input.failOnOpen && output.value.gate.status === 'open') {
4790
+ return Result.err(
4791
+ new ValidationError('Regrade audit found current-tree residue.', {
4792
+ context: {
4793
+ gate: output.value.gate,
4794
+ transitions: output.value.transitions
4795
+ .filter((transition) => transition.report.status === 'open')
4796
+ .map((transition) => ({
4797
+ open: transition.report.open,
4798
+ source: transition.source,
4799
+ transitionId: transition.transitionId,
4800
+ })),
4801
+ },
4802
+ })
4803
+ );
4804
+ }
4805
+ return Result.ok(output.value);
4806
+ },
4807
+ input: regradeAuditInputSchema,
4808
+ intent: 'read',
4809
+ output: regradeAuditOutputSchema,
4810
+ permit: 'public',
4811
+ });
4812
+
4813
+ export const checkRegradeTrail = trail('check.regrade', {
4814
+ cli: { path: ['regrade', 'check'] },
4815
+ description: 'Check a saved Regrade plan gate without writing source',
4816
+ implementation: async (input, ctx) => {
4817
+ const lifecycle = new RegradeLifecycleTracker({ progress: ctx.progress });
4818
+ const rootDirResult = await lifecycle.run('resolve-root', () =>
4819
+ resolveTrailRootDir(input.rootDir, ctx.cwd)
4820
+ );
4821
+ if (rootDirResult.isErr()) {
4822
+ return Result.err(rootDirResult.error);
4823
+ }
4824
+ const result = await lifecycle.run('check-plan', () =>
4825
+ runCheckRegradePlan(input, rootDirResult.value)
4826
+ );
4827
+ if (result.isErr()) {
4828
+ return Result.err(result.error);
4829
+ }
4830
+ const checked = {
4831
+ ...result.value,
4832
+ check: {
4833
+ plan:
4834
+ result.value.plan?.path ??
4835
+ result.value.history?.path ??
4836
+ input.plan ??
4837
+ '',
4838
+ status: 'passed' as const,
4839
+ },
4840
+ lifecycle: lifecycle.summary(),
4841
+ };
4842
+ const output = validateOutput(regradeCheckOutputSchema, checked);
4843
+ if (output.isErr()) {
4844
+ return Result.err(output.error);
4845
+ }
4846
+ return Result.ok(output.value);
4847
+ },
4848
+ input: regradePlanReferenceInputSchema,
4849
+ intent: 'read',
4850
+ output: regradeCheckOutputSchema,
4851
+ permit: 'public',
4852
+ });
4853
+
4854
+ export const previewRegradeTrail = trail('preview.regrade', {
4855
+ cli: { path: ['regrade', 'preview'] },
4856
+ description: 'Preview a saved Regrade plan without writing source',
4857
+ implementation: async (input, ctx) => {
4858
+ const lifecycle = new RegradeLifecycleTracker({ progress: ctx.progress });
4859
+ const rootDirResult = await lifecycle.run('resolve-root', () =>
4860
+ resolveTrailRootDir(input.rootDir, ctx.cwd)
4861
+ );
4862
+ if (rootDirResult.isErr()) {
4863
+ return Result.err(rootDirResult.error);
4864
+ }
4865
+ const result = await lifecycle.run('preview-plan', () =>
4866
+ runPreviewRegradePlan(input, rootDirResult.value)
4867
+ );
4868
+ if (result.isErr()) {
4869
+ return Result.err(result.error);
4870
+ }
4871
+ const output = validateOutput(regradeLifecycleReportOutputSchema, {
4872
+ ...result.value,
4873
+ lifecycle: lifecycle.summary(),
4874
+ });
4875
+ if (output.isErr()) {
4876
+ return Result.err(output.error);
4877
+ }
4878
+ return Result.ok(output.value);
4879
+ },
4880
+ input: regradePlanReferenceInputSchema,
4881
+ intent: 'read',
4882
+ output: regradeLifecycleReportOutputSchema,
4883
+ permit: 'public',
4884
+ });
4885
+
4886
+ export const applyRegradeTrail = trail('apply.regrade', {
4887
+ cli: { path: ['regrade', 'apply'] },
4888
+ description: 'Apply a saved Regrade plan and move it to history',
4889
+ implementation: async (input, ctx) => {
4890
+ const lifecycle = new RegradeLifecycleTracker({ progress: ctx.progress });
4891
+ const rootDirResult = await lifecycle.run('resolve-root', () =>
4892
+ resolveTrailRootDir(input.rootDir, ctx.cwd)
4893
+ );
4894
+ if (rootDirResult.isErr()) {
4895
+ return Result.err(rootDirResult.error);
4896
+ }
4897
+ const result = await lifecycle.run('apply-plan', () =>
4898
+ runApplyRegradePlan(input, rootDirResult.value, ctx.dryRun === true)
4899
+ );
4900
+ if (result.isErr()) {
4901
+ return Result.err(result.error);
4902
+ }
4903
+ const output = validateOutput(regradeLifecycleReportOutputSchema, {
4904
+ ...result.value,
4905
+ lifecycle: lifecycle.summary(),
4906
+ });
4907
+ if (output.isErr()) {
4908
+ return Result.err(output.error);
4909
+ }
4910
+ return Result.ok(output.value);
4911
+ },
4912
+ input: regradeApplyPlanInputSchema,
4913
+ intent: 'write',
4914
+ output: regradeLifecycleReportOutputSchema,
4915
+ permit: 'public',
4916
+ });
4917
+
4918
+ export const adjustRegradeTrail = trail('adjust.regrade', {
4919
+ args: ['transition'],
4920
+ cli: { path: ['regrade', 'adjust'] },
4921
+ description:
4922
+ 'Pull a graduated Regrade transition back to an active plan for adjustment',
4923
+ implementation: async (input, ctx) => {
4924
+ const lifecycle = new RegradeLifecycleTracker({ progress: ctx.progress });
4925
+ const rootDirResult = await lifecycle.run('resolve-root', () =>
4926
+ resolveTrailRootDir(input.rootDir, ctx.cwd)
4927
+ );
4928
+ if (rootDirResult.isErr()) {
4929
+ return Result.err(rootDirResult.error);
4930
+ }
4931
+ const result = await lifecycle.run('adjust-plan', () =>
4932
+ runAdjustRegrade(input, rootDirResult.value, ctx.dryRun === true)
4933
+ );
4934
+ if (result.isErr()) {
4935
+ return Result.err(result.error);
4936
+ }
4937
+ const output = regradePlanArtifactSchema.safeParse(result.value);
4938
+ if (!output.success) {
4939
+ return Result.err(
4940
+ new ValidationError('Invalid Regrade plan output.', {
4941
+ context: { issues: output.error.issues },
4942
+ })
4943
+ );
4944
+ }
4945
+ return Result.ok({ ...output.data, lifecycle: lifecycle.summary() });
4946
+ },
4947
+ input: regradeAdjustInputSchema,
4948
+ intent: 'write',
4949
+ output: regradePlanCommandOutputSchema,
4950
+ permit: 'public',
4951
+ });