@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.
- package/CHANGELOG.md +1906 -0
- package/README.md +48 -0
- package/bin/trails.ts +3 -0
- package/package.json +57 -0
- package/src/app.ts +167 -0
- package/src/clack.ts +111 -0
- package/src/cli.ts +308 -0
- package/src/completions.ts +431 -0
- package/src/lifecycle-source-io.ts +33 -0
- package/src/load-app-mirror.ts +202 -0
- package/src/local-state-io.ts +129 -0
- package/src/mcp-app.ts +42 -0
- package/src/mcp-options.ts +92 -0
- package/src/mcp.ts +8 -0
- package/src/project-writes.ts +377 -0
- package/src/regrade/audit.ts +571 -0
- package/src/regrade/config.ts +152 -0
- package/src/regrade/history.ts +636 -0
- package/src/regrade/lifecycle.ts +76 -0
- package/src/regrade/live-api-preserve.ts +123 -0
- package/src/regrade/plan-artifact.ts +515 -0
- package/src/regrade/plan-derivation.ts +301 -0
- package/src/regrade/prepared-run.ts +259 -0
- package/src/regrade/receipt-history.ts +446 -0
- package/src/regrade/source-transaction.ts +185 -0
- package/src/release/bindings.ts +58 -0
- package/src/release/changeset-packages.ts +99 -0
- package/src/release/check.ts +1191 -0
- package/src/release/cli-bundle.ts +575 -0
- package/src/release/config.ts +73 -0
- package/src/release/contract-facts.ts +425 -0
- package/src/release/homebrew.ts +221 -0
- package/src/release/index.ts +180 -0
- package/src/release/lock-roundtrip-smoke.ts +255 -0
- package/src/release/lock-roundtrip-workspace.ts +107 -0
- package/src/release/native-bun-publish.ts +964 -0
- package/src/release/native-bun-registry.ts +848 -0
- package/src/release/notes-cli.ts +171 -0
- package/src/release/notes.ts +390 -0
- package/src/release/pack-coherence.ts +455 -0
- package/src/release/package-route-facts.ts +146 -0
- package/src/release/packed-artifacts-smoke.ts +236 -0
- package/src/release/policy.ts +1780 -0
- package/src/release/semver.ts +104 -0
- package/src/release/smoke.ts +56 -0
- package/src/release/stable-version-release.ts +80 -0
- package/src/release/wayfinder-dogfood-smoke.ts +762 -0
- package/src/release/zero-line-transition.ts +68 -0
- package/src/retired-topo-command.ts +36 -0
- package/src/run-adapter-check.ts +76 -0
- package/src/run-argv.ts +133 -0
- package/src/run-collision.ts +126 -0
- package/src/run-completions-install.ts +179 -0
- package/src/run-example.ts +149 -0
- package/src/run-examples.ts +148 -0
- package/src/run-quiet.ts +75 -0
- package/src/run-regrade-progress.ts +47 -0
- package/src/run-release-check.ts +74 -0
- package/src/run-schema.ts +74 -0
- package/src/run-trace.ts +273 -0
- package/src/run-warden.ts +39 -0
- package/src/run-watch-project.ts +52 -0
- package/src/run-watch.ts +381 -0
- package/src/run-wayfind-outline.ts +170 -0
- package/src/scaffold-version-sync.ts +183 -0
- package/src/scaffold-versions.generated.ts +12 -0
- package/src/trails/adapter-check.ts +244 -0
- package/src/trails/add-surface.ts +816 -0
- package/src/trails/add-trail.ts +141 -0
- package/src/trails/add-verify.ts +252 -0
- package/src/trails/compile.ts +118 -0
- package/src/trails/completions-complete.ts +236 -0
- package/src/trails/completions.ts +47 -0
- package/src/trails/config-explain.ts +43 -0
- package/src/trails/create-adapter.ts +785 -0
- package/src/trails/create-scaffold.ts +1215 -0
- package/src/trails/create-versions.ts +62 -0
- package/src/trails/create.ts +652 -0
- package/src/trails/deprecate.ts +59 -0
- package/src/trails/dev-clean.ts +80 -0
- package/src/trails/dev-reset.ts +48 -0
- package/src/trails/dev-stats.ts +71 -0
- package/src/trails/dev-support.ts +360 -0
- package/src/trails/doctor.ts +77 -0
- package/src/trails/draft-promote.ts +949 -0
- package/src/trails/guide.ts +106 -0
- package/src/trails/load-app.ts +1145 -0
- package/src/trails/operator-context.ts +66 -0
- package/src/trails/project-context-output.ts +304 -0
- package/src/trails/project-context.ts +613 -0
- package/src/trails/project.ts +65 -0
- package/src/trails/regrade.ts +4951 -0
- package/src/trails/release-check.ts +113 -0
- package/src/trails/release-smoke.ts +49 -0
- package/src/trails/revise.ts +53 -0
- package/src/trails/root-dir.ts +21 -0
- package/src/trails/run-example.ts +592 -0
- package/src/trails/run-examples.ts +149 -0
- package/src/trails/run.ts +496 -0
- package/src/trails/scaffold-json.ts +60 -0
- package/src/trails/scaffold-topo-identity.ts +479 -0
- package/src/trails/survey.ts +990 -0
- package/src/trails/topo-activation.ts +14 -0
- package/src/trails/topo-constants.ts +2 -0
- package/src/trails/topo-history.ts +47 -0
- package/src/trails/topo-output-schemas.ts +259 -0
- package/src/trails/topo-pin.ts +38 -0
- package/src/trails/topo-read-support.ts +368 -0
- package/src/trails/topo-reports.ts +809 -0
- package/src/trails/topo-store-support.ts +323 -0
- package/src/trails/topo-support.ts +247 -0
- package/src/trails/topo-unpin.ts +61 -0
- package/src/trails/topo.ts +92 -0
- package/src/trails/validate.ts +348 -0
- package/src/trails/version-lifecycle-support.ts +936 -0
- package/src/trails/warden-guide.ts +134 -0
- package/src/trails/warden.ts +598 -0
- package/src/trails/wayfind-diff.ts +716 -0
- package/src/trails/wayfind-outline.ts +876 -0
- package/src/trails/wayfind.ts +1319 -0
- 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
|
+
});
|