@ontrails/trails 1.0.0-beta.4 → 1.0.0-beta.42
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 +1106 -4
- package/README.md +27 -0
- package/package.json +31 -7
- package/src/app.ts +147 -3
- package/src/cli.ts +345 -11
- package/src/completions.ts +240 -0
- package/src/lifecycle-source-io.ts +33 -0
- package/src/load-app-mirror.ts +202 -0
- package/src/local-state-io.ts +173 -0
- package/src/mcp-app.ts +42 -0
- package/src/mcp-options.ts +91 -0
- package/src/mcp.ts +8 -0
- package/src/project-writes.ts +377 -0
- package/src/regrade/config.ts +152 -0
- package/src/regrade/history.ts +430 -0
- package/src/regrade/live-api-preserve.ts +8 -0
- package/src/regrade/plan-artifact.ts +285 -0
- package/src/release/bindings.ts +39 -0
- package/src/release/check.ts +844 -0
- package/src/release/config.ts +63 -0
- package/src/release/contract-facts.ts +425 -0
- package/src/release/index.ts +142 -0
- package/src/release/lock-roundtrip-smoke.ts +230 -0
- package/src/release/native-bun-publish.ts +651 -0
- package/src/release/native-bun-registry.ts +765 -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/packed-artifacts-smoke.ts +236 -0
- package/src/release/policy.ts +1687 -0
- package/src/release/semver.ts +104 -0
- package/src/release/smoke.ts +56 -0
- package/src/release/wayfinder-dogfood-smoke.ts +760 -0
- package/src/retired-topo-command.ts +36 -0
- package/src/run-adapter-check.ts +76 -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-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.ts +432 -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 +99 -45
- package/src/trails/add-trail.ts +84 -37
- package/src/trails/add-verify.ts +100 -30
- package/src/trails/compile.ts +58 -0
- package/src/trails/completions-complete.ts +165 -0
- package/src/trails/completions.ts +47 -0
- package/src/trails/create-adapter.ts +785 -0
- package/src/trails/create-scaffold.ts +401 -106
- package/src/trails/create-versions.ts +62 -0
- package/src/trails/create.ts +186 -72
- package/src/trails/deprecate.ts +59 -0
- package/src/trails/dev-clean.ts +82 -0
- package/src/trails/dev-reset.ts +50 -0
- package/src/trails/dev-stats.ts +72 -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 +70 -68
- package/src/trails/load-app.ts +1123 -15
- package/src/trails/operator-context.ts +66 -0
- package/src/trails/project.ts +17 -3
- package/src/trails/regrade.ts +3091 -0
- package/src/trails/release-check.ts +105 -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 +475 -0
- package/src/trails/run-examples.ts +129 -0
- package/src/trails/run.ts +434 -0
- package/src/trails/scaffold-json.ts +58 -0
- package/src/trails/survey.ts +877 -214
- 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 +325 -0
- package/src/trails/topo-support.ts +220 -0
- package/src/trails/topo-unpin.ts +61 -0
- package/src/trails/topo.ts +92 -0
- package/src/trails/validate.ts +27 -0
- package/src/trails/version-lifecycle-support.ts +936 -0
- package/src/trails/warden-guide.ts +134 -0
- package/src/trails/warden.ts +198 -58
- package/src/trails/wayfind-outline.ts +876 -0
- package/src/trails/wayfind.ts +1053 -0
- package/src/versions.ts +31 -0
- package/.turbo/turbo-build.log +0 -1
- package/.turbo/turbo-lint.log +0 -3
- package/.turbo/turbo-typecheck.log +0 -1
- package/__tests__/examples.test.ts +0 -6
- package/dist/bin/trails.d.ts +0 -3
- package/dist/bin/trails.d.ts.map +0 -1
- package/dist/bin/trails.js +0 -4
- package/dist/bin/trails.js.map +0 -1
- package/dist/src/app.d.ts +0 -2
- package/dist/src/app.d.ts.map +0 -1
- package/dist/src/app.js +0 -11
- package/dist/src/app.js.map +0 -1
- package/dist/src/clack.d.ts +0 -9
- package/dist/src/clack.d.ts.map +0 -1
- package/dist/src/clack.js +0 -84
- package/dist/src/clack.js.map +0 -1
- package/dist/src/cli.d.ts +0 -2
- package/dist/src/cli.d.ts.map +0 -1
- package/dist/src/cli.js +0 -13
- package/dist/src/cli.js.map +0 -1
- package/dist/src/trails/add-surface.d.ts +0 -13
- package/dist/src/trails/add-surface.d.ts.map +0 -1
- package/dist/src/trails/add-surface.js +0 -88
- package/dist/src/trails/add-surface.js.map +0 -1
- package/dist/src/trails/add-trail.d.ts +0 -10
- package/dist/src/trails/add-trail.d.ts.map +0 -1
- package/dist/src/trails/add-trail.js +0 -77
- package/dist/src/trails/add-trail.js.map +0 -1
- package/dist/src/trails/add-verify.d.ts +0 -10
- package/dist/src/trails/add-verify.d.ts.map +0 -1
- package/dist/src/trails/add-verify.js +0 -67
- package/dist/src/trails/add-verify.js.map +0 -1
- package/dist/src/trails/create-scaffold.d.ts +0 -15
- package/dist/src/trails/create-scaffold.d.ts.map +0 -1
- package/dist/src/trails/create-scaffold.js +0 -288
- package/dist/src/trails/create-scaffold.js.map +0 -1
- package/dist/src/trails/create.d.ts +0 -22
- package/dist/src/trails/create.d.ts.map +0 -1
- package/dist/src/trails/create.js +0 -121
- package/dist/src/trails/create.js.map +0 -1
- package/dist/src/trails/guide.d.ts +0 -11
- package/dist/src/trails/guide.d.ts.map +0 -1
- package/dist/src/trails/guide.js +0 -80
- package/dist/src/trails/guide.js.map +0 -1
- package/dist/src/trails/load-app.d.ts +0 -4
- package/dist/src/trails/load-app.d.ts.map +0 -1
- package/dist/src/trails/load-app.js +0 -24
- package/dist/src/trails/load-app.js.map +0 -1
- package/dist/src/trails/project.d.ts +0 -8
- package/dist/src/trails/project.d.ts.map +0 -1
- package/dist/src/trails/project.js +0 -43
- package/dist/src/trails/project.js.map +0 -1
- package/dist/src/trails/survey.d.ts +0 -31
- package/dist/src/trails/survey.d.ts.map +0 -1
- package/dist/src/trails/survey.js +0 -221
- package/dist/src/trails/survey.js.map +0 -1
- package/dist/src/trails/warden.d.ts +0 -19
- package/dist/src/trails/warden.d.ts.map +0 -1
- package/dist/src/trails/warden.js +0 -88
- package/dist/src/trails/warden.js.map +0 -1
- package/dist/tsconfig.tsbuildinfo +0 -1
- package/src/__tests__/create.test.ts +0 -349
- package/src/__tests__/guide.test.ts +0 -91
- package/src/__tests__/load-app.test.ts +0 -15
- package/src/__tests__/survey.test.ts +0 -159
- package/src/__tests__/warden.test.ts +0 -74
- package/tsconfig.json +0 -9
|
@@ -0,0 +1,3091 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `regrade` trail -- Run downstream migration checks and safe rewrites.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
InternalError,
|
|
7
|
+
NotFoundError,
|
|
8
|
+
Result,
|
|
9
|
+
ValidationError,
|
|
10
|
+
matchesAnyPathGlob,
|
|
11
|
+
pathScopeSchema,
|
|
12
|
+
trail,
|
|
13
|
+
validateOutput,
|
|
14
|
+
} from '@ontrails/core';
|
|
15
|
+
import type { PathScope, Result as TrailsResult } from '@ontrails/core';
|
|
16
|
+
import {
|
|
17
|
+
createGovernedAstIdentifierRenameClasses,
|
|
18
|
+
listVocabularyRegradePlansFromRegistry,
|
|
19
|
+
loadWardenRegradeClasses,
|
|
20
|
+
readVocabularyTransitionRecord,
|
|
21
|
+
regradeReportOutput,
|
|
22
|
+
runRegrade,
|
|
23
|
+
runVocabularyRegrade,
|
|
24
|
+
transitionRecordReportWithSummary,
|
|
25
|
+
vocabularyRegradeTransitionForInput,
|
|
26
|
+
vocabularyDispositionValues,
|
|
27
|
+
vocabularyRegradePlanSchema,
|
|
28
|
+
writeVocabularyTransitionRecord,
|
|
29
|
+
} from '@ontrails/regrade';
|
|
30
|
+
import type {
|
|
31
|
+
RegradeApplySummary,
|
|
32
|
+
RegradeReport,
|
|
33
|
+
RegradeReportEntry,
|
|
34
|
+
RegradeScanDirectoryBucket,
|
|
35
|
+
RegradeScanExtensionBucket,
|
|
36
|
+
VocabularyPreserveRule,
|
|
37
|
+
VocabularyRegradePlan,
|
|
38
|
+
VocabularyPreserveInventoryEntry,
|
|
39
|
+
} from '@ontrails/regrade';
|
|
40
|
+
import { execFileSync } from 'node:child_process';
|
|
41
|
+
import {
|
|
42
|
+
existsSync,
|
|
43
|
+
mkdirSync,
|
|
44
|
+
readdirSync,
|
|
45
|
+
readFileSync,
|
|
46
|
+
rmSync,
|
|
47
|
+
writeFileSync,
|
|
48
|
+
} from 'node:fs';
|
|
49
|
+
import type { Dirent } from 'node:fs';
|
|
50
|
+
import { basename, dirname, isAbsolute, join } from 'node:path';
|
|
51
|
+
import { z } from 'zod';
|
|
52
|
+
|
|
53
|
+
import { loadRegradeConfig } from '../regrade/config.js';
|
|
54
|
+
import {
|
|
55
|
+
REGRADE_HISTORY_SCHEMA_VERSION,
|
|
56
|
+
appendRegradeHistoryRun,
|
|
57
|
+
readRegradeHistoryArtifact,
|
|
58
|
+
regradeHistoryPathForPlan,
|
|
59
|
+
resolveRegradeHistoryPath,
|
|
60
|
+
verifyRegradeHistoryRuns,
|
|
61
|
+
} from '../regrade/history.js';
|
|
62
|
+
import type { RegradeHistorySummary } from '../regrade/history.js';
|
|
63
|
+
import { deriveLiveApiPreserveInventory } from '../regrade/live-api-preserve.js';
|
|
64
|
+
import {
|
|
65
|
+
REGRADE_PLAN_SCHEMA_VERSION,
|
|
66
|
+
regradePlanArtifactSchema,
|
|
67
|
+
regradePlanPathForPlan,
|
|
68
|
+
regradeSourceHash,
|
|
69
|
+
regradeSourceHashMatches,
|
|
70
|
+
rootRelativePath,
|
|
71
|
+
} from '../regrade/plan-artifact.js';
|
|
72
|
+
import type {
|
|
73
|
+
ClassRegradePlan,
|
|
74
|
+
RegradePlanArtifact,
|
|
75
|
+
RegradePlanBody,
|
|
76
|
+
RegradePlanExpansion,
|
|
77
|
+
VocabularyRegradePlanArtifact,
|
|
78
|
+
} from '../regrade/plan-artifact.js';
|
|
79
|
+
import { resolveTrailRootDir } from './root-dir.js';
|
|
80
|
+
|
|
81
|
+
const regradePathScopeInputSchema = pathScopeSchema.extend({
|
|
82
|
+
exclude: pathScopeSchema.shape.exclude.describe(
|
|
83
|
+
'Root-relative path globs to exclude during Regrade collection'
|
|
84
|
+
),
|
|
85
|
+
extensions: pathScopeSchema.shape.extensions.describe(
|
|
86
|
+
'Source file extensions to scan during Regrade collection'
|
|
87
|
+
),
|
|
88
|
+
include: pathScopeSchema.shape.include.describe(
|
|
89
|
+
'Root-relative path patterns to include in vocabulary regrade mode'
|
|
90
|
+
),
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const regradePreserveRuleInputSchema = z.object({
|
|
94
|
+
disposition: z
|
|
95
|
+
.enum(vocabularyDispositionValues)
|
|
96
|
+
.optional()
|
|
97
|
+
.describe('Classification to assign to occurrences this rule preserves'),
|
|
98
|
+
forms: z
|
|
99
|
+
.array(z.string().min(1))
|
|
100
|
+
.optional()
|
|
101
|
+
.describe('Matched forms this preserve rule applies to'),
|
|
102
|
+
paths: z
|
|
103
|
+
.array(z.string())
|
|
104
|
+
.optional()
|
|
105
|
+
.describe('Root-relative path globs where this preserve rule applies'),
|
|
106
|
+
pattern: z.string().min(1).describe('Regex or literal pattern to preserve'),
|
|
107
|
+
reason: z.string().optional().describe('Why this form is preserved'),
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const regradePreserveInputSchema = z.union([
|
|
111
|
+
z.string().min(1),
|
|
112
|
+
regradePreserveRuleInputSchema,
|
|
113
|
+
]);
|
|
114
|
+
|
|
115
|
+
const regradeInputSchema = regradePathScopeInputSchema.extend({
|
|
116
|
+
apply: z
|
|
117
|
+
.boolean()
|
|
118
|
+
.default(false)
|
|
119
|
+
.describe('Write safe rewrites to disk; dry-run report only by default'),
|
|
120
|
+
check: z
|
|
121
|
+
.boolean()
|
|
122
|
+
.default(false)
|
|
123
|
+
.describe(
|
|
124
|
+
'Legacy compatibility: check a saved transition record gate without applying rewrites; prefer `regrade check` for saved plans'
|
|
125
|
+
),
|
|
126
|
+
classIds: z
|
|
127
|
+
.array(z.string())
|
|
128
|
+
.optional()
|
|
129
|
+
.describe('Regrade class ids to run (defaults to all built-in classes)'),
|
|
130
|
+
configPath: z
|
|
131
|
+
.string()
|
|
132
|
+
.optional()
|
|
133
|
+
.describe('Path to a Trails config file with regrade defaults'),
|
|
134
|
+
from: z
|
|
135
|
+
.string()
|
|
136
|
+
.min(1)
|
|
137
|
+
.optional()
|
|
138
|
+
.describe('Source vocabulary term for a vocabulary regrade'),
|
|
139
|
+
includeEntries: z
|
|
140
|
+
.enum(['actionable', 'all'])
|
|
141
|
+
.default('actionable')
|
|
142
|
+
.describe(
|
|
143
|
+
'Report entry detail to include; counts always cover the full run'
|
|
144
|
+
),
|
|
145
|
+
intent: z
|
|
146
|
+
.string()
|
|
147
|
+
.optional()
|
|
148
|
+
.describe('Human-authored migration intent for a vocabulary regrade'),
|
|
149
|
+
overrides: z
|
|
150
|
+
.record(z.string().min(1), z.string().min(1))
|
|
151
|
+
.optional()
|
|
152
|
+
.describe('Explicit source-form to target-form mappings'),
|
|
153
|
+
planRecord: z
|
|
154
|
+
.string()
|
|
155
|
+
.optional()
|
|
156
|
+
.describe(
|
|
157
|
+
'Legacy compatibility path to a confirmed transition record; prefer `regrade check`, `regrade preview`, and `regrade apply` with saved plans'
|
|
158
|
+
),
|
|
159
|
+
preserve: z
|
|
160
|
+
.array(regradePreserveInputSchema)
|
|
161
|
+
.optional()
|
|
162
|
+
.describe(
|
|
163
|
+
'Regex or literal contexts, or structured preserve rules, for a vocabulary regrade'
|
|
164
|
+
),
|
|
165
|
+
rootDir: z.string().optional().describe('Workspace root directory'),
|
|
166
|
+
to: z
|
|
167
|
+
.string()
|
|
168
|
+
.min(1)
|
|
169
|
+
.optional()
|
|
170
|
+
.describe('Target vocabulary term for a vocabulary regrade'),
|
|
171
|
+
writeRecord: z
|
|
172
|
+
.boolean()
|
|
173
|
+
.default(false)
|
|
174
|
+
.describe(
|
|
175
|
+
'Legacy compatibility: persist dry-run or apply evidence as a transition record; prefer `regrade plan` and plan history'
|
|
176
|
+
),
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
type RegradeInput = z.output<typeof regradeInputSchema>;
|
|
180
|
+
|
|
181
|
+
const regradePlanSummarySchema = z.object({
|
|
182
|
+
classIds: z
|
|
183
|
+
.array(z.string())
|
|
184
|
+
.optional()
|
|
185
|
+
.describe('Class ids for a class-mode plan'),
|
|
186
|
+
expansionPending: z
|
|
187
|
+
.number()
|
|
188
|
+
.optional()
|
|
189
|
+
.describe('Pending staged expansion candidates on this plan'),
|
|
190
|
+
from: z.string().optional().describe('Source term for a vocabulary plan'),
|
|
191
|
+
kind: z.enum(['class', 'vocabulary']).describe('Regrade plan kind'),
|
|
192
|
+
path: z.string(),
|
|
193
|
+
schemaVersion: z.number(),
|
|
194
|
+
status: z.enum(['active', 'stale']),
|
|
195
|
+
to: z.string().optional().describe('Target term for a vocabulary plan'),
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
const regradePlansOutputSchema = z.object({
|
|
199
|
+
plans: z.array(regradePlanSummarySchema),
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
const regradeCheckOutputSchema = regradeReportOutput.extend({
|
|
203
|
+
check: z
|
|
204
|
+
.object({
|
|
205
|
+
plan: z
|
|
206
|
+
.string()
|
|
207
|
+
.describe(
|
|
208
|
+
'Saved Regrade plan or graduated history path that passed checks'
|
|
209
|
+
),
|
|
210
|
+
status: z.literal('passed').describe('Check result'),
|
|
211
|
+
})
|
|
212
|
+
.describe('Saved Regrade plan check result'),
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
const regradePlanInputSchema = regradePathScopeInputSchema.extend({
|
|
216
|
+
classIds: z
|
|
217
|
+
.array(z.string().min(1))
|
|
218
|
+
.optional()
|
|
219
|
+
.describe(
|
|
220
|
+
'Regrade class ids for a class-mode plan; pair with `type: class` on the CLI so the plan subcommand wins over `regrade` positionals'
|
|
221
|
+
),
|
|
222
|
+
configPath: z
|
|
223
|
+
.string()
|
|
224
|
+
.optional()
|
|
225
|
+
.describe('Path to a Trails config file with regrade defaults'),
|
|
226
|
+
expand: z
|
|
227
|
+
.boolean()
|
|
228
|
+
.default(false)
|
|
229
|
+
.describe('Stage wide-net review candidates in the saved plan'),
|
|
230
|
+
fresh: z
|
|
231
|
+
.boolean()
|
|
232
|
+
.default(false)
|
|
233
|
+
.describe(
|
|
234
|
+
'Replace an existing active plan instead of preserving authored fields'
|
|
235
|
+
),
|
|
236
|
+
from: z
|
|
237
|
+
.string()
|
|
238
|
+
.min(1)
|
|
239
|
+
.optional()
|
|
240
|
+
.describe('Source vocabulary term or phrase'),
|
|
241
|
+
include: pathScopeSchema.shape.include.describe(
|
|
242
|
+
'Root-relative path globs to collect during the plan run'
|
|
243
|
+
),
|
|
244
|
+
includeEntries: z
|
|
245
|
+
.enum(['actionable', 'all'])
|
|
246
|
+
.default('actionable')
|
|
247
|
+
.describe(
|
|
248
|
+
'Report entry detail to inspect while deriving plan freshness and expansion'
|
|
249
|
+
),
|
|
250
|
+
intent: z
|
|
251
|
+
.string()
|
|
252
|
+
.optional()
|
|
253
|
+
.describe('Human-authored migration intent for the plan'),
|
|
254
|
+
name: z
|
|
255
|
+
.string()
|
|
256
|
+
.min(1)
|
|
257
|
+
.optional()
|
|
258
|
+
.describe(
|
|
259
|
+
'Transition name for a class-mode plan; names the plan and history files'
|
|
260
|
+
),
|
|
261
|
+
overrides: z
|
|
262
|
+
.record(z.string().min(1), z.string().min(1))
|
|
263
|
+
.optional()
|
|
264
|
+
.describe('Explicit source-form to target-form mappings'),
|
|
265
|
+
preserve: z
|
|
266
|
+
.array(regradePreserveInputSchema)
|
|
267
|
+
.optional()
|
|
268
|
+
.describe(
|
|
269
|
+
'Regex or literal contexts, or structured preserve rules, for a vocabulary regrade'
|
|
270
|
+
),
|
|
271
|
+
rootDir: z.string().optional().describe('Workspace root directory'),
|
|
272
|
+
to: z.string().min(1).optional().describe('Target vocabulary term or phrase'),
|
|
273
|
+
type: z
|
|
274
|
+
.enum(['class', 'vocabulary'])
|
|
275
|
+
.optional()
|
|
276
|
+
.describe(
|
|
277
|
+
'Optional plan type qualifier when a source/target pair is ambiguous'
|
|
278
|
+
),
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
const regradePlanReferenceInputSchema = z.object({
|
|
282
|
+
includeEntries: z
|
|
283
|
+
.enum(['actionable', 'all'])
|
|
284
|
+
.default('actionable')
|
|
285
|
+
.describe('Report entry detail to include while evaluating a saved plan'),
|
|
286
|
+
plan: z
|
|
287
|
+
.string()
|
|
288
|
+
.optional()
|
|
289
|
+
.describe('Plan name or path; omitted when exactly one active plan exists'),
|
|
290
|
+
rootDir: z.string().optional().describe('Workspace root directory'),
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
const regradeApplyPlanInputSchema = regradePlanReferenceInputSchema;
|
|
294
|
+
|
|
295
|
+
const regradeAdjustInputSchema = z.object({
|
|
296
|
+
rootDir: z.string().optional().describe('Workspace root directory'),
|
|
297
|
+
transition: z
|
|
298
|
+
.string()
|
|
299
|
+
.min(1)
|
|
300
|
+
.describe('Graduated transition name, e.g. facet-to-trailhead'),
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
type RegradePlanInput = z.output<typeof regradePlanInputSchema>;
|
|
304
|
+
type RegradePlanReferenceInput = z.output<
|
|
305
|
+
typeof regradePlanReferenceInputSchema
|
|
306
|
+
>;
|
|
307
|
+
type RegradeApplyPlanInput = z.output<typeof regradeApplyPlanInputSchema>;
|
|
308
|
+
type RegradeAdjustInput = z.output<typeof regradeAdjustInputSchema>;
|
|
309
|
+
|
|
310
|
+
const hasVocabularyInput = (input: RegradeInput) =>
|
|
311
|
+
input.from !== undefined ||
|
|
312
|
+
input.check ||
|
|
313
|
+
input.include !== undefined ||
|
|
314
|
+
input.intent !== undefined ||
|
|
315
|
+
input.overrides !== undefined ||
|
|
316
|
+
input.planRecord !== undefined ||
|
|
317
|
+
input.preserve !== undefined ||
|
|
318
|
+
input.to !== undefined;
|
|
319
|
+
|
|
320
|
+
const classModeCollection = (
|
|
321
|
+
input: RegradeInput,
|
|
322
|
+
configScope?: RegradeConfigScope | undefined
|
|
323
|
+
):
|
|
324
|
+
| {
|
|
325
|
+
readonly exclude?: readonly string[];
|
|
326
|
+
readonly extensions?: readonly string[];
|
|
327
|
+
}
|
|
328
|
+
| undefined => {
|
|
329
|
+
if (
|
|
330
|
+
configScope?.exclude === undefined &&
|
|
331
|
+
configScope?.extensions === undefined &&
|
|
332
|
+
input.exclude === undefined &&
|
|
333
|
+
input.extensions === undefined
|
|
334
|
+
) {
|
|
335
|
+
return undefined;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
return {
|
|
339
|
+
...(configScope?.exclude === undefined
|
|
340
|
+
? {}
|
|
341
|
+
: { exclude: configScope.exclude }),
|
|
342
|
+
...(configScope?.extensions === undefined
|
|
343
|
+
? {}
|
|
344
|
+
: { extensions: configScope.extensions }),
|
|
345
|
+
...(input.exclude === undefined ? {} : { exclude: input.exclude }),
|
|
346
|
+
...(input.extensions === undefined ? {} : { extensions: input.extensions }),
|
|
347
|
+
};
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
interface RegradeConfigScope {
|
|
351
|
+
readonly exclude?: PathScope['exclude'] | undefined;
|
|
352
|
+
readonly extensions?: PathScope['extensions'] | undefined;
|
|
353
|
+
readonly include?: PathScope['include'] | undefined;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
interface RegradeCollectionScope {
|
|
357
|
+
readonly exclude?: readonly string[];
|
|
358
|
+
readonly extensions?: readonly string[];
|
|
359
|
+
readonly include?: readonly string[];
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const symbolSourceExtensions: readonly string[] = [
|
|
363
|
+
'.cjs',
|
|
364
|
+
'.cts',
|
|
365
|
+
'.js',
|
|
366
|
+
'.jsx',
|
|
367
|
+
'.mjs',
|
|
368
|
+
'.mts',
|
|
369
|
+
'.ts',
|
|
370
|
+
'.tsx',
|
|
371
|
+
] as const;
|
|
372
|
+
|
|
373
|
+
const vocabularyProseExtensions: readonly string[] = [
|
|
374
|
+
'.md',
|
|
375
|
+
'.mdx',
|
|
376
|
+
'.txt',
|
|
377
|
+
] as const;
|
|
378
|
+
|
|
379
|
+
const normalizeExtension = (extension: string): string =>
|
|
380
|
+
extension === '' || extension.startsWith('.') ? extension : `.${extension}`;
|
|
381
|
+
|
|
382
|
+
const compileVocabularyPreservePattern = (pattern: string): RegExp => {
|
|
383
|
+
try {
|
|
384
|
+
return new RegExp(pattern);
|
|
385
|
+
} catch {
|
|
386
|
+
return new RegExp(pattern.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&'));
|
|
387
|
+
}
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
const globalVocabularyPreservePattern = (pattern: RegExp): RegExp => {
|
|
391
|
+
const flags = pattern.flags.includes('g')
|
|
392
|
+
? pattern.flags
|
|
393
|
+
: `${pattern.flags}g`;
|
|
394
|
+
return new RegExp(pattern.source, flags);
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
const preservePatternOverlapsSpan = (
|
|
398
|
+
pattern: RegExp,
|
|
399
|
+
source: string,
|
|
400
|
+
start: number,
|
|
401
|
+
end: number
|
|
402
|
+
): boolean => {
|
|
403
|
+
for (const match of source.matchAll(
|
|
404
|
+
globalVocabularyPreservePattern(pattern)
|
|
405
|
+
)) {
|
|
406
|
+
const matchStart = match.index ?? 0;
|
|
407
|
+
const matchEnd = matchStart + match[0].length;
|
|
408
|
+
if (matchStart !== matchEnd && start < matchEnd && matchStart < end) {
|
|
409
|
+
return true;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
return false;
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
const preserveRuleMatchesSymbolOccurrence = (
|
|
416
|
+
rule: VocabularyPreserveRule,
|
|
417
|
+
occurrence: {
|
|
418
|
+
readonly form: string;
|
|
419
|
+
readonly path: string;
|
|
420
|
+
readonly source: string;
|
|
421
|
+
readonly start: number;
|
|
422
|
+
readonly end: number;
|
|
423
|
+
}
|
|
424
|
+
): boolean => {
|
|
425
|
+
if (rule.forms !== undefined && !rule.forms.includes(occurrence.form)) {
|
|
426
|
+
return false;
|
|
427
|
+
}
|
|
428
|
+
if (
|
|
429
|
+
rule.paths !== undefined &&
|
|
430
|
+
!matchesAnyPathGlob(occurrence.path, rule.paths)
|
|
431
|
+
) {
|
|
432
|
+
return false;
|
|
433
|
+
}
|
|
434
|
+
const pattern = compileVocabularyPreservePattern(rule.pattern);
|
|
435
|
+
return (
|
|
436
|
+
pattern.test(occurrence.form) ||
|
|
437
|
+
preservePatternOverlapsSpan(
|
|
438
|
+
pattern,
|
|
439
|
+
occurrence.source,
|
|
440
|
+
occurrence.start,
|
|
441
|
+
occurrence.end
|
|
442
|
+
)
|
|
443
|
+
);
|
|
444
|
+
};
|
|
445
|
+
|
|
446
|
+
const symbolOccurrenceIsPreserved = (
|
|
447
|
+
rules: readonly VocabularyPreserveRule[] | undefined,
|
|
448
|
+
occurrence: {
|
|
449
|
+
readonly form: string;
|
|
450
|
+
readonly path: string;
|
|
451
|
+
readonly source: string;
|
|
452
|
+
readonly start: number;
|
|
453
|
+
readonly end: number;
|
|
454
|
+
}
|
|
455
|
+
): boolean =>
|
|
456
|
+
rules?.some((rule) =>
|
|
457
|
+
preserveRuleMatchesSymbolOccurrence(rule, occurrence)
|
|
458
|
+
) ?? false;
|
|
459
|
+
|
|
460
|
+
const vocabularyScopeFromConfig = (
|
|
461
|
+
scope: RegradeConfigScope | undefined
|
|
462
|
+
): VocabularyRegradePlan['scope'] | undefined =>
|
|
463
|
+
scope === undefined
|
|
464
|
+
? undefined
|
|
465
|
+
: {
|
|
466
|
+
...(scope.exclude === undefined ? {} : { exclude: scope.exclude }),
|
|
467
|
+
...(scope.extensions === undefined
|
|
468
|
+
? {}
|
|
469
|
+
: { extensions: scope.extensions }),
|
|
470
|
+
...(scope.include === undefined ? {} : { include: scope.include }),
|
|
471
|
+
};
|
|
472
|
+
|
|
473
|
+
const vocabularyPreserveFromInput = (
|
|
474
|
+
preserve: RegradeInput['preserve']
|
|
475
|
+
): readonly VocabularyPreserveRule[] | undefined =>
|
|
476
|
+
preserve?.map((rule) => {
|
|
477
|
+
if (typeof rule === 'string') {
|
|
478
|
+
return { pattern: rule, reason: 'preserved-by-operator-input' };
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
return {
|
|
482
|
+
...(rule.disposition === undefined
|
|
483
|
+
? {}
|
|
484
|
+
: { disposition: rule.disposition }),
|
|
485
|
+
...(rule.forms === undefined ? {} : { forms: rule.forms }),
|
|
486
|
+
...(rule.paths === undefined ? {} : { paths: rule.paths }),
|
|
487
|
+
pattern: rule.pattern,
|
|
488
|
+
...(rule.reason === undefined ? {} : { reason: rule.reason }),
|
|
489
|
+
};
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
const vocabularyRegistryPlanForInput = (
|
|
493
|
+
input: RegradeInput
|
|
494
|
+
): VocabularyRegradePlan | undefined =>
|
|
495
|
+
listVocabularyRegradePlansFromRegistry().find(
|
|
496
|
+
(plan) => plan.from === input.from && plan.to === input.to
|
|
497
|
+
);
|
|
498
|
+
|
|
499
|
+
const uniqueSorted = (values: readonly string[]): readonly string[] =>
|
|
500
|
+
[...new Set(values)].toSorted((left, right) => left.localeCompare(right));
|
|
501
|
+
|
|
502
|
+
const uniqueInOrder = (values: readonly string[]): readonly string[] => [
|
|
503
|
+
...new Set(values),
|
|
504
|
+
];
|
|
505
|
+
|
|
506
|
+
const mergeScopeList = (
|
|
507
|
+
left: readonly string[] | undefined,
|
|
508
|
+
right: readonly string[] | undefined
|
|
509
|
+
): readonly string[] | undefined => {
|
|
510
|
+
const merged = uniqueInOrder([...(left ?? []), ...(right ?? [])]);
|
|
511
|
+
return merged.length === 0 ? undefined : merged;
|
|
512
|
+
};
|
|
513
|
+
|
|
514
|
+
const mergeVocabularyScope = (
|
|
515
|
+
registryScope: VocabularyRegradePlan['scope'] | undefined,
|
|
516
|
+
configScope: VocabularyRegradePlan['scope'] | undefined,
|
|
517
|
+
input: Pick<RegradeInput, 'exclude' | 'extensions' | 'include'>
|
|
518
|
+
): VocabularyRegradePlan['scope'] | undefined => {
|
|
519
|
+
const callerExclude = input.exclude ?? configScope?.exclude;
|
|
520
|
+
const callerInclude = input.include ?? configScope?.include;
|
|
521
|
+
const extensions =
|
|
522
|
+
input.extensions ?? configScope?.extensions ?? registryScope?.extensions;
|
|
523
|
+
const exclude = mergeScopeList(registryScope?.exclude, callerExclude);
|
|
524
|
+
const include = mergeScopeList(registryScope?.include, callerInclude);
|
|
525
|
+
|
|
526
|
+
if (
|
|
527
|
+
exclude === undefined &&
|
|
528
|
+
extensions === undefined &&
|
|
529
|
+
include === undefined
|
|
530
|
+
) {
|
|
531
|
+
return undefined;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
return {
|
|
535
|
+
...(exclude === undefined ? {} : { exclude }),
|
|
536
|
+
...(extensions === undefined ? {} : { extensions }),
|
|
537
|
+
...(include === undefined ? {} : { include }),
|
|
538
|
+
};
|
|
539
|
+
};
|
|
540
|
+
|
|
541
|
+
const mergeNumericRecords = (
|
|
542
|
+
left: Readonly<Record<string, number>>,
|
|
543
|
+
right: Readonly<Record<string, number>>
|
|
544
|
+
): Readonly<Record<string, number>> => {
|
|
545
|
+
const keys = uniqueSorted([...Object.keys(left), ...Object.keys(right)]);
|
|
546
|
+
return Object.fromEntries(
|
|
547
|
+
keys.map((key) => [key, Math.max(left[key] ?? 0, right[key] ?? 0)])
|
|
548
|
+
);
|
|
549
|
+
};
|
|
550
|
+
|
|
551
|
+
const sumNumericRecords = (
|
|
552
|
+
left: Readonly<Record<string, number>>,
|
|
553
|
+
right: Readonly<Record<string, number>>
|
|
554
|
+
): Readonly<Record<string, number>> => {
|
|
555
|
+
const keys = uniqueSorted([...Object.keys(left), ...Object.keys(right)]);
|
|
556
|
+
return Object.fromEntries(
|
|
557
|
+
keys.map((key) => [key, (left[key] ?? 0) + (right[key] ?? 0)])
|
|
558
|
+
);
|
|
559
|
+
};
|
|
560
|
+
|
|
561
|
+
const extensionForPath = (path: string): string => {
|
|
562
|
+
const name = path.split('/').at(-1) ?? path;
|
|
563
|
+
const dot = name.lastIndexOf('.');
|
|
564
|
+
return dot <= 0 || dot === name.length - 1 ? '<none>' : name.slice(dot);
|
|
565
|
+
};
|
|
566
|
+
|
|
567
|
+
const topLevelForPath = (path: string): string => {
|
|
568
|
+
const [segment] = path.split('/');
|
|
569
|
+
return segment === undefined || segment.length === 0 ? '.' : segment;
|
|
570
|
+
};
|
|
571
|
+
|
|
572
|
+
const countFilesBy = (
|
|
573
|
+
paths: readonly string[],
|
|
574
|
+
keyForPath: (path: string) => string
|
|
575
|
+
): Map<string, number> => {
|
|
576
|
+
const counts = new Map<string, number>();
|
|
577
|
+
for (const path of new Set(paths)) {
|
|
578
|
+
const key = keyForPath(path);
|
|
579
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
580
|
+
}
|
|
581
|
+
return counts;
|
|
582
|
+
};
|
|
583
|
+
|
|
584
|
+
const countOccurrencesBy = (
|
|
585
|
+
paths: readonly string[],
|
|
586
|
+
keyForPath: (path: string) => string
|
|
587
|
+
): Map<string, number> => {
|
|
588
|
+
const counts = new Map<string, number>();
|
|
589
|
+
for (const path of paths) {
|
|
590
|
+
const key = keyForPath(path);
|
|
591
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
592
|
+
}
|
|
593
|
+
return counts;
|
|
594
|
+
};
|
|
595
|
+
|
|
596
|
+
const sortBuckets = <T extends { readonly files: number }>(
|
|
597
|
+
left: T & { readonly key: string },
|
|
598
|
+
right: T & { readonly key: string }
|
|
599
|
+
): number => right.files - left.files || left.key.localeCompare(right.key);
|
|
600
|
+
|
|
601
|
+
const mergedDirectoryBuckets = (
|
|
602
|
+
matchedPaths: readonly string[],
|
|
603
|
+
occurrencePaths: readonly string[]
|
|
604
|
+
): readonly RegradeScanDirectoryBucket[] => {
|
|
605
|
+
const fileCounts = countFilesBy(matchedPaths, topLevelForPath);
|
|
606
|
+
const occurrenceCounts = countOccurrencesBy(occurrencePaths, topLevelForPath);
|
|
607
|
+
const buckets: (RegradeScanDirectoryBucket & { readonly key: string })[] = [];
|
|
608
|
+
for (const [path, files] of fileCounts.entries()) {
|
|
609
|
+
buckets.push(
|
|
610
|
+
occurrencePaths.length === 0
|
|
611
|
+
? { files, key: path, path }
|
|
612
|
+
: {
|
|
613
|
+
files,
|
|
614
|
+
key: path,
|
|
615
|
+
occurrences: occurrenceCounts.get(path) ?? 0,
|
|
616
|
+
path,
|
|
617
|
+
}
|
|
618
|
+
);
|
|
619
|
+
}
|
|
620
|
+
return buckets
|
|
621
|
+
.toSorted(sortBuckets)
|
|
622
|
+
.map(({ key: _key, ...bucket }) => bucket);
|
|
623
|
+
};
|
|
624
|
+
|
|
625
|
+
const mergedExtensionBuckets = (
|
|
626
|
+
matchedPaths: readonly string[],
|
|
627
|
+
occurrencePaths: readonly string[]
|
|
628
|
+
): readonly RegradeScanExtensionBucket[] => {
|
|
629
|
+
const fileCounts = countFilesBy(matchedPaths, extensionForPath);
|
|
630
|
+
const occurrenceCounts = countOccurrencesBy(
|
|
631
|
+
occurrencePaths,
|
|
632
|
+
extensionForPath
|
|
633
|
+
);
|
|
634
|
+
const buckets: (RegradeScanExtensionBucket & { readonly key: string })[] = [];
|
|
635
|
+
for (const [extension, files] of fileCounts.entries()) {
|
|
636
|
+
buckets.push(
|
|
637
|
+
occurrencePaths.length === 0
|
|
638
|
+
? { extension, files, key: extension }
|
|
639
|
+
: {
|
|
640
|
+
extension,
|
|
641
|
+
files,
|
|
642
|
+
key: extension,
|
|
643
|
+
occurrences: occurrenceCounts.get(extension) ?? 0,
|
|
644
|
+
}
|
|
645
|
+
);
|
|
646
|
+
}
|
|
647
|
+
return buckets
|
|
648
|
+
.toSorted(sortBuckets)
|
|
649
|
+
.map(({ key: _key, ...bucket }) => bucket);
|
|
650
|
+
};
|
|
651
|
+
|
|
652
|
+
const actionableEntryPaths = (
|
|
653
|
+
entries: readonly RegradeReportEntry[]
|
|
654
|
+
): readonly string[] =>
|
|
655
|
+
entries.flatMap((entry) =>
|
|
656
|
+
entry.outcome === 'rewrite' || entry.outcome === 'needs-review'
|
|
657
|
+
? [entry.path]
|
|
658
|
+
: []
|
|
659
|
+
);
|
|
660
|
+
|
|
661
|
+
const mergeApplySummary = (
|
|
662
|
+
left: RegradeApplySummary | undefined,
|
|
663
|
+
right: RegradeApplySummary | undefined
|
|
664
|
+
): RegradeApplySummary | undefined => {
|
|
665
|
+
if (left === undefined && right === undefined) {
|
|
666
|
+
return undefined;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
const leftValue = left ?? {
|
|
670
|
+
applied: 0,
|
|
671
|
+
filesChanged: 0,
|
|
672
|
+
review: 0,
|
|
673
|
+
skipped: 0,
|
|
674
|
+
unknown: 0,
|
|
675
|
+
};
|
|
676
|
+
const rightValue = right ?? {
|
|
677
|
+
applied: 0,
|
|
678
|
+
filesChanged: 0,
|
|
679
|
+
review: 0,
|
|
680
|
+
skipped: 0,
|
|
681
|
+
unknown: 0,
|
|
682
|
+
};
|
|
683
|
+
|
|
684
|
+
return {
|
|
685
|
+
applied: leftValue.applied + rightValue.applied,
|
|
686
|
+
filesChanged: leftValue.filesChanged + rightValue.filesChanged,
|
|
687
|
+
review: leftValue.review + rightValue.review,
|
|
688
|
+
skipped: Math.max(leftValue.skipped, rightValue.skipped),
|
|
689
|
+
unknown: leftValue.unknown + rightValue.unknown,
|
|
690
|
+
};
|
|
691
|
+
};
|
|
692
|
+
|
|
693
|
+
type VocabularyTransitionRunReport = NonNullable<
|
|
694
|
+
RegradeReport['run']
|
|
695
|
+
>['report'];
|
|
696
|
+
|
|
697
|
+
const transitionRunReportForRegradeReport = (
|
|
698
|
+
report: RegradeReport
|
|
699
|
+
): VocabularyTransitionRunReport => {
|
|
700
|
+
const applied = report.apply?.applied ?? 0;
|
|
701
|
+
const filesChanged = report.apply?.filesChanged ?? 0;
|
|
702
|
+
const modified = report.apply === undefined ? report.rewritten : 0;
|
|
703
|
+
const deferred = report.review;
|
|
704
|
+
const open =
|
|
705
|
+
report.apply === undefined
|
|
706
|
+
? report.rewritten + report.review
|
|
707
|
+
: report.review;
|
|
708
|
+
const remainingByDisposition =
|
|
709
|
+
open === 0 ? {} : { 'code-context-out-of-engine': open };
|
|
710
|
+
const reasons = [
|
|
711
|
+
...(report.apply === undefined && report.rewritten > 0
|
|
712
|
+
? ['safe-modifications-not-yet-applied']
|
|
713
|
+
: []),
|
|
714
|
+
...(report.review > 0 ? ['deferred-forms-or-occurrences'] : []),
|
|
715
|
+
];
|
|
716
|
+
|
|
717
|
+
return {
|
|
718
|
+
applied,
|
|
719
|
+
deferred,
|
|
720
|
+
dispositions: remainingByDisposition,
|
|
721
|
+
filesChanged,
|
|
722
|
+
gate: {
|
|
723
|
+
reasons,
|
|
724
|
+
remaining: open,
|
|
725
|
+
remainingByDisposition,
|
|
726
|
+
status: open === 0 ? 'green' : 'open',
|
|
727
|
+
},
|
|
728
|
+
modified,
|
|
729
|
+
open,
|
|
730
|
+
skipped: report.skipped,
|
|
731
|
+
};
|
|
732
|
+
};
|
|
733
|
+
|
|
734
|
+
const mergeTransitionRunReportWithSymbol = (
|
|
735
|
+
vocabularyReport: VocabularyTransitionRunReport,
|
|
736
|
+
symbolReport: RegradeReport
|
|
737
|
+
): VocabularyTransitionRunReport => {
|
|
738
|
+
const symbolRunReport = transitionRunReportForRegradeReport(symbolReport);
|
|
739
|
+
const modified = vocabularyReport.modified + symbolRunReport.modified;
|
|
740
|
+
const open = vocabularyReport.open + symbolRunReport.open;
|
|
741
|
+
const dispositions = sumNumericRecords(
|
|
742
|
+
vocabularyReport.dispositions,
|
|
743
|
+
symbolRunReport.dispositions
|
|
744
|
+
);
|
|
745
|
+
const remainingByDisposition = sumNumericRecords(
|
|
746
|
+
vocabularyReport.gate.remainingByDisposition,
|
|
747
|
+
symbolRunReport.gate.remainingByDisposition
|
|
748
|
+
);
|
|
749
|
+
const reasons = uniqueSorted([
|
|
750
|
+
...vocabularyReport.gate.reasons,
|
|
751
|
+
...symbolRunReport.gate.reasons,
|
|
752
|
+
]);
|
|
753
|
+
|
|
754
|
+
return {
|
|
755
|
+
applied: vocabularyReport.applied + symbolRunReport.applied,
|
|
756
|
+
deferred: vocabularyReport.deferred + symbolRunReport.deferred,
|
|
757
|
+
dispositions,
|
|
758
|
+
filesChanged: vocabularyReport.filesChanged + symbolRunReport.filesChanged,
|
|
759
|
+
gate: {
|
|
760
|
+
reasons,
|
|
761
|
+
remaining: open,
|
|
762
|
+
remainingByDisposition,
|
|
763
|
+
status: open === 0 ? 'green' : 'open',
|
|
764
|
+
},
|
|
765
|
+
modified,
|
|
766
|
+
open,
|
|
767
|
+
skipped: vocabularyReport.skipped + symbolRunReport.skipped,
|
|
768
|
+
};
|
|
769
|
+
};
|
|
770
|
+
|
|
771
|
+
const mergeRegradeReports = (
|
|
772
|
+
vocabularyReport: RegradeReport,
|
|
773
|
+
symbolReport: RegradeReport
|
|
774
|
+
): RegradeReport => {
|
|
775
|
+
const entries = [
|
|
776
|
+
...vocabularyReport.entries,
|
|
777
|
+
...symbolReport.entries,
|
|
778
|
+
].toSorted(
|
|
779
|
+
(left, right) =>
|
|
780
|
+
left.path.localeCompare(right.path) ||
|
|
781
|
+
(left.classId ?? '').localeCompare(right.classId ?? '')
|
|
782
|
+
);
|
|
783
|
+
const matchedPaths = actionableEntryPaths(entries);
|
|
784
|
+
const rewritten = new Set(
|
|
785
|
+
entries
|
|
786
|
+
.filter((entry) => entry.outcome === 'rewrite')
|
|
787
|
+
.map((entry) => entry.path)
|
|
788
|
+
).size;
|
|
789
|
+
const review = new Set(
|
|
790
|
+
entries
|
|
791
|
+
.filter((entry) => entry.outcome === 'needs-review')
|
|
792
|
+
.map((entry) => entry.path)
|
|
793
|
+
).size;
|
|
794
|
+
const matched = new Set(matchedPaths).size;
|
|
795
|
+
const occurrencePaths =
|
|
796
|
+
vocabularyReport.run?.ledger.occurrences.map(
|
|
797
|
+
(occurrence) => occurrence.path
|
|
798
|
+
) ?? [];
|
|
799
|
+
const skippedByReason = mergeNumericRecords(
|
|
800
|
+
vocabularyReport.skipsByReason,
|
|
801
|
+
symbolReport.skipsByReason
|
|
802
|
+
);
|
|
803
|
+
const apply = mergeApplySummary(vocabularyReport.apply, symbolReport.apply);
|
|
804
|
+
const scanned = vocabularyReport.scanned + symbolReport.scanned;
|
|
805
|
+
const run =
|
|
806
|
+
vocabularyReport.run === undefined
|
|
807
|
+
? undefined
|
|
808
|
+
: {
|
|
809
|
+
...vocabularyReport.run,
|
|
810
|
+
report: mergeTransitionRunReportWithSymbol(
|
|
811
|
+
vocabularyReport.run.report,
|
|
812
|
+
symbolReport
|
|
813
|
+
),
|
|
814
|
+
};
|
|
815
|
+
|
|
816
|
+
return {
|
|
817
|
+
...vocabularyReport,
|
|
818
|
+
...(apply === undefined ? {} : { apply }),
|
|
819
|
+
entries,
|
|
820
|
+
matched,
|
|
821
|
+
review,
|
|
822
|
+
rewritten,
|
|
823
|
+
scan: {
|
|
824
|
+
byDirectory: mergedDirectoryBuckets(matchedPaths, occurrencePaths),
|
|
825
|
+
byExtension: mergedExtensionBuckets(matchedPaths, occurrencePaths),
|
|
826
|
+
files: {
|
|
827
|
+
matched: new Set(matchedPaths).size,
|
|
828
|
+
scanned,
|
|
829
|
+
skipped: Math.max(vocabularyReport.skipped, symbolReport.skipped),
|
|
830
|
+
},
|
|
831
|
+
skippedByReason,
|
|
832
|
+
},
|
|
833
|
+
...(run === undefined ? {} : { run }),
|
|
834
|
+
scanned,
|
|
835
|
+
selectedClassIds: uniqueSorted([
|
|
836
|
+
...vocabularyReport.selectedClassIds,
|
|
837
|
+
...symbolReport.selectedClassIds,
|
|
838
|
+
]),
|
|
839
|
+
skipped: Math.max(vocabularyReport.skipped, symbolReport.skipped),
|
|
840
|
+
skipsByReason: skippedByReason,
|
|
841
|
+
unknownClassIds: uniqueSorted([
|
|
842
|
+
...vocabularyReport.unknownClassIds,
|
|
843
|
+
...symbolReport.unknownClassIds,
|
|
844
|
+
]),
|
|
845
|
+
};
|
|
846
|
+
};
|
|
847
|
+
|
|
848
|
+
const vocabularySymbolCollection = (
|
|
849
|
+
scope: VocabularyRegradePlan['scope'] | undefined
|
|
850
|
+
): RegradeCollectionScope | null | undefined => {
|
|
851
|
+
const exclude = scope?.exclude;
|
|
852
|
+
const extensions = scope?.extensions;
|
|
853
|
+
const include = scope?.include;
|
|
854
|
+
const explicitExtensions = extensions !== undefined;
|
|
855
|
+
const codeExtensions =
|
|
856
|
+
extensions === undefined
|
|
857
|
+
? symbolSourceExtensions
|
|
858
|
+
: uniqueSorted(
|
|
859
|
+
extensions
|
|
860
|
+
.map(normalizeExtension)
|
|
861
|
+
.filter((extension) => symbolSourceExtensions.includes(extension))
|
|
862
|
+
);
|
|
863
|
+
if (explicitExtensions && codeExtensions.length === 0) {
|
|
864
|
+
return null;
|
|
865
|
+
}
|
|
866
|
+
if (
|
|
867
|
+
exclude === undefined &&
|
|
868
|
+
codeExtensions.length === 0 &&
|
|
869
|
+
include === undefined
|
|
870
|
+
) {
|
|
871
|
+
return undefined;
|
|
872
|
+
}
|
|
873
|
+
return {
|
|
874
|
+
...(exclude === undefined ? {} : { exclude }),
|
|
875
|
+
...(codeExtensions.length === 0 ? {} : { extensions: codeExtensions }),
|
|
876
|
+
...(include === undefined ? {} : { include }),
|
|
877
|
+
};
|
|
878
|
+
};
|
|
879
|
+
|
|
880
|
+
const vocabularyProseScope = (
|
|
881
|
+
scope: VocabularyRegradePlan['scope'] | undefined
|
|
882
|
+
): NonNullable<VocabularyRegradePlan['scope']> | null => {
|
|
883
|
+
const explicitExtensions = scope?.extensions !== undefined;
|
|
884
|
+
const extensions =
|
|
885
|
+
scope?.extensions === undefined
|
|
886
|
+
? vocabularyProseExtensions
|
|
887
|
+
: uniqueSorted(
|
|
888
|
+
scope.extensions
|
|
889
|
+
.map(normalizeExtension)
|
|
890
|
+
.filter((extension) =>
|
|
891
|
+
vocabularyProseExtensions.includes(extension)
|
|
892
|
+
)
|
|
893
|
+
);
|
|
894
|
+
|
|
895
|
+
if (explicitExtensions && extensions.length === 0) {
|
|
896
|
+
return null;
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
return {
|
|
900
|
+
...(scope?.exclude === undefined ? {} : { exclude: scope.exclude }),
|
|
901
|
+
extensions,
|
|
902
|
+
...(scope?.ignoredDirectories === undefined
|
|
903
|
+
? {}
|
|
904
|
+
: { ignoredDirectories: scope.ignoredDirectories }),
|
|
905
|
+
...(scope?.include === undefined ? {} : { include: scope.include }),
|
|
906
|
+
};
|
|
907
|
+
};
|
|
908
|
+
|
|
909
|
+
const vocabularyProsePlan = (
|
|
910
|
+
plan: VocabularyRegradePlan
|
|
911
|
+
): VocabularyRegradePlan | null => {
|
|
912
|
+
const scope = vocabularyProseScope(plan.scope);
|
|
913
|
+
if (scope === null) {
|
|
914
|
+
return null;
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
return { ...plan, scope };
|
|
918
|
+
};
|
|
919
|
+
|
|
920
|
+
const mergeVocabularyOverrides = (
|
|
921
|
+
registryPlan: VocabularyRegradePlan | undefined,
|
|
922
|
+
input: z.output<typeof regradeInputSchema>
|
|
923
|
+
): VocabularyRegradePlan['overrides'] | undefined => {
|
|
924
|
+
const overrides = {
|
|
925
|
+
...registryPlan?.overrides,
|
|
926
|
+
...input.overrides,
|
|
927
|
+
};
|
|
928
|
+
return Object.keys(overrides).length === 0 ? undefined : overrides;
|
|
929
|
+
};
|
|
930
|
+
|
|
931
|
+
const mergeVocabularyPreserveRules = (
|
|
932
|
+
registryPlan: VocabularyRegradePlan | undefined,
|
|
933
|
+
preserve: readonly VocabularyPreserveRule[] | undefined
|
|
934
|
+
): readonly VocabularyPreserveRule[] | undefined => {
|
|
935
|
+
const rules = [...(registryPlan?.preserve ?? []), ...(preserve ?? [])];
|
|
936
|
+
return rules.length === 0 ? undefined : rules;
|
|
937
|
+
};
|
|
938
|
+
|
|
939
|
+
const vocabularyIntentForInput = (
|
|
940
|
+
input: z.output<typeof regradeInputSchema>,
|
|
941
|
+
registryPlan: VocabularyRegradePlan | undefined
|
|
942
|
+
): string | undefined => {
|
|
943
|
+
if (input.intent !== undefined) {
|
|
944
|
+
return input.intent;
|
|
945
|
+
}
|
|
946
|
+
return registryPlan?.intent;
|
|
947
|
+
};
|
|
948
|
+
|
|
949
|
+
const buildVocabularyPlan = (
|
|
950
|
+
input: RegradeInput,
|
|
951
|
+
configScope?: VocabularyRegradePlan['scope']
|
|
952
|
+
): TrailsResult<VocabularyRegradePlan, ValidationError> => {
|
|
953
|
+
if (input.from === undefined || input.to === undefined) {
|
|
954
|
+
return Result.err(
|
|
955
|
+
new ValidationError('A vocabulary regrade requires both `from` and `to`.')
|
|
956
|
+
);
|
|
957
|
+
}
|
|
958
|
+
if (input.classIds !== undefined) {
|
|
959
|
+
return Result.err(
|
|
960
|
+
new ValidationError(
|
|
961
|
+
'`classIds` selects class-mode Regrade and cannot be combined with vocabulary-regrade `from`/`to`.'
|
|
962
|
+
)
|
|
963
|
+
);
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
const preserve = vocabularyPreserveFromInput(input.preserve);
|
|
967
|
+
const registryPlan = vocabularyRegistryPlanForInput(input);
|
|
968
|
+
const intent = vocabularyIntentForInput(input, registryPlan);
|
|
969
|
+
const overrides = mergeVocabularyOverrides(registryPlan, input);
|
|
970
|
+
const preserveRules = mergeVocabularyPreserveRules(registryPlan, preserve);
|
|
971
|
+
const scope = mergeVocabularyScope(registryPlan?.scope, configScope, input);
|
|
972
|
+
|
|
973
|
+
return Result.ok({
|
|
974
|
+
...(registryPlan?.caseSensitive === undefined
|
|
975
|
+
? {}
|
|
976
|
+
: { caseSensitive: registryPlan.caseSensitive }),
|
|
977
|
+
...(registryPlan?.deferForms === undefined
|
|
978
|
+
? {}
|
|
979
|
+
: { deferForms: registryPlan.deferForms }),
|
|
980
|
+
from: input.from,
|
|
981
|
+
id: registryPlan?.id ?? `vocabulary:${input.from}->${input.to}`,
|
|
982
|
+
kind: 'vocabulary',
|
|
983
|
+
...(intent === undefined ? {} : { intent }),
|
|
984
|
+
...(overrides === undefined ? {} : { overrides }),
|
|
985
|
+
...(preserveRules === undefined ? {} : { preserve: preserveRules }),
|
|
986
|
+
...(scope === undefined ? {} : { scope }),
|
|
987
|
+
to: input.to,
|
|
988
|
+
});
|
|
989
|
+
};
|
|
990
|
+
|
|
991
|
+
const regradeRootNotFound = (rootDir: string) =>
|
|
992
|
+
Result.err(
|
|
993
|
+
new NotFoundError(
|
|
994
|
+
`Regrade root "${rootDir}" could not be read as a directory.`
|
|
995
|
+
)
|
|
996
|
+
);
|
|
997
|
+
|
|
998
|
+
const regradeNoEngineForScope = () =>
|
|
999
|
+
Result.err(
|
|
1000
|
+
new ValidationError(
|
|
1001
|
+
'Vocabulary regrade has no prose or governed symbol engine for the selected extension scope.'
|
|
1002
|
+
)
|
|
1003
|
+
);
|
|
1004
|
+
|
|
1005
|
+
const regradeRootIsReadable = (rootDir: string): boolean => {
|
|
1006
|
+
try {
|
|
1007
|
+
readdirSync(rootDir, { withFileTypes: true });
|
|
1008
|
+
return true;
|
|
1009
|
+
} catch {
|
|
1010
|
+
return false;
|
|
1011
|
+
}
|
|
1012
|
+
};
|
|
1013
|
+
|
|
1014
|
+
const validateRegradeReport = (
|
|
1015
|
+
report: RegradeReport
|
|
1016
|
+
): TrailsResult<RegradeReport, Error> => {
|
|
1017
|
+
const validated = validateOutput(regradeReportOutput, report);
|
|
1018
|
+
if (validated.isErr()) {
|
|
1019
|
+
return validated;
|
|
1020
|
+
}
|
|
1021
|
+
return Result.ok(report);
|
|
1022
|
+
};
|
|
1023
|
+
|
|
1024
|
+
const reportWithVocabularyTransitionRun = (params: {
|
|
1025
|
+
readonly plan: VocabularyRegradePlan;
|
|
1026
|
+
readonly preserveInventory: readonly VocabularyPreserveInventoryEntry[];
|
|
1027
|
+
readonly report: RegradeReport;
|
|
1028
|
+
}): RegradeReport => {
|
|
1029
|
+
const preserveInventory =
|
|
1030
|
+
params.preserveInventory.length === 0
|
|
1031
|
+
? params.report.run?.preserveInventory
|
|
1032
|
+
: params.preserveInventory;
|
|
1033
|
+
const run = params.report.run ?? {
|
|
1034
|
+
ledger: { cycle: 1, forms: {}, occurrences: [] },
|
|
1035
|
+
plan: params.plan,
|
|
1036
|
+
report: transitionRunReportForRegradeReport(params.report),
|
|
1037
|
+
};
|
|
1038
|
+
|
|
1039
|
+
return {
|
|
1040
|
+
...params.report,
|
|
1041
|
+
run: {
|
|
1042
|
+
...run,
|
|
1043
|
+
plan: params.plan,
|
|
1044
|
+
...(preserveInventory === undefined ? {} : { preserveInventory }),
|
|
1045
|
+
report:
|
|
1046
|
+
params.report.run === undefined
|
|
1047
|
+
? transitionRunReportForRegradeReport(params.report)
|
|
1048
|
+
: params.report.run.report,
|
|
1049
|
+
},
|
|
1050
|
+
};
|
|
1051
|
+
};
|
|
1052
|
+
|
|
1053
|
+
const runGovernedSymbolRegrade = (params: {
|
|
1054
|
+
readonly apply: boolean;
|
|
1055
|
+
readonly includeEntries: RegradeInput['includeEntries'];
|
|
1056
|
+
readonly plan: VocabularyRegradePlan;
|
|
1057
|
+
readonly preserveInventory: readonly VocabularyPreserveInventoryEntry[];
|
|
1058
|
+
readonly rootDir: string;
|
|
1059
|
+
}): TrailsResult<RegradeReport | null, Error> => {
|
|
1060
|
+
const transition = vocabularyRegradeTransitionForInput(
|
|
1061
|
+
params.plan.from,
|
|
1062
|
+
params.plan.to
|
|
1063
|
+
);
|
|
1064
|
+
if (transition === undefined) {
|
|
1065
|
+
return Result.ok(null);
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
const symbolCollection = vocabularySymbolCollection(params.plan.scope);
|
|
1069
|
+
if (symbolCollection === null) {
|
|
1070
|
+
return Result.ok(null);
|
|
1071
|
+
}
|
|
1072
|
+
return runRegrade({
|
|
1073
|
+
apply: params.apply,
|
|
1074
|
+
classes: createGovernedAstIdentifierRenameClasses(
|
|
1075
|
+
{
|
|
1076
|
+
...transition,
|
|
1077
|
+
symbolRenames: transition.symbolRenames,
|
|
1078
|
+
},
|
|
1079
|
+
{
|
|
1080
|
+
shouldPreserve: (occurrence) =>
|
|
1081
|
+
symbolOccurrenceIsPreserved(params.plan.preserve, {
|
|
1082
|
+
end: occurrence.end,
|
|
1083
|
+
form: occurrence.from,
|
|
1084
|
+
path: occurrence.path,
|
|
1085
|
+
source: occurrence.source,
|
|
1086
|
+
start: occurrence.start,
|
|
1087
|
+
}) ||
|
|
1088
|
+
symbolOccurrenceIsPreserved(params.preserveInventory, {
|
|
1089
|
+
end: occurrence.end,
|
|
1090
|
+
form: occurrence.from,
|
|
1091
|
+
path: occurrence.path,
|
|
1092
|
+
source: occurrence.source,
|
|
1093
|
+
start: occurrence.start,
|
|
1094
|
+
}),
|
|
1095
|
+
}
|
|
1096
|
+
),
|
|
1097
|
+
...(symbolCollection === undefined ? {} : { collection: symbolCollection }),
|
|
1098
|
+
includeEntries: params.includeEntries,
|
|
1099
|
+
root: params.rootDir,
|
|
1100
|
+
});
|
|
1101
|
+
};
|
|
1102
|
+
|
|
1103
|
+
const vocabularyRecordPathForInput = (
|
|
1104
|
+
rootDir: string,
|
|
1105
|
+
recordPath: string
|
|
1106
|
+
): string => (isAbsolute(recordPath) ? recordPath : join(rootDir, recordPath));
|
|
1107
|
+
|
|
1108
|
+
const currentCommitSha = (rootDir: string): string | undefined => {
|
|
1109
|
+
try {
|
|
1110
|
+
return execFileSync(
|
|
1111
|
+
'git',
|
|
1112
|
+
['-C', rootDir, 'rev-parse', '--short=7', 'HEAD'],
|
|
1113
|
+
{
|
|
1114
|
+
encoding: 'utf8',
|
|
1115
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
1116
|
+
}
|
|
1117
|
+
).trim();
|
|
1118
|
+
} catch {
|
|
1119
|
+
return undefined;
|
|
1120
|
+
}
|
|
1121
|
+
};
|
|
1122
|
+
|
|
1123
|
+
const vocabularyRecordEnvironment = (
|
|
1124
|
+
rootDir: string
|
|
1125
|
+
): { readonly commitSha?: string; readonly root: string } => {
|
|
1126
|
+
const commitSha = currentCommitSha(rootDir);
|
|
1127
|
+
return {
|
|
1128
|
+
...(commitSha === undefined ? {} : { commitSha }),
|
|
1129
|
+
root: rootDir,
|
|
1130
|
+
};
|
|
1131
|
+
};
|
|
1132
|
+
|
|
1133
|
+
const pendingExpansionCandidateCount = (plan: RegradePlanArtifact): number =>
|
|
1134
|
+
plan.expansion?.candidates.filter(
|
|
1135
|
+
(candidate) => candidate.status === 'pending'
|
|
1136
|
+
).length ?? 0;
|
|
1137
|
+
|
|
1138
|
+
const reportWithPlanSummary = (
|
|
1139
|
+
report: RegradeReport,
|
|
1140
|
+
plan: RegradePlanArtifact,
|
|
1141
|
+
status: 'active' | 'stale'
|
|
1142
|
+
): RegradeReport => ({
|
|
1143
|
+
...report,
|
|
1144
|
+
plan: {
|
|
1145
|
+
...(pendingExpansionCandidateCount(plan) === 0
|
|
1146
|
+
? {}
|
|
1147
|
+
: { expansionPending: pendingExpansionCandidateCount(plan) }),
|
|
1148
|
+
path: plan.path,
|
|
1149
|
+
schemaVersion: plan.schemaVersion,
|
|
1150
|
+
status,
|
|
1151
|
+
},
|
|
1152
|
+
});
|
|
1153
|
+
|
|
1154
|
+
const reportWithHistorySummary = (
|
|
1155
|
+
report: RegradeReport,
|
|
1156
|
+
params: RegradeHistorySummary
|
|
1157
|
+
): RegradeReport => ({
|
|
1158
|
+
...report,
|
|
1159
|
+
history: {
|
|
1160
|
+
path: params.path,
|
|
1161
|
+
schemaVersion: params.schemaVersion,
|
|
1162
|
+
status: params.status,
|
|
1163
|
+
},
|
|
1164
|
+
});
|
|
1165
|
+
|
|
1166
|
+
const authoredPlanFieldKeys = [
|
|
1167
|
+
'caseSensitive',
|
|
1168
|
+
'deferForms',
|
|
1169
|
+
'id',
|
|
1170
|
+
'intent',
|
|
1171
|
+
'overrides',
|
|
1172
|
+
'preserve',
|
|
1173
|
+
'scope',
|
|
1174
|
+
] as const;
|
|
1175
|
+
|
|
1176
|
+
const isAuthoredPlanField = (
|
|
1177
|
+
input: RegradePlanInput,
|
|
1178
|
+
key: (typeof authoredPlanFieldKeys)[number]
|
|
1179
|
+
): boolean => {
|
|
1180
|
+
switch (key) {
|
|
1181
|
+
case 'intent':
|
|
1182
|
+
case 'overrides':
|
|
1183
|
+
case 'preserve': {
|
|
1184
|
+
return input[key] !== undefined;
|
|
1185
|
+
}
|
|
1186
|
+
case 'scope': {
|
|
1187
|
+
return (
|
|
1188
|
+
input.exclude !== undefined ||
|
|
1189
|
+
input.extensions !== undefined ||
|
|
1190
|
+
input.include !== undefined
|
|
1191
|
+
);
|
|
1192
|
+
}
|
|
1193
|
+
default: {
|
|
1194
|
+
return false;
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
};
|
|
1198
|
+
|
|
1199
|
+
const regradePlanProvenanceForInput = (
|
|
1200
|
+
input: RegradePlanInput,
|
|
1201
|
+
plan: VocabularyRegradePlan
|
|
1202
|
+
): RegradePlanArtifact['provenance'] => {
|
|
1203
|
+
const fields: Record<string, 'authored' | 'derived'> = {
|
|
1204
|
+
from: 'authored',
|
|
1205
|
+
kind: 'derived',
|
|
1206
|
+
to: 'authored',
|
|
1207
|
+
};
|
|
1208
|
+
|
|
1209
|
+
for (const key of [
|
|
1210
|
+
'caseSensitive',
|
|
1211
|
+
'deferForms',
|
|
1212
|
+
'id',
|
|
1213
|
+
'intent',
|
|
1214
|
+
'overrides',
|
|
1215
|
+
'preserve',
|
|
1216
|
+
'scope',
|
|
1217
|
+
] as const) {
|
|
1218
|
+
if (plan[key] !== undefined) {
|
|
1219
|
+
fields[key] = isAuthoredPlanField(input, key) ? 'authored' : 'derived';
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
return { fields };
|
|
1224
|
+
};
|
|
1225
|
+
|
|
1226
|
+
const mergeAuthoredPlanFields = (
|
|
1227
|
+
current: VocabularyRegradePlanArtifact,
|
|
1228
|
+
plan: VocabularyRegradePlan
|
|
1229
|
+
): VocabularyRegradePlan => {
|
|
1230
|
+
const merged: Record<string, unknown> = { ...plan };
|
|
1231
|
+
for (const key of authoredPlanFieldKeys) {
|
|
1232
|
+
if (
|
|
1233
|
+
current.provenance.fields[key] === 'authored' &&
|
|
1234
|
+
current.plan[key] !== undefined
|
|
1235
|
+
) {
|
|
1236
|
+
Object.assign(merged, { [key]: current.plan[key] });
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
return vocabularyRegradePlanSchema.parse(merged) as VocabularyRegradePlan;
|
|
1240
|
+
};
|
|
1241
|
+
|
|
1242
|
+
const preserveAuthoredPlanProvenance = (
|
|
1243
|
+
current: VocabularyRegradePlanArtifact,
|
|
1244
|
+
provenance: RegradePlanArtifact['provenance']
|
|
1245
|
+
): RegradePlanArtifact['provenance'] => {
|
|
1246
|
+
const fields = { ...provenance.fields };
|
|
1247
|
+
for (const key of authoredPlanFieldKeys) {
|
|
1248
|
+
if (
|
|
1249
|
+
current.provenance.fields[key] === 'authored' &&
|
|
1250
|
+
current.plan[key] !== undefined
|
|
1251
|
+
) {
|
|
1252
|
+
fields[key] = 'authored';
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
return { fields };
|
|
1256
|
+
};
|
|
1257
|
+
|
|
1258
|
+
const buildRegradePlanArtifact = (params: {
|
|
1259
|
+
readonly expansion?: RegradePlanArtifact['expansion'];
|
|
1260
|
+
readonly input: RegradePlanInput;
|
|
1261
|
+
readonly plan: VocabularyRegradePlan;
|
|
1262
|
+
readonly report: RegradeReport;
|
|
1263
|
+
readonly rootDir: string;
|
|
1264
|
+
readonly transitionId?: string | undefined;
|
|
1265
|
+
}): RegradePlanArtifact => {
|
|
1266
|
+
const absolutePath = regradePlanPathForPlan(params.rootDir, params.plan);
|
|
1267
|
+
return {
|
|
1268
|
+
...(params.expansion === undefined ? {} : { expansion: params.expansion }),
|
|
1269
|
+
kind: 'regrade-plan',
|
|
1270
|
+
path: rootRelativePath(params.rootDir, absolutePath),
|
|
1271
|
+
plan: params.plan,
|
|
1272
|
+
provenance: regradePlanProvenanceForInput(params.input, params.plan),
|
|
1273
|
+
schemaVersion: REGRADE_PLAN_SCHEMA_VERSION,
|
|
1274
|
+
sourceHash: regradeSourceHash(params.report),
|
|
1275
|
+
...(params.transitionId === undefined
|
|
1276
|
+
? {}
|
|
1277
|
+
: { transitionId: params.transitionId }),
|
|
1278
|
+
};
|
|
1279
|
+
};
|
|
1280
|
+
|
|
1281
|
+
const writeRegradePlanArtifact = (
|
|
1282
|
+
rootDir: string,
|
|
1283
|
+
artifact: RegradePlanArtifact
|
|
1284
|
+
): TrailsResult<RegradePlanArtifact, InternalError | ValidationError> => {
|
|
1285
|
+
const parsed = regradePlanArtifactSchema.safeParse(artifact);
|
|
1286
|
+
if (!parsed.success) {
|
|
1287
|
+
return Result.err(
|
|
1288
|
+
new ValidationError('Invalid Regrade plan artifact.', {
|
|
1289
|
+
context: { issues: parsed.error.issues },
|
|
1290
|
+
})
|
|
1291
|
+
);
|
|
1292
|
+
}
|
|
1293
|
+
const absolutePath = join(rootDir, artifact.path);
|
|
1294
|
+
try {
|
|
1295
|
+
mkdirSync(dirname(absolutePath), { recursive: true });
|
|
1296
|
+
writeFileSync(absolutePath, `${JSON.stringify(parsed.data, null, 2)}\n`);
|
|
1297
|
+
} catch (error) {
|
|
1298
|
+
return Result.err(
|
|
1299
|
+
new InternalError('Failed to write Regrade plan artifact.', {
|
|
1300
|
+
...(error instanceof Error ? { cause: error } : {}),
|
|
1301
|
+
context: { path: artifact.path },
|
|
1302
|
+
})
|
|
1303
|
+
);
|
|
1304
|
+
}
|
|
1305
|
+
return Result.ok(parsed.data as unknown as RegradePlanArtifact);
|
|
1306
|
+
};
|
|
1307
|
+
|
|
1308
|
+
const validateRegradePlanArtifact = (
|
|
1309
|
+
artifact: RegradePlanArtifact
|
|
1310
|
+
): TrailsResult<RegradePlanArtifact, ValidationError> => {
|
|
1311
|
+
const parsed = regradePlanArtifactSchema.safeParse(artifact);
|
|
1312
|
+
if (!parsed.success) {
|
|
1313
|
+
return Result.err(
|
|
1314
|
+
new ValidationError('Invalid Regrade plan artifact.', {
|
|
1315
|
+
context: { issues: parsed.error.issues },
|
|
1316
|
+
})
|
|
1317
|
+
);
|
|
1318
|
+
}
|
|
1319
|
+
return Result.ok(parsed.data as unknown as RegradePlanArtifact);
|
|
1320
|
+
};
|
|
1321
|
+
|
|
1322
|
+
const readRegradePlanArtifact = (
|
|
1323
|
+
path: string
|
|
1324
|
+
): TrailsResult<RegradePlanArtifact, InternalError | ValidationError> => {
|
|
1325
|
+
if (!existsSync(path)) {
|
|
1326
|
+
return Result.err(new ValidationError(`Regrade plan "${path}" not found.`));
|
|
1327
|
+
}
|
|
1328
|
+
let parsedJson: unknown;
|
|
1329
|
+
try {
|
|
1330
|
+
parsedJson = JSON.parse(readFileSync(path, 'utf8'));
|
|
1331
|
+
} catch (error) {
|
|
1332
|
+
return Result.err(
|
|
1333
|
+
new InternalError('Failed to read Regrade plan artifact.', {
|
|
1334
|
+
...(error instanceof Error ? { cause: error } : {}),
|
|
1335
|
+
context: { path },
|
|
1336
|
+
})
|
|
1337
|
+
);
|
|
1338
|
+
}
|
|
1339
|
+
const parsed = regradePlanArtifactSchema.safeParse(parsedJson);
|
|
1340
|
+
if (!parsed.success) {
|
|
1341
|
+
return Result.err(
|
|
1342
|
+
new ValidationError('Invalid Regrade plan artifact.', {
|
|
1343
|
+
context: { issues: parsed.error.issues, path },
|
|
1344
|
+
})
|
|
1345
|
+
);
|
|
1346
|
+
}
|
|
1347
|
+
return Result.ok(parsed.data as unknown as RegradePlanArtifact);
|
|
1348
|
+
};
|
|
1349
|
+
|
|
1350
|
+
/**
|
|
1351
|
+
* Transition identity is not an authored plan field — it follows the
|
|
1352
|
+
* transition. Plan re-derivation (including `--fresh`) carries it forward
|
|
1353
|
+
* from the existing active plan of the same kind so a subsequent apply
|
|
1354
|
+
* appends to the same consolidated history spine instead of forking it.
|
|
1355
|
+
*/
|
|
1356
|
+
const priorTransitionId = (
|
|
1357
|
+
currentPath: string,
|
|
1358
|
+
kind: RegradePlanBody['kind']
|
|
1359
|
+
): string | undefined => {
|
|
1360
|
+
if (!existsSync(currentPath)) {
|
|
1361
|
+
return undefined;
|
|
1362
|
+
}
|
|
1363
|
+
const existing = readRegradePlanArtifact(currentPath);
|
|
1364
|
+
if (existing.isErr() || existing.value.plan.kind !== kind) {
|
|
1365
|
+
return undefined;
|
|
1366
|
+
}
|
|
1367
|
+
return existing.value.transitionId;
|
|
1368
|
+
};
|
|
1369
|
+
|
|
1370
|
+
const hasPathSeparator = (value: string): boolean =>
|
|
1371
|
+
value.includes('/') || value.includes('\\');
|
|
1372
|
+
|
|
1373
|
+
const isPlanPathReference = (value: string): boolean =>
|
|
1374
|
+
hasPathSeparator(value) ||
|
|
1375
|
+
value.startsWith('.') ||
|
|
1376
|
+
value.startsWith('~') ||
|
|
1377
|
+
isAbsolute(value);
|
|
1378
|
+
|
|
1379
|
+
const collectActiveRegradePlanPaths = (rootDir: string): string[] => {
|
|
1380
|
+
const results: string[] = [];
|
|
1381
|
+
const skipDirectories = new Set([
|
|
1382
|
+
'.git',
|
|
1383
|
+
'.next',
|
|
1384
|
+
'.turbo',
|
|
1385
|
+
'dist',
|
|
1386
|
+
'node_modules',
|
|
1387
|
+
]);
|
|
1388
|
+
|
|
1389
|
+
const visit = (dir: string): void => {
|
|
1390
|
+
let entries: Dirent[] | undefined;
|
|
1391
|
+
try {
|
|
1392
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
1393
|
+
} catch {
|
|
1394
|
+
return;
|
|
1395
|
+
}
|
|
1396
|
+
if (entries === undefined) {
|
|
1397
|
+
return;
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
if (
|
|
1401
|
+
entries.some((entry) => entry.isDirectory() && entry.name === '.trails')
|
|
1402
|
+
) {
|
|
1403
|
+
const regradeDir = join(dir, '.trails', 'regrade');
|
|
1404
|
+
try {
|
|
1405
|
+
for (const entry of readdirSync(regradeDir, { withFileTypes: true })) {
|
|
1406
|
+
if (entry.isFile() && entry.name.endsWith('.json')) {
|
|
1407
|
+
results.push(join(regradeDir, entry.name));
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
} catch {
|
|
1411
|
+
// Not every `.trails` directory has Regrade plans.
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
for (const entry of entries) {
|
|
1416
|
+
if (!entry.isDirectory() || skipDirectories.has(entry.name)) {
|
|
1417
|
+
continue;
|
|
1418
|
+
}
|
|
1419
|
+
visit(join(dir, entry.name));
|
|
1420
|
+
}
|
|
1421
|
+
};
|
|
1422
|
+
|
|
1423
|
+
visit(rootDir);
|
|
1424
|
+
return results.toSorted((left, right) => left.localeCompare(right));
|
|
1425
|
+
};
|
|
1426
|
+
|
|
1427
|
+
const resolveRegradePlanPath = (
|
|
1428
|
+
rootDir: string,
|
|
1429
|
+
planRef?: string | undefined
|
|
1430
|
+
): TrailsResult<string, ValidationError> => {
|
|
1431
|
+
if (planRef !== undefined) {
|
|
1432
|
+
if (isPlanPathReference(planRef)) {
|
|
1433
|
+
const normalized = planRef.startsWith('~/')
|
|
1434
|
+
? join(process.env['HOME'] ?? '', planRef.slice(2))
|
|
1435
|
+
: planRef;
|
|
1436
|
+
return Result.ok(
|
|
1437
|
+
isAbsolute(normalized) ? normalized : join(rootDir, normalized)
|
|
1438
|
+
);
|
|
1439
|
+
}
|
|
1440
|
+
const normalizedRef = planRef.endsWith('.json')
|
|
1441
|
+
? planRef.slice(0, -'.json'.length)
|
|
1442
|
+
: planRef;
|
|
1443
|
+
const matches = collectActiveRegradePlanPaths(rootDir).filter(
|
|
1444
|
+
(candidate) => basename(candidate, '.json') === normalizedRef
|
|
1445
|
+
);
|
|
1446
|
+
if (matches.length === 1) {
|
|
1447
|
+
return Result.ok(matches[0] as string);
|
|
1448
|
+
}
|
|
1449
|
+
if (matches.length === 0) {
|
|
1450
|
+
return Result.err(
|
|
1451
|
+
new ValidationError(`No active Regrade plan named "${planRef}" found.`)
|
|
1452
|
+
);
|
|
1453
|
+
}
|
|
1454
|
+
return Result.err(
|
|
1455
|
+
new ValidationError(
|
|
1456
|
+
`Multiple active Regrade plans named "${planRef}" found.`,
|
|
1457
|
+
{
|
|
1458
|
+
context: {
|
|
1459
|
+
matches: matches.map((match) => rootRelativePath(rootDir, match)),
|
|
1460
|
+
},
|
|
1461
|
+
}
|
|
1462
|
+
)
|
|
1463
|
+
);
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
const plans = collectActiveRegradePlanPaths(rootDir);
|
|
1467
|
+
if (plans.length === 1) {
|
|
1468
|
+
return Result.ok(plans[0] as string);
|
|
1469
|
+
}
|
|
1470
|
+
if (plans.length === 0) {
|
|
1471
|
+
return Result.err(new ValidationError('No active Regrade plans found.'));
|
|
1472
|
+
}
|
|
1473
|
+
return Result.err(
|
|
1474
|
+
new ValidationError('Multiple active Regrade plans found; pass `--plan`.', {
|
|
1475
|
+
context: { plans: plans.map((plan) => rootRelativePath(rootDir, plan)) },
|
|
1476
|
+
})
|
|
1477
|
+
);
|
|
1478
|
+
};
|
|
1479
|
+
|
|
1480
|
+
const planStatusForReport = (
|
|
1481
|
+
artifact: RegradePlanArtifact,
|
|
1482
|
+
report: RegradeReport
|
|
1483
|
+
): 'active' | 'stale' =>
|
|
1484
|
+
regradeSourceHashMatches(artifact.sourceHash, report) ? 'active' : 'stale';
|
|
1485
|
+
|
|
1486
|
+
const regradePlanGateContext = (
|
|
1487
|
+
report: RegradeReport
|
|
1488
|
+
):
|
|
1489
|
+
| {
|
|
1490
|
+
readonly gate?: unknown;
|
|
1491
|
+
readonly modified?: number;
|
|
1492
|
+
readonly review?: number;
|
|
1493
|
+
}
|
|
1494
|
+
| undefined => {
|
|
1495
|
+
const { apply, review, rewritten } = report;
|
|
1496
|
+
const modified = apply === undefined ? rewritten : 0;
|
|
1497
|
+
const counts = {
|
|
1498
|
+
...(modified === 0 ? {} : { modified }),
|
|
1499
|
+
...(review === 0 ? {} : { review }),
|
|
1500
|
+
};
|
|
1501
|
+
// Class-mode reports carry no vocabulary run: the gate is derived from the
|
|
1502
|
+
// outstanding rewrite and review counts alone.
|
|
1503
|
+
const gateStatus = report.run?.report.gate.status;
|
|
1504
|
+
if (gateStatus !== undefined && gateStatus !== 'green') {
|
|
1505
|
+
return { gate: report.run?.report.gate, ...counts };
|
|
1506
|
+
}
|
|
1507
|
+
if (modified > 0 || review > 0) {
|
|
1508
|
+
return { gate: report.run?.report.gate, ...counts };
|
|
1509
|
+
}
|
|
1510
|
+
return undefined;
|
|
1511
|
+
};
|
|
1512
|
+
|
|
1513
|
+
const persistVocabularyRecord = (params: {
|
|
1514
|
+
readonly report: RegradeReport;
|
|
1515
|
+
readonly rootDir: string;
|
|
1516
|
+
readonly status: 'applied' | 'candidate' | 'checked';
|
|
1517
|
+
}): TrailsResult<RegradeReport, Error> => {
|
|
1518
|
+
const recordResult = writeVocabularyTransitionRecord({
|
|
1519
|
+
environment: vocabularyRecordEnvironment(params.rootDir),
|
|
1520
|
+
report: params.report,
|
|
1521
|
+
root: params.rootDir,
|
|
1522
|
+
status: params.status,
|
|
1523
|
+
});
|
|
1524
|
+
if (recordResult.isErr()) {
|
|
1525
|
+
return recordResult;
|
|
1526
|
+
}
|
|
1527
|
+
return validateRegradeReport(
|
|
1528
|
+
transitionRecordReportWithSummary(params.report, recordResult.value.summary)
|
|
1529
|
+
);
|
|
1530
|
+
};
|
|
1531
|
+
|
|
1532
|
+
const runResolvedVocabularyPlan = (params: {
|
|
1533
|
+
readonly apply: boolean;
|
|
1534
|
+
readonly includeEntries: RegradeInput['includeEntries'];
|
|
1535
|
+
readonly plan: VocabularyRegradePlan;
|
|
1536
|
+
readonly preserveInventory: readonly VocabularyPreserveInventoryEntry[];
|
|
1537
|
+
readonly rootDir: string;
|
|
1538
|
+
}): TrailsResult<RegradeReport, Error> => {
|
|
1539
|
+
const prosePlan = vocabularyProsePlan(params.plan);
|
|
1540
|
+
const reportResult: TrailsResult<RegradeReport | null, Error> =
|
|
1541
|
+
prosePlan === null
|
|
1542
|
+
? Result.ok(null)
|
|
1543
|
+
: runVocabularyRegrade({
|
|
1544
|
+
apply: params.apply,
|
|
1545
|
+
includeEntries: params.includeEntries,
|
|
1546
|
+
plan: prosePlan,
|
|
1547
|
+
...(params.preserveInventory.length === 0
|
|
1548
|
+
? {}
|
|
1549
|
+
: { preserveInventory: params.preserveInventory }),
|
|
1550
|
+
root: params.rootDir,
|
|
1551
|
+
});
|
|
1552
|
+
if (reportResult.isErr()) {
|
|
1553
|
+
return reportResult;
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
const symbolReportResult = runGovernedSymbolRegrade({
|
|
1557
|
+
apply: params.apply,
|
|
1558
|
+
includeEntries: params.includeEntries,
|
|
1559
|
+
plan: params.plan,
|
|
1560
|
+
preserveInventory: params.preserveInventory,
|
|
1561
|
+
rootDir: params.rootDir,
|
|
1562
|
+
});
|
|
1563
|
+
if (symbolReportResult.isErr()) {
|
|
1564
|
+
return symbolReportResult;
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
const report = reportResult.value;
|
|
1568
|
+
const symbolReport = symbolReportResult.value;
|
|
1569
|
+
if (report === null) {
|
|
1570
|
+
if (symbolReport === null) {
|
|
1571
|
+
return regradeNoEngineForScope();
|
|
1572
|
+
}
|
|
1573
|
+
return validateRegradeReport(
|
|
1574
|
+
reportWithVocabularyTransitionRun({
|
|
1575
|
+
plan: params.plan,
|
|
1576
|
+
preserveInventory: params.preserveInventory,
|
|
1577
|
+
report: symbolReport,
|
|
1578
|
+
})
|
|
1579
|
+
);
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
const validated = validateRegradeReport(
|
|
1583
|
+
reportWithVocabularyTransitionRun({
|
|
1584
|
+
plan: params.plan,
|
|
1585
|
+
preserveInventory: params.preserveInventory,
|
|
1586
|
+
report,
|
|
1587
|
+
})
|
|
1588
|
+
);
|
|
1589
|
+
if (validated.isErr()) {
|
|
1590
|
+
return validated;
|
|
1591
|
+
}
|
|
1592
|
+
if (symbolReport === null) {
|
|
1593
|
+
return Result.ok(validated.value);
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
return validateRegradeReport(
|
|
1597
|
+
reportWithVocabularyTransitionRun({
|
|
1598
|
+
plan: params.plan,
|
|
1599
|
+
preserveInventory: params.preserveInventory,
|
|
1600
|
+
report: mergeRegradeReports(report, symbolReport),
|
|
1601
|
+
})
|
|
1602
|
+
);
|
|
1603
|
+
};
|
|
1604
|
+
|
|
1605
|
+
interface ClassRegradeCoreParams {
|
|
1606
|
+
readonly apply: boolean;
|
|
1607
|
+
readonly classIds?: readonly string[] | undefined;
|
|
1608
|
+
readonly collection?:
|
|
1609
|
+
| {
|
|
1610
|
+
readonly exclude?: readonly string[] | undefined;
|
|
1611
|
+
readonly extensions?: readonly string[] | undefined;
|
|
1612
|
+
readonly include?: readonly string[] | undefined;
|
|
1613
|
+
}
|
|
1614
|
+
| undefined;
|
|
1615
|
+
readonly includeEntries: RegradeInput['includeEntries'];
|
|
1616
|
+
readonly rootDir: string;
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
const runClassRegradeCore = async (
|
|
1620
|
+
params: ClassRegradeCoreParams
|
|
1621
|
+
): Promise<TrailsResult<RegradeReport, Error>> => {
|
|
1622
|
+
const classSet = await loadWardenRegradeClasses(params.rootDir);
|
|
1623
|
+
if (classSet.diagnostics.length > 0) {
|
|
1624
|
+
return Result.err(
|
|
1625
|
+
new InternalError('Failed to load Regrade project Warden rules.', {
|
|
1626
|
+
context: {
|
|
1627
|
+
diagnostics: classSet.diagnostics,
|
|
1628
|
+
rootDir: params.rootDir,
|
|
1629
|
+
},
|
|
1630
|
+
})
|
|
1631
|
+
);
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
const collection =
|
|
1635
|
+
params.collection === undefined
|
|
1636
|
+
? undefined
|
|
1637
|
+
: {
|
|
1638
|
+
...(params.collection.exclude === undefined
|
|
1639
|
+
? {}
|
|
1640
|
+
: { exclude: params.collection.exclude }),
|
|
1641
|
+
...(params.collection.extensions === undefined
|
|
1642
|
+
? {}
|
|
1643
|
+
: { extensions: params.collection.extensions }),
|
|
1644
|
+
...(params.collection.include === undefined
|
|
1645
|
+
? {}
|
|
1646
|
+
: { include: params.collection.include }),
|
|
1647
|
+
};
|
|
1648
|
+
const reportResult: TrailsResult<RegradeReport | null, Error> = runRegrade({
|
|
1649
|
+
apply: params.apply,
|
|
1650
|
+
classes: classSet.classes,
|
|
1651
|
+
...(collection === undefined ? {} : { collection }),
|
|
1652
|
+
includeEntries: params.includeEntries,
|
|
1653
|
+
root: params.rootDir,
|
|
1654
|
+
...(params.classIds === undefined
|
|
1655
|
+
? {}
|
|
1656
|
+
: { selection: { classIds: params.classIds } }),
|
|
1657
|
+
});
|
|
1658
|
+
if (reportResult.isErr()) {
|
|
1659
|
+
return reportResult;
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
const report = reportResult.value;
|
|
1663
|
+
if (report === null) {
|
|
1664
|
+
return regradeRootNotFound(params.rootDir);
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
return validateRegradeReport(report);
|
|
1668
|
+
};
|
|
1669
|
+
|
|
1670
|
+
const runClassPlanRegradeRun = (params: {
|
|
1671
|
+
readonly apply: boolean;
|
|
1672
|
+
readonly includeEntries: RegradePlanReferenceInput['includeEntries'];
|
|
1673
|
+
readonly plan: ClassRegradePlan;
|
|
1674
|
+
readonly rootDir: string;
|
|
1675
|
+
}): Promise<TrailsResult<RegradeReport, Error>> =>
|
|
1676
|
+
runClassRegradeCore({
|
|
1677
|
+
apply: params.apply,
|
|
1678
|
+
classIds: params.plan.classIds,
|
|
1679
|
+
...(params.plan.scope === undefined
|
|
1680
|
+
? {}
|
|
1681
|
+
: { collection: params.plan.scope }),
|
|
1682
|
+
includeEntries: params.includeEntries,
|
|
1683
|
+
rootDir: params.rootDir,
|
|
1684
|
+
});
|
|
1685
|
+
|
|
1686
|
+
const runPlanArtifactDryRun = async (params: {
|
|
1687
|
+
readonly artifact: RegradePlanArtifact;
|
|
1688
|
+
readonly includeEntries: RegradePlanReferenceInput['includeEntries'];
|
|
1689
|
+
readonly rootDir: string;
|
|
1690
|
+
}): Promise<TrailsResult<RegradeReport, Error>> => {
|
|
1691
|
+
const planBody = params.artifact.plan;
|
|
1692
|
+
if (planBody.kind === 'class') {
|
|
1693
|
+
return runClassPlanRegradeRun({
|
|
1694
|
+
apply: false,
|
|
1695
|
+
includeEntries: params.includeEntries,
|
|
1696
|
+
plan: planBody,
|
|
1697
|
+
rootDir: params.rootDir,
|
|
1698
|
+
});
|
|
1699
|
+
}
|
|
1700
|
+
const preserveInventory = await deriveLiveApiPreserveInventory(planBody);
|
|
1701
|
+
return runResolvedVocabularyPlan({
|
|
1702
|
+
apply: false,
|
|
1703
|
+
includeEntries: params.includeEntries,
|
|
1704
|
+
plan: planBody,
|
|
1705
|
+
preserveInventory,
|
|
1706
|
+
rootDir: params.rootDir,
|
|
1707
|
+
});
|
|
1708
|
+
};
|
|
1709
|
+
|
|
1710
|
+
const runLegacyVocabularyRecordRegrade = (
|
|
1711
|
+
input: RegradeInput,
|
|
1712
|
+
rootDir: string,
|
|
1713
|
+
absoluteRecordPath: string
|
|
1714
|
+
): TrailsResult<RegradeReport, Error> => {
|
|
1715
|
+
const recordResult = readVocabularyTransitionRecord(absoluteRecordPath);
|
|
1716
|
+
if (recordResult.isErr()) {
|
|
1717
|
+
return recordResult;
|
|
1718
|
+
}
|
|
1719
|
+
const record = recordResult.value;
|
|
1720
|
+
if (record.report.run === undefined) {
|
|
1721
|
+
return Result.err(
|
|
1722
|
+
new ValidationError(
|
|
1723
|
+
'Transition record does not contain a vocabulary run.'
|
|
1724
|
+
)
|
|
1725
|
+
);
|
|
1726
|
+
}
|
|
1727
|
+
const dryRun = runResolvedVocabularyPlan({
|
|
1728
|
+
apply: false,
|
|
1729
|
+
includeEntries: input.includeEntries,
|
|
1730
|
+
plan: record.report.run.plan,
|
|
1731
|
+
preserveInventory: record.report.run.preserveInventory ?? [],
|
|
1732
|
+
rootDir,
|
|
1733
|
+
});
|
|
1734
|
+
if (dryRun.isErr()) {
|
|
1735
|
+
return dryRun;
|
|
1736
|
+
}
|
|
1737
|
+
if (regradeSourceHash(dryRun.value) !== regradeSourceHash(record.report)) {
|
|
1738
|
+
return Result.err(
|
|
1739
|
+
new ValidationError(
|
|
1740
|
+
'Vocabulary transition record is stale for the current source tree. Re-run discovery and review the new record before applying.',
|
|
1741
|
+
{ context: { recordPath: record.recordPath } }
|
|
1742
|
+
)
|
|
1743
|
+
);
|
|
1744
|
+
}
|
|
1745
|
+
if (input.check) {
|
|
1746
|
+
const checked = transitionRecordReportWithSummary(record.report, {
|
|
1747
|
+
path: record.recordPath,
|
|
1748
|
+
schemaVersion: record.schemaVersion,
|
|
1749
|
+
status: 'checked',
|
|
1750
|
+
});
|
|
1751
|
+
if (record.report.run.report.gate.status !== 'green') {
|
|
1752
|
+
return Result.err(
|
|
1753
|
+
new ValidationError('Vocabulary transition record gate is open.', {
|
|
1754
|
+
context: {
|
|
1755
|
+
gate: record.report.run.report.gate,
|
|
1756
|
+
recordPath: record.recordPath,
|
|
1757
|
+
},
|
|
1758
|
+
})
|
|
1759
|
+
);
|
|
1760
|
+
}
|
|
1761
|
+
return validateRegradeReport(checked);
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1764
|
+
if (!input.apply) {
|
|
1765
|
+
return Result.err(
|
|
1766
|
+
new ValidationError(
|
|
1767
|
+
'Applying a legacy vocabulary transition record requires `apply: true` or `--apply`. Use `--check` to verify the record without mutating source.',
|
|
1768
|
+
{ context: { recordPath: record.recordPath } }
|
|
1769
|
+
)
|
|
1770
|
+
);
|
|
1771
|
+
}
|
|
1772
|
+
|
|
1773
|
+
const applied = runResolvedVocabularyPlan({
|
|
1774
|
+
apply: true,
|
|
1775
|
+
includeEntries: input.includeEntries,
|
|
1776
|
+
plan: record.report.run.plan,
|
|
1777
|
+
preserveInventory: record.report.run.preserveInventory ?? [],
|
|
1778
|
+
rootDir,
|
|
1779
|
+
});
|
|
1780
|
+
if (applied.isErr()) {
|
|
1781
|
+
return applied;
|
|
1782
|
+
}
|
|
1783
|
+
return persistVocabularyRecord({
|
|
1784
|
+
report: applied.value,
|
|
1785
|
+
rootDir,
|
|
1786
|
+
status: 'applied',
|
|
1787
|
+
});
|
|
1788
|
+
};
|
|
1789
|
+
|
|
1790
|
+
const runVocabularyCommandRegrade = async (
|
|
1791
|
+
input: RegradeInput,
|
|
1792
|
+
rootDir: string,
|
|
1793
|
+
configScope?: RegradeConfigScope | undefined
|
|
1794
|
+
): Promise<TrailsResult<RegradeReport, Error>> => {
|
|
1795
|
+
if (input.apply && input.planRecord === undefined) {
|
|
1796
|
+
return Result.err(
|
|
1797
|
+
new ValidationError(
|
|
1798
|
+
'Vocabulary regrade apply requires `planRecord`. Run discovery with `writeRecord` first, review the record, then apply the confirmed record.'
|
|
1799
|
+
)
|
|
1800
|
+
);
|
|
1801
|
+
}
|
|
1802
|
+
if (input.check && input.planRecord === undefined) {
|
|
1803
|
+
return Result.err(
|
|
1804
|
+
new ValidationError(
|
|
1805
|
+
'Vocabulary regrade check requires `planRecord` so the gate is computed from persisted evidence.'
|
|
1806
|
+
)
|
|
1807
|
+
);
|
|
1808
|
+
}
|
|
1809
|
+
if (input.planRecord !== undefined) {
|
|
1810
|
+
const absoluteRecordPath = vocabularyRecordPathForInput(
|
|
1811
|
+
rootDir,
|
|
1812
|
+
input.planRecord
|
|
1813
|
+
);
|
|
1814
|
+
return runLegacyVocabularyRecordRegrade(input, rootDir, absoluteRecordPath);
|
|
1815
|
+
}
|
|
1816
|
+
|
|
1817
|
+
const planResult = buildVocabularyPlan(
|
|
1818
|
+
input,
|
|
1819
|
+
vocabularyScopeFromConfig(configScope)
|
|
1820
|
+
);
|
|
1821
|
+
if (planResult.isErr()) {
|
|
1822
|
+
return planResult;
|
|
1823
|
+
}
|
|
1824
|
+
if (!regradeRootIsReadable(rootDir)) {
|
|
1825
|
+
return regradeRootNotFound(rootDir);
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
const preserveInventory = await deriveLiveApiPreserveInventory(
|
|
1829
|
+
planResult.value
|
|
1830
|
+
);
|
|
1831
|
+
const report = runResolvedVocabularyPlan({
|
|
1832
|
+
apply: input.apply,
|
|
1833
|
+
includeEntries: input.includeEntries,
|
|
1834
|
+
plan: planResult.value,
|
|
1835
|
+
preserveInventory,
|
|
1836
|
+
rootDir,
|
|
1837
|
+
});
|
|
1838
|
+
if (report.isErr() || !input.writeRecord) {
|
|
1839
|
+
return report;
|
|
1840
|
+
}
|
|
1841
|
+
return persistVocabularyRecord({
|
|
1842
|
+
report: report.value,
|
|
1843
|
+
rootDir,
|
|
1844
|
+
status: input.apply ? 'applied' : 'candidate',
|
|
1845
|
+
});
|
|
1846
|
+
};
|
|
1847
|
+
|
|
1848
|
+
const expansionCandidateKey = (
|
|
1849
|
+
candidate: RegradePlanExpansion['candidates'][number]
|
|
1850
|
+
): string =>
|
|
1851
|
+
[candidate.kind, candidate.value, candidate.suggestedClassification].join(
|
|
1852
|
+
'\0'
|
|
1853
|
+
);
|
|
1854
|
+
|
|
1855
|
+
const expansionEvidenceKey = (
|
|
1856
|
+
evidence: RegradePlanExpansion['candidates'][number]['evidence'][number]
|
|
1857
|
+
): string =>
|
|
1858
|
+
[
|
|
1859
|
+
evidence.path,
|
|
1860
|
+
evidence.line ?? '',
|
|
1861
|
+
evidence.column ?? '',
|
|
1862
|
+
evidence.detail ?? '',
|
|
1863
|
+
].join('\0');
|
|
1864
|
+
|
|
1865
|
+
const mergeExpansionEvidence = (
|
|
1866
|
+
left: RegradePlanExpansion['candidates'][number]['evidence'],
|
|
1867
|
+
right: RegradePlanExpansion['candidates'][number]['evidence']
|
|
1868
|
+
): RegradePlanExpansion['candidates'][number]['evidence'] => {
|
|
1869
|
+
const merged = new Map<
|
|
1870
|
+
string,
|
|
1871
|
+
RegradePlanExpansion['candidates'][number]['evidence'][number]
|
|
1872
|
+
>();
|
|
1873
|
+
for (const evidence of [...left, ...right]) {
|
|
1874
|
+
merged.set(expansionEvidenceKey(evidence), evidence);
|
|
1875
|
+
}
|
|
1876
|
+
return [...merged.values()].toSorted((a, b) =>
|
|
1877
|
+
a.path === b.path
|
|
1878
|
+
? (a.line ?? 0) - (b.line ?? 0) ||
|
|
1879
|
+
(a.column ?? 0) - (b.column ?? 0) ||
|
|
1880
|
+
(a.detail ?? '').localeCompare(b.detail ?? '')
|
|
1881
|
+
: a.path.localeCompare(b.path)
|
|
1882
|
+
);
|
|
1883
|
+
};
|
|
1884
|
+
|
|
1885
|
+
const compareCandidates = (
|
|
1886
|
+
left: RegradePlanExpansion['candidates'][number],
|
|
1887
|
+
right: RegradePlanExpansion['candidates'][number]
|
|
1888
|
+
): number => {
|
|
1889
|
+
if (left.kind !== right.kind) {
|
|
1890
|
+
return left.kind.localeCompare(right.kind);
|
|
1891
|
+
}
|
|
1892
|
+
if (left.value !== right.value) {
|
|
1893
|
+
return left.value.localeCompare(right.value);
|
|
1894
|
+
}
|
|
1895
|
+
return left.suggestedClassification.localeCompare(
|
|
1896
|
+
right.suggestedClassification
|
|
1897
|
+
);
|
|
1898
|
+
};
|
|
1899
|
+
|
|
1900
|
+
const expansionForReport = (
|
|
1901
|
+
report: RegradeReport
|
|
1902
|
+
): RegradePlanArtifact['expansion'] => {
|
|
1903
|
+
const candidates = new Map<
|
|
1904
|
+
string,
|
|
1905
|
+
RegradePlanExpansion['candidates'][number]
|
|
1906
|
+
>();
|
|
1907
|
+
const candidateValues = new Set<string>();
|
|
1908
|
+
const addCandidate = (
|
|
1909
|
+
candidate: RegradePlanExpansion['candidates'][number]
|
|
1910
|
+
): void => {
|
|
1911
|
+
const key = expansionCandidateKey(candidate);
|
|
1912
|
+
candidateValues.add(`${candidate.kind}\0${candidate.value}`);
|
|
1913
|
+
const current = candidates.get(key);
|
|
1914
|
+
if (current === undefined) {
|
|
1915
|
+
candidates.set(key, candidate);
|
|
1916
|
+
return;
|
|
1917
|
+
}
|
|
1918
|
+
candidates.set(key, {
|
|
1919
|
+
...current,
|
|
1920
|
+
evidence: mergeExpansionEvidence(current.evidence, candidate.evidence),
|
|
1921
|
+
});
|
|
1922
|
+
};
|
|
1923
|
+
|
|
1924
|
+
for (const occurrence of report.run?.ledger.occurrences ?? []) {
|
|
1925
|
+
if (occurrence.verdict !== 'deferred') {
|
|
1926
|
+
continue;
|
|
1927
|
+
}
|
|
1928
|
+
addCandidate({
|
|
1929
|
+
evidence: [
|
|
1930
|
+
{
|
|
1931
|
+
column: occurrence.column,
|
|
1932
|
+
detail: occurrence.reason,
|
|
1933
|
+
line: occurrence.line,
|
|
1934
|
+
path: occurrence.path,
|
|
1935
|
+
},
|
|
1936
|
+
],
|
|
1937
|
+
kind: 'form',
|
|
1938
|
+
status: 'pending',
|
|
1939
|
+
suggestedClassification: occurrence.disposition,
|
|
1940
|
+
value: occurrence.form,
|
|
1941
|
+
});
|
|
1942
|
+
}
|
|
1943
|
+
|
|
1944
|
+
for (const entry of report.entries) {
|
|
1945
|
+
if (entry.outcome !== 'needs-review' || entry.reviewDetails === undefined) {
|
|
1946
|
+
continue;
|
|
1947
|
+
}
|
|
1948
|
+
for (const detail of entry.reviewDetails) {
|
|
1949
|
+
if (detail.symbol === undefined) {
|
|
1950
|
+
continue;
|
|
1951
|
+
}
|
|
1952
|
+
if (candidateValues.has(`form\0${detail.symbol}`)) {
|
|
1953
|
+
continue;
|
|
1954
|
+
}
|
|
1955
|
+
addCandidate({
|
|
1956
|
+
evidence: [
|
|
1957
|
+
{
|
|
1958
|
+
...(detail.span === undefined
|
|
1959
|
+
? {}
|
|
1960
|
+
: {
|
|
1961
|
+
column: detail.span.column,
|
|
1962
|
+
line: detail.span.line,
|
|
1963
|
+
}),
|
|
1964
|
+
detail: detail.reason,
|
|
1965
|
+
path: entry.path,
|
|
1966
|
+
},
|
|
1967
|
+
],
|
|
1968
|
+
kind: 'form',
|
|
1969
|
+
status: 'pending',
|
|
1970
|
+
suggestedClassification: entry.reason ?? detail.reason,
|
|
1971
|
+
value: detail.symbol,
|
|
1972
|
+
});
|
|
1973
|
+
}
|
|
1974
|
+
}
|
|
1975
|
+
|
|
1976
|
+
return { candidates: [...candidates.values()].toSorted(compareCandidates) };
|
|
1977
|
+
};
|
|
1978
|
+
|
|
1979
|
+
const preserveRuleCoversForm = (
|
|
1980
|
+
rule: VocabularyPreserveRule,
|
|
1981
|
+
form: string
|
|
1982
|
+
): boolean =>
|
|
1983
|
+
(rule.forms === undefined || rule.forms.includes(form)) &&
|
|
1984
|
+
compileVocabularyPreservePattern(rule.pattern).test(form);
|
|
1985
|
+
|
|
1986
|
+
const preserveRuleCoversCandidateEvidence = (
|
|
1987
|
+
rule: VocabularyPreserveRule,
|
|
1988
|
+
form: string,
|
|
1989
|
+
evidence: RegradePlanExpansion['candidates'][number]['evidence'][number]
|
|
1990
|
+
): boolean =>
|
|
1991
|
+
preserveRuleCoversForm(rule, form) &&
|
|
1992
|
+
(rule.paths === undefined || matchesAnyPathGlob(evidence.path, rule.paths));
|
|
1993
|
+
|
|
1994
|
+
const preserveRulesCoverFormCandidate = (
|
|
1995
|
+
preserve: readonly VocabularyPreserveRule[] | undefined,
|
|
1996
|
+
candidate: RegradePlanExpansion['candidates'][number]
|
|
1997
|
+
): boolean => {
|
|
1998
|
+
if (preserve === undefined) {
|
|
1999
|
+
return false;
|
|
2000
|
+
}
|
|
2001
|
+
if (candidate.kind !== 'form') {
|
|
2002
|
+
return false;
|
|
2003
|
+
}
|
|
2004
|
+
|
|
2005
|
+
if (candidate.evidence.length === 0) {
|
|
2006
|
+
return preserve.some(
|
|
2007
|
+
(rule) =>
|
|
2008
|
+
rule.paths === undefined &&
|
|
2009
|
+
preserveRuleCoversForm(rule, candidate.value)
|
|
2010
|
+
);
|
|
2011
|
+
}
|
|
2012
|
+
|
|
2013
|
+
return candidate.evidence.every((evidence) =>
|
|
2014
|
+
preserve.some((rule) =>
|
|
2015
|
+
preserveRuleCoversCandidateEvidence(rule, candidate.value, evidence)
|
|
2016
|
+
)
|
|
2017
|
+
);
|
|
2018
|
+
};
|
|
2019
|
+
|
|
2020
|
+
const primaryPlanCoversExpansionCandidate = (
|
|
2021
|
+
plan: VocabularyRegradePlan,
|
|
2022
|
+
candidate: RegradePlanExpansion['candidates'][number]
|
|
2023
|
+
): boolean => {
|
|
2024
|
+
if (candidate.kind !== 'form') {
|
|
2025
|
+
return false;
|
|
2026
|
+
}
|
|
2027
|
+
return (
|
|
2028
|
+
plan.deferForms?.includes(candidate.value) === true ||
|
|
2029
|
+
plan.overrides?.[candidate.value] !== undefined ||
|
|
2030
|
+
preserveRulesCoverFormCandidate(plan.preserve, candidate)
|
|
2031
|
+
);
|
|
2032
|
+
};
|
|
2033
|
+
|
|
2034
|
+
const mergeRegradePlanExpansion = (
|
|
2035
|
+
current: RegradePlanExpansion | undefined,
|
|
2036
|
+
next: RegradePlanExpansion | undefined,
|
|
2037
|
+
plan: VocabularyRegradePlan
|
|
2038
|
+
): RegradePlanExpansion | undefined => {
|
|
2039
|
+
const candidates = new Map<
|
|
2040
|
+
string,
|
|
2041
|
+
RegradePlanExpansion['candidates'][number]
|
|
2042
|
+
>();
|
|
2043
|
+
|
|
2044
|
+
for (const candidate of current?.candidates ?? []) {
|
|
2045
|
+
if (primaryPlanCoversExpansionCandidate(plan, candidate)) {
|
|
2046
|
+
continue;
|
|
2047
|
+
}
|
|
2048
|
+
candidates.set(expansionCandidateKey(candidate), candidate);
|
|
2049
|
+
}
|
|
2050
|
+
|
|
2051
|
+
for (const candidate of next?.candidates ?? []) {
|
|
2052
|
+
if (primaryPlanCoversExpansionCandidate(plan, candidate)) {
|
|
2053
|
+
continue;
|
|
2054
|
+
}
|
|
2055
|
+
const key = expansionCandidateKey(candidate);
|
|
2056
|
+
const existing = candidates.get(key);
|
|
2057
|
+
if (existing?.status === 'rejected') {
|
|
2058
|
+
candidates.set(key, existing);
|
|
2059
|
+
continue;
|
|
2060
|
+
}
|
|
2061
|
+
candidates.set(key, {
|
|
2062
|
+
...candidate,
|
|
2063
|
+
...(existing === undefined
|
|
2064
|
+
? {}
|
|
2065
|
+
: {
|
|
2066
|
+
evidence: mergeExpansionEvidence(
|
|
2067
|
+
existing.evidence,
|
|
2068
|
+
candidate.evidence
|
|
2069
|
+
),
|
|
2070
|
+
status: existing.status,
|
|
2071
|
+
}),
|
|
2072
|
+
});
|
|
2073
|
+
}
|
|
2074
|
+
|
|
2075
|
+
const merged = [...candidates.values()]
|
|
2076
|
+
.filter(
|
|
2077
|
+
(candidate) => !primaryPlanCoversExpansionCandidate(plan, candidate)
|
|
2078
|
+
)
|
|
2079
|
+
.toSorted(compareCandidates);
|
|
2080
|
+
return merged.length === 0 ? undefined : { candidates: merged };
|
|
2081
|
+
};
|
|
2082
|
+
|
|
2083
|
+
const classPlanScopeForInput = (
|
|
2084
|
+
input: RegradePlanInput,
|
|
2085
|
+
configScope: RegradeConfigScope | undefined
|
|
2086
|
+
): ClassRegradePlan['scope'] => {
|
|
2087
|
+
const exclude = input.exclude ?? configScope?.exclude;
|
|
2088
|
+
const extensions = input.extensions ?? configScope?.extensions;
|
|
2089
|
+
const include = input.include ?? configScope?.include;
|
|
2090
|
+
if (
|
|
2091
|
+
exclude === undefined &&
|
|
2092
|
+
extensions === undefined &&
|
|
2093
|
+
include === undefined
|
|
2094
|
+
) {
|
|
2095
|
+
return undefined;
|
|
2096
|
+
}
|
|
2097
|
+
return {
|
|
2098
|
+
...(exclude === undefined ? {} : { exclude: [...exclude] }),
|
|
2099
|
+
...(extensions === undefined ? {} : { extensions: [...extensions] }),
|
|
2100
|
+
...(include === undefined ? {} : { include: [...include] }),
|
|
2101
|
+
};
|
|
2102
|
+
};
|
|
2103
|
+
|
|
2104
|
+
const validateClassPlanInput = (
|
|
2105
|
+
input: RegradePlanInput
|
|
2106
|
+
): ValidationError | null => {
|
|
2107
|
+
if (input.classIds === undefined || input.classIds.length === 0) {
|
|
2108
|
+
return new ValidationError(
|
|
2109
|
+
'A class-mode Regrade plan requires at least one class id.'
|
|
2110
|
+
);
|
|
2111
|
+
}
|
|
2112
|
+
if (input.from !== undefined || input.to !== undefined) {
|
|
2113
|
+
return new ValidationError(
|
|
2114
|
+
'`classIds` selects a class-mode plan and cannot be combined with vocabulary `from`/`to`.'
|
|
2115
|
+
);
|
|
2116
|
+
}
|
|
2117
|
+
if (input.type === 'vocabulary') {
|
|
2118
|
+
return new ValidationError(
|
|
2119
|
+
'`type: vocabulary` cannot be combined with `classIds`.'
|
|
2120
|
+
);
|
|
2121
|
+
}
|
|
2122
|
+
if (input.expand) {
|
|
2123
|
+
return new ValidationError(
|
|
2124
|
+
'`expand` stages vocabulary review candidates and is not supported for class-mode plans.'
|
|
2125
|
+
);
|
|
2126
|
+
}
|
|
2127
|
+
return null;
|
|
2128
|
+
};
|
|
2129
|
+
|
|
2130
|
+
type ClassPlanArtifact = RegradePlanArtifact & {
|
|
2131
|
+
readonly plan: ClassRegradePlan;
|
|
2132
|
+
};
|
|
2133
|
+
|
|
2134
|
+
const readCurrentClassPlanArtifact = (
|
|
2135
|
+
input: RegradePlanInput,
|
|
2136
|
+
currentPath: string
|
|
2137
|
+
): TrailsResult<ClassPlanArtifact | null, Error> => {
|
|
2138
|
+
if (input.fresh || !existsSync(currentPath)) {
|
|
2139
|
+
return Result.ok(null);
|
|
2140
|
+
}
|
|
2141
|
+
const currentResult = readRegradePlanArtifact(currentPath);
|
|
2142
|
+
if (currentResult.isErr()) {
|
|
2143
|
+
return currentResult;
|
|
2144
|
+
}
|
|
2145
|
+
const candidate = currentResult.value;
|
|
2146
|
+
if (candidate.plan.kind !== 'class') {
|
|
2147
|
+
return Result.ok(null);
|
|
2148
|
+
}
|
|
2149
|
+
return Result.ok({ ...candidate, plan: candidate.plan });
|
|
2150
|
+
};
|
|
2151
|
+
|
|
2152
|
+
/** Carry authored intent and scope forward from the existing plan artifact. */
|
|
2153
|
+
const mergeAuthoredClassPlanFields = (
|
|
2154
|
+
plan: ClassRegradePlan,
|
|
2155
|
+
input: RegradePlanInput,
|
|
2156
|
+
authoredScope: boolean,
|
|
2157
|
+
current: ClassPlanArtifact | null
|
|
2158
|
+
): ClassRegradePlan => {
|
|
2159
|
+
if (current === null) {
|
|
2160
|
+
return plan;
|
|
2161
|
+
}
|
|
2162
|
+
let merged = plan;
|
|
2163
|
+
if (
|
|
2164
|
+
input.intent === undefined &&
|
|
2165
|
+
current.provenance.fields['intent'] === 'authored' &&
|
|
2166
|
+
current.plan.intent !== undefined
|
|
2167
|
+
) {
|
|
2168
|
+
merged = { ...merged, intent: current.plan.intent };
|
|
2169
|
+
}
|
|
2170
|
+
if (
|
|
2171
|
+
input.name === undefined &&
|
|
2172
|
+
current.provenance.fields['name'] === 'authored' &&
|
|
2173
|
+
current.plan.name !== undefined
|
|
2174
|
+
) {
|
|
2175
|
+
merged = { ...merged, name: current.plan.name };
|
|
2176
|
+
}
|
|
2177
|
+
if (
|
|
2178
|
+
!authoredScope &&
|
|
2179
|
+
current.provenance.fields['scope'] === 'authored' &&
|
|
2180
|
+
current.plan.scope !== undefined
|
|
2181
|
+
) {
|
|
2182
|
+
merged = { ...merged, scope: current.plan.scope };
|
|
2183
|
+
}
|
|
2184
|
+
return merged;
|
|
2185
|
+
};
|
|
2186
|
+
|
|
2187
|
+
const classPlanProvenance = (
|
|
2188
|
+
plan: ClassRegradePlan,
|
|
2189
|
+
authoredScope: boolean,
|
|
2190
|
+
current: ClassPlanArtifact | null
|
|
2191
|
+
): RegradePlanArtifact['provenance'] => ({
|
|
2192
|
+
fields: {
|
|
2193
|
+
classIds: 'authored',
|
|
2194
|
+
id: 'derived',
|
|
2195
|
+
kind: 'derived',
|
|
2196
|
+
...(plan.intent === undefined ? {} : { intent: 'authored' }),
|
|
2197
|
+
...(plan.name === undefined ? {} : { name: 'authored' }),
|
|
2198
|
+
...(plan.scope === undefined
|
|
2199
|
+
? {}
|
|
2200
|
+
: {
|
|
2201
|
+
scope:
|
|
2202
|
+
authoredScope || current?.provenance.fields['scope'] === 'authored'
|
|
2203
|
+
? 'authored'
|
|
2204
|
+
: 'derived',
|
|
2205
|
+
}),
|
|
2206
|
+
},
|
|
2207
|
+
});
|
|
2208
|
+
|
|
2209
|
+
/**
|
|
2210
|
+
* A named class plan keys its file on the name alone, so a reused name with
|
|
2211
|
+
* different class ids would silently overwrite an unrelated in-progress plan
|
|
2212
|
+
* (and later mix runs into its consolidated history). Refuse the collision;
|
|
2213
|
+
* unreadable or non-class artifacts keep their existing handling.
|
|
2214
|
+
*/
|
|
2215
|
+
const classPlanIdentityConflict = (
|
|
2216
|
+
rootDir: string,
|
|
2217
|
+
currentPath: string,
|
|
2218
|
+
classIds: readonly string[]
|
|
2219
|
+
): ValidationError | null => {
|
|
2220
|
+
if (!existsSync(currentPath)) {
|
|
2221
|
+
return null;
|
|
2222
|
+
}
|
|
2223
|
+
const existing = readRegradePlanArtifact(currentPath);
|
|
2224
|
+
if (existing.isErr() || existing.value.plan.kind !== 'class') {
|
|
2225
|
+
return null;
|
|
2226
|
+
}
|
|
2227
|
+
const existingIds = existing.value.plan.classIds;
|
|
2228
|
+
if (
|
|
2229
|
+
existingIds.length === classIds.length &&
|
|
2230
|
+
existingIds.every((id, index) => id === classIds[index])
|
|
2231
|
+
) {
|
|
2232
|
+
return null;
|
|
2233
|
+
}
|
|
2234
|
+
return new ValidationError(
|
|
2235
|
+
'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.',
|
|
2236
|
+
{
|
|
2237
|
+
context: {
|
|
2238
|
+
existing: [...existingIds],
|
|
2239
|
+
path: rootRelativePath(rootDir, currentPath),
|
|
2240
|
+
planned: [...classIds],
|
|
2241
|
+
},
|
|
2242
|
+
}
|
|
2243
|
+
);
|
|
2244
|
+
};
|
|
2245
|
+
|
|
2246
|
+
const runClassPlanRegrade = async (
|
|
2247
|
+
input: RegradePlanInput,
|
|
2248
|
+
rootDir: string,
|
|
2249
|
+
configScope: RegradeConfigScope | undefined,
|
|
2250
|
+
shouldDryRun: boolean
|
|
2251
|
+
): Promise<TrailsResult<RegradePlanArtifact, Error>> => {
|
|
2252
|
+
const invalid = validateClassPlanInput(input);
|
|
2253
|
+
if (invalid !== null) {
|
|
2254
|
+
return Result.err(invalid);
|
|
2255
|
+
}
|
|
2256
|
+
const classIds = input.classIds ?? [];
|
|
2257
|
+
if (!regradeRootIsReadable(rootDir)) {
|
|
2258
|
+
return regradeRootNotFound(rootDir);
|
|
2259
|
+
}
|
|
2260
|
+
|
|
2261
|
+
const inputScope = classPlanScopeForInput(input, configScope);
|
|
2262
|
+
const basePlan: ClassRegradePlan = {
|
|
2263
|
+
classIds: [...classIds],
|
|
2264
|
+
id: `class:${classIds.join('+')}`,
|
|
2265
|
+
...(input.intent === undefined ? {} : { intent: input.intent }),
|
|
2266
|
+
kind: 'class',
|
|
2267
|
+
...(input.name === undefined ? {} : { name: input.name }),
|
|
2268
|
+
...(inputScope === undefined ? {} : { scope: inputScope }),
|
|
2269
|
+
};
|
|
2270
|
+
const currentPath = regradePlanPathForPlan(rootDir, basePlan);
|
|
2271
|
+
const conflict = classPlanIdentityConflict(rootDir, currentPath, classIds);
|
|
2272
|
+
if (conflict !== null) {
|
|
2273
|
+
return Result.err(conflict);
|
|
2274
|
+
}
|
|
2275
|
+
const currentResult = readCurrentClassPlanArtifact(input, currentPath);
|
|
2276
|
+
if (currentResult.isErr()) {
|
|
2277
|
+
return currentResult;
|
|
2278
|
+
}
|
|
2279
|
+
const current = currentResult.value;
|
|
2280
|
+
const authoredScope =
|
|
2281
|
+
input.exclude !== undefined ||
|
|
2282
|
+
input.extensions !== undefined ||
|
|
2283
|
+
input.include !== undefined;
|
|
2284
|
+
const plan = mergeAuthoredClassPlanFields(
|
|
2285
|
+
basePlan,
|
|
2286
|
+
input,
|
|
2287
|
+
authoredScope,
|
|
2288
|
+
current
|
|
2289
|
+
);
|
|
2290
|
+
|
|
2291
|
+
const report = await runClassPlanRegradeRun({
|
|
2292
|
+
apply: false,
|
|
2293
|
+
includeEntries: input.includeEntries,
|
|
2294
|
+
plan,
|
|
2295
|
+
rootDir,
|
|
2296
|
+
});
|
|
2297
|
+
if (report.isErr()) {
|
|
2298
|
+
return report;
|
|
2299
|
+
}
|
|
2300
|
+
if (report.value.unknownClassIds.length > 0) {
|
|
2301
|
+
return Result.err(
|
|
2302
|
+
new ValidationError('Unknown Regrade class ids.', {
|
|
2303
|
+
context: { unknownClassIds: report.value.unknownClassIds },
|
|
2304
|
+
})
|
|
2305
|
+
);
|
|
2306
|
+
}
|
|
2307
|
+
|
|
2308
|
+
const transitionId = priorTransitionId(currentPath, 'class');
|
|
2309
|
+
const artifact: RegradePlanArtifact = {
|
|
2310
|
+
kind: 'regrade-plan',
|
|
2311
|
+
path: rootRelativePath(rootDir, currentPath),
|
|
2312
|
+
plan,
|
|
2313
|
+
provenance: classPlanProvenance(plan, authoredScope, current),
|
|
2314
|
+
schemaVersion: REGRADE_PLAN_SCHEMA_VERSION,
|
|
2315
|
+
sourceHash: regradeSourceHash(report.value),
|
|
2316
|
+
...(transitionId === undefined ? {} : { transitionId }),
|
|
2317
|
+
};
|
|
2318
|
+
if (shouldDryRun) {
|
|
2319
|
+
return validateRegradePlanArtifact(artifact);
|
|
2320
|
+
}
|
|
2321
|
+
return writeRegradePlanArtifact(rootDir, artifact);
|
|
2322
|
+
};
|
|
2323
|
+
|
|
2324
|
+
const readCurrentVocabularyPlanArtifact = (
|
|
2325
|
+
input: RegradePlanInput,
|
|
2326
|
+
currentPath: string
|
|
2327
|
+
): TrailsResult<VocabularyRegradePlanArtifact | null, Error> => {
|
|
2328
|
+
if (input.fresh || !existsSync(currentPath)) {
|
|
2329
|
+
return Result.ok(null);
|
|
2330
|
+
}
|
|
2331
|
+
const currentResult = readRegradePlanArtifact(currentPath);
|
|
2332
|
+
if (currentResult.isErr()) {
|
|
2333
|
+
return currentResult;
|
|
2334
|
+
}
|
|
2335
|
+
const candidate = currentResult.value;
|
|
2336
|
+
if (candidate.plan.kind !== 'vocabulary') {
|
|
2337
|
+
return Result.ok(null);
|
|
2338
|
+
}
|
|
2339
|
+
return Result.ok({ ...candidate, plan: candidate.plan });
|
|
2340
|
+
};
|
|
2341
|
+
|
|
2342
|
+
const runPlanRegrade = async (
|
|
2343
|
+
input: RegradePlanInput,
|
|
2344
|
+
rootDir: string,
|
|
2345
|
+
configScope?: RegradeConfigScope | undefined,
|
|
2346
|
+
shouldDryRun = false
|
|
2347
|
+
): Promise<TrailsResult<RegradePlanArtifact, Error>> => {
|
|
2348
|
+
if (input.classIds !== undefined || input.type === 'class') {
|
|
2349
|
+
return runClassPlanRegrade(input, rootDir, configScope, shouldDryRun);
|
|
2350
|
+
}
|
|
2351
|
+
if (input.type !== undefined && input.type !== 'vocabulary') {
|
|
2352
|
+
return Result.err(
|
|
2353
|
+
new ValidationError(`Unsupported Regrade plan type "${input.type}".`)
|
|
2354
|
+
);
|
|
2355
|
+
}
|
|
2356
|
+
if (input.name !== undefined) {
|
|
2357
|
+
return Result.err(
|
|
2358
|
+
new ValidationError(
|
|
2359
|
+
'`name` names a class-mode transition; vocabulary transitions are keyed by `from`/`to`.'
|
|
2360
|
+
)
|
|
2361
|
+
);
|
|
2362
|
+
}
|
|
2363
|
+
const planInput: RegradeInput = {
|
|
2364
|
+
...input,
|
|
2365
|
+
apply: false,
|
|
2366
|
+
check: false,
|
|
2367
|
+
writeRecord: false,
|
|
2368
|
+
};
|
|
2369
|
+
const planResult = buildVocabularyPlan(
|
|
2370
|
+
planInput,
|
|
2371
|
+
vocabularyScopeFromConfig(configScope)
|
|
2372
|
+
);
|
|
2373
|
+
if (planResult.isErr()) {
|
|
2374
|
+
return planResult;
|
|
2375
|
+
}
|
|
2376
|
+
if (!regradeRootIsReadable(rootDir)) {
|
|
2377
|
+
return regradeRootNotFound(rootDir);
|
|
2378
|
+
}
|
|
2379
|
+
const currentPath = regradePlanPathForPlan(rootDir, planResult.value);
|
|
2380
|
+
const currentResult = readCurrentVocabularyPlanArtifact(input, currentPath);
|
|
2381
|
+
if (currentResult.isErr()) {
|
|
2382
|
+
return currentResult;
|
|
2383
|
+
}
|
|
2384
|
+
const current = currentResult.value ?? undefined;
|
|
2385
|
+
const plan =
|
|
2386
|
+
current === undefined
|
|
2387
|
+
? planResult.value
|
|
2388
|
+
: mergeAuthoredPlanFields(current, planResult.value);
|
|
2389
|
+
const preserveInventory = await deriveLiveApiPreserveInventory(plan);
|
|
2390
|
+
const report = runResolvedVocabularyPlan({
|
|
2391
|
+
apply: false,
|
|
2392
|
+
includeEntries: input.includeEntries,
|
|
2393
|
+
plan,
|
|
2394
|
+
preserveInventory,
|
|
2395
|
+
rootDir,
|
|
2396
|
+
});
|
|
2397
|
+
if (report.isErr()) {
|
|
2398
|
+
return report;
|
|
2399
|
+
}
|
|
2400
|
+
const expansion = mergeRegradePlanExpansion(
|
|
2401
|
+
current?.expansion,
|
|
2402
|
+
input.expand ? expansionForReport(report.value) : undefined,
|
|
2403
|
+
plan
|
|
2404
|
+
);
|
|
2405
|
+
const transitionId = priorTransitionId(currentPath, 'vocabulary');
|
|
2406
|
+
const artifact = buildRegradePlanArtifact({
|
|
2407
|
+
...(expansion === undefined ? {} : { expansion }),
|
|
2408
|
+
input,
|
|
2409
|
+
plan,
|
|
2410
|
+
report: report.value,
|
|
2411
|
+
rootDir,
|
|
2412
|
+
...(transitionId === undefined ? {} : { transitionId }),
|
|
2413
|
+
});
|
|
2414
|
+
const mergedArtifact =
|
|
2415
|
+
current === undefined
|
|
2416
|
+
? artifact
|
|
2417
|
+
: {
|
|
2418
|
+
...artifact,
|
|
2419
|
+
provenance: preserveAuthoredPlanProvenance(
|
|
2420
|
+
current,
|
|
2421
|
+
artifact.provenance
|
|
2422
|
+
),
|
|
2423
|
+
};
|
|
2424
|
+
if (shouldDryRun) {
|
|
2425
|
+
return validateRegradePlanArtifact(mergedArtifact);
|
|
2426
|
+
}
|
|
2427
|
+
return writeRegradePlanArtifact(rootDir, mergedArtifact);
|
|
2428
|
+
};
|
|
2429
|
+
|
|
2430
|
+
const loadPlanForInput = async (
|
|
2431
|
+
input: RegradePlanReferenceInput,
|
|
2432
|
+
rootDir: string
|
|
2433
|
+
): Promise<
|
|
2434
|
+
TrailsResult<
|
|
2435
|
+
{ readonly artifact: RegradePlanArtifact; readonly path: string },
|
|
2436
|
+
Error
|
|
2437
|
+
>
|
|
2438
|
+
> => {
|
|
2439
|
+
const path = resolveRegradePlanPath(rootDir, input.plan);
|
|
2440
|
+
if (path.isErr()) {
|
|
2441
|
+
return path;
|
|
2442
|
+
}
|
|
2443
|
+
const artifact = readRegradePlanArtifact(path.value);
|
|
2444
|
+
if (artifact.isErr()) {
|
|
2445
|
+
return artifact;
|
|
2446
|
+
}
|
|
2447
|
+
return Result.ok({ artifact: artifact.value, path: path.value });
|
|
2448
|
+
};
|
|
2449
|
+
|
|
2450
|
+
const reportWithCheckedHistorySummary = (
|
|
2451
|
+
report: RegradeReport,
|
|
2452
|
+
historyPath: string
|
|
2453
|
+
): RegradeReport => ({
|
|
2454
|
+
...report,
|
|
2455
|
+
history: {
|
|
2456
|
+
path: historyPath,
|
|
2457
|
+
schemaVersion: REGRADE_HISTORY_SCHEMA_VERSION,
|
|
2458
|
+
status: 'checked',
|
|
2459
|
+
},
|
|
2460
|
+
});
|
|
2461
|
+
|
|
2462
|
+
/**
|
|
2463
|
+
* Check a graduated transition: verify every recorded run in the
|
|
2464
|
+
* consolidated history at its own stamped lock. Historical runs are not
|
|
2465
|
+
* re-executed — per-run stamp verification is the machine acceptance.
|
|
2466
|
+
*/
|
|
2467
|
+
const checkGraduatedRegradeHistory = (
|
|
2468
|
+
historyPath: string
|
|
2469
|
+
): TrailsResult<RegradeReport, Error> => {
|
|
2470
|
+
const artifact = readRegradeHistoryArtifact(historyPath);
|
|
2471
|
+
if (artifact.isErr()) {
|
|
2472
|
+
return artifact;
|
|
2473
|
+
}
|
|
2474
|
+
const verified = verifyRegradeHistoryRuns(artifact.value);
|
|
2475
|
+
if (verified.isErr()) {
|
|
2476
|
+
return verified;
|
|
2477
|
+
}
|
|
2478
|
+
const lastRun = artifact.value.runs.at(-1);
|
|
2479
|
+
if (lastRun === undefined) {
|
|
2480
|
+
return Result.err(
|
|
2481
|
+
new ValidationError('Regrade history has no recorded runs.', {
|
|
2482
|
+
context: { path: artifact.value.path },
|
|
2483
|
+
})
|
|
2484
|
+
);
|
|
2485
|
+
}
|
|
2486
|
+
return validateRegradeReport(
|
|
2487
|
+
reportWithCheckedHistorySummary(lastRun.report, artifact.value.path)
|
|
2488
|
+
);
|
|
2489
|
+
};
|
|
2490
|
+
|
|
2491
|
+
const runCheckRegradePlan = async (
|
|
2492
|
+
input: RegradePlanReferenceInput,
|
|
2493
|
+
rootDir: string
|
|
2494
|
+
): Promise<TrailsResult<RegradeReport, Error>> => {
|
|
2495
|
+
const planPath = resolveRegradePlanPath(rootDir, input.plan);
|
|
2496
|
+
if (planPath.isErr()) {
|
|
2497
|
+
if (input.plan !== undefined && !isPlanPathReference(input.plan)) {
|
|
2498
|
+
const historyPath = resolveRegradeHistoryPath(rootDir, input.plan);
|
|
2499
|
+
if (historyPath.isOk()) {
|
|
2500
|
+
return checkGraduatedRegradeHistory(historyPath.value);
|
|
2501
|
+
}
|
|
2502
|
+
}
|
|
2503
|
+
return planPath;
|
|
2504
|
+
}
|
|
2505
|
+
const artifact = readRegradePlanArtifact(planPath.value);
|
|
2506
|
+
if (artifact.isErr()) {
|
|
2507
|
+
return artifact;
|
|
2508
|
+
}
|
|
2509
|
+
const loaded = {
|
|
2510
|
+
value: { artifact: artifact.value, path: planPath.value },
|
|
2511
|
+
};
|
|
2512
|
+
const report = await runPlanArtifactDryRun({
|
|
2513
|
+
artifact: loaded.value.artifact,
|
|
2514
|
+
includeEntries: input.includeEntries,
|
|
2515
|
+
rootDir,
|
|
2516
|
+
});
|
|
2517
|
+
if (report.isErr()) {
|
|
2518
|
+
return report;
|
|
2519
|
+
}
|
|
2520
|
+
const status = planStatusForReport(loaded.value.artifact, report.value);
|
|
2521
|
+
const checked = reportWithPlanSummary(
|
|
2522
|
+
report.value,
|
|
2523
|
+
loaded.value.artifact,
|
|
2524
|
+
status
|
|
2525
|
+
);
|
|
2526
|
+
if (status === 'stale') {
|
|
2527
|
+
return Result.err(
|
|
2528
|
+
new ValidationError(
|
|
2529
|
+
'Regrade plan is stale for the current source tree.',
|
|
2530
|
+
{
|
|
2531
|
+
context: { plan: loaded.value.artifact.path },
|
|
2532
|
+
}
|
|
2533
|
+
)
|
|
2534
|
+
);
|
|
2535
|
+
}
|
|
2536
|
+
const gateContext = regradePlanGateContext(checked);
|
|
2537
|
+
if (gateContext !== undefined) {
|
|
2538
|
+
return Result.err(
|
|
2539
|
+
new ValidationError('Regrade plan gate is open.', {
|
|
2540
|
+
context: {
|
|
2541
|
+
...gateContext,
|
|
2542
|
+
plan: loaded.value.artifact.path,
|
|
2543
|
+
},
|
|
2544
|
+
})
|
|
2545
|
+
);
|
|
2546
|
+
}
|
|
2547
|
+
return validateRegradeReport(checked);
|
|
2548
|
+
};
|
|
2549
|
+
|
|
2550
|
+
const runPreviewRegradePlan = async (
|
|
2551
|
+
input: RegradePlanReferenceInput,
|
|
2552
|
+
rootDir: string
|
|
2553
|
+
): Promise<TrailsResult<RegradeReport, Error>> => {
|
|
2554
|
+
const loaded = await loadPlanForInput(input, rootDir);
|
|
2555
|
+
if (loaded.isErr()) {
|
|
2556
|
+
return loaded;
|
|
2557
|
+
}
|
|
2558
|
+
const report = await runPlanArtifactDryRun({
|
|
2559
|
+
artifact: loaded.value.artifact,
|
|
2560
|
+
includeEntries: input.includeEntries,
|
|
2561
|
+
rootDir,
|
|
2562
|
+
});
|
|
2563
|
+
if (report.isErr()) {
|
|
2564
|
+
return report;
|
|
2565
|
+
}
|
|
2566
|
+
return validateRegradeReport(
|
|
2567
|
+
reportWithPlanSummary(
|
|
2568
|
+
report.value,
|
|
2569
|
+
loaded.value.artifact,
|
|
2570
|
+
planStatusForReport(loaded.value.artifact, report.value)
|
|
2571
|
+
)
|
|
2572
|
+
);
|
|
2573
|
+
};
|
|
2574
|
+
|
|
2575
|
+
const writeRegradeHistory = (params: {
|
|
2576
|
+
readonly artifact: RegradePlanArtifact;
|
|
2577
|
+
readonly completedReport: RegradeReport;
|
|
2578
|
+
readonly planPath: string;
|
|
2579
|
+
readonly report: RegradeReport;
|
|
2580
|
+
readonly rootDir: string;
|
|
2581
|
+
}): TrailsResult<RegradeHistorySummary, Error> => {
|
|
2582
|
+
const absolutePath = regradeHistoryPathForPlan(
|
|
2583
|
+
params.rootDir,
|
|
2584
|
+
params.artifact.plan
|
|
2585
|
+
);
|
|
2586
|
+
let priorHistoryBytes: string | undefined;
|
|
2587
|
+
if (existsSync(absolutePath)) {
|
|
2588
|
+
try {
|
|
2589
|
+
priorHistoryBytes = readFileSync(absolutePath, 'utf8');
|
|
2590
|
+
} catch (error) {
|
|
2591
|
+
return Result.err(
|
|
2592
|
+
new InternalError('Failed to read Regrade history entry.', {
|
|
2593
|
+
...(error instanceof Error ? { cause: error } : {}),
|
|
2594
|
+
context: { path: rootRelativePath(params.rootDir, absolutePath) },
|
|
2595
|
+
})
|
|
2596
|
+
);
|
|
2597
|
+
}
|
|
2598
|
+
}
|
|
2599
|
+
const appended = appendRegradeHistoryRun({
|
|
2600
|
+
artifact: params.artifact,
|
|
2601
|
+
completedReport: params.completedReport,
|
|
2602
|
+
report: params.report,
|
|
2603
|
+
rootDir: params.rootDir,
|
|
2604
|
+
});
|
|
2605
|
+
if (appended.isErr()) {
|
|
2606
|
+
return appended;
|
|
2607
|
+
}
|
|
2608
|
+
try {
|
|
2609
|
+
// Apply always consumes the active plan, replay included: the plan is a
|
|
2610
|
+
// single-use apply intent, and the consolidated history already records
|
|
2611
|
+
// the run the replay repeats.
|
|
2612
|
+
rmSync(params.planPath, { force: true });
|
|
2613
|
+
} catch (error) {
|
|
2614
|
+
try {
|
|
2615
|
+
if (priorHistoryBytes === undefined) {
|
|
2616
|
+
rmSync(absolutePath, { force: true });
|
|
2617
|
+
} else {
|
|
2618
|
+
writeFileSync(absolutePath, priorHistoryBytes);
|
|
2619
|
+
}
|
|
2620
|
+
} catch {
|
|
2621
|
+
// Best-effort rollback; the surfaced error below preserves the primary failure.
|
|
2622
|
+
}
|
|
2623
|
+
return Result.err(
|
|
2624
|
+
new InternalError('Failed to remove active Regrade plan.', {
|
|
2625
|
+
...(error instanceof Error ? { cause: error } : {}),
|
|
2626
|
+
context: {
|
|
2627
|
+
history: appended.value.path,
|
|
2628
|
+
plan: rootRelativePath(params.rootDir, params.planPath),
|
|
2629
|
+
},
|
|
2630
|
+
})
|
|
2631
|
+
);
|
|
2632
|
+
}
|
|
2633
|
+
return appended;
|
|
2634
|
+
};
|
|
2635
|
+
|
|
2636
|
+
const historyReportForAppliedPlan = (
|
|
2637
|
+
dryRunReport: RegradeReport,
|
|
2638
|
+
appliedReport: RegradeReport
|
|
2639
|
+
): RegradeReport => ({
|
|
2640
|
+
...dryRunReport,
|
|
2641
|
+
...(appliedReport.apply === undefined ? {} : { apply: appliedReport.apply }),
|
|
2642
|
+
...(dryRunReport.run === undefined
|
|
2643
|
+
? {}
|
|
2644
|
+
: {
|
|
2645
|
+
run: {
|
|
2646
|
+
...dryRunReport.run,
|
|
2647
|
+
report:
|
|
2648
|
+
appliedReport.run?.report ??
|
|
2649
|
+
transitionRunReportForRegradeReport(appliedReport),
|
|
2650
|
+
},
|
|
2651
|
+
}),
|
|
2652
|
+
});
|
|
2653
|
+
|
|
2654
|
+
const runApplyRegradePlan = async (
|
|
2655
|
+
input: RegradeApplyPlanInput,
|
|
2656
|
+
rootDir: string,
|
|
2657
|
+
shouldDryRun: boolean
|
|
2658
|
+
): Promise<TrailsResult<RegradeReport, Error>> => {
|
|
2659
|
+
const loaded = await loadPlanForInput(input, rootDir);
|
|
2660
|
+
if (loaded.isErr()) {
|
|
2661
|
+
return loaded;
|
|
2662
|
+
}
|
|
2663
|
+
const dryRunReport = await runPlanArtifactDryRun({
|
|
2664
|
+
artifact: loaded.value.artifact,
|
|
2665
|
+
includeEntries: input.includeEntries,
|
|
2666
|
+
rootDir,
|
|
2667
|
+
});
|
|
2668
|
+
if (dryRunReport.isErr()) {
|
|
2669
|
+
return dryRunReport;
|
|
2670
|
+
}
|
|
2671
|
+
const status = planStatusForReport(loaded.value.artifact, dryRunReport.value);
|
|
2672
|
+
if (status === 'stale') {
|
|
2673
|
+
return Result.err(
|
|
2674
|
+
new ValidationError(
|
|
2675
|
+
'Regrade plan is stale for the current source tree.',
|
|
2676
|
+
{
|
|
2677
|
+
context: { plan: loaded.value.artifact.path },
|
|
2678
|
+
}
|
|
2679
|
+
)
|
|
2680
|
+
);
|
|
2681
|
+
}
|
|
2682
|
+
if (shouldDryRun) {
|
|
2683
|
+
return validateRegradeReport(
|
|
2684
|
+
reportWithPlanSummary(dryRunReport.value, loaded.value.artifact, status)
|
|
2685
|
+
);
|
|
2686
|
+
}
|
|
2687
|
+
|
|
2688
|
+
const planBody = loaded.value.artifact.plan;
|
|
2689
|
+
let applied: TrailsResult<RegradeReport, Error>;
|
|
2690
|
+
if (planBody.kind === 'class') {
|
|
2691
|
+
applied = await runClassPlanRegradeRun({
|
|
2692
|
+
apply: true,
|
|
2693
|
+
includeEntries: input.includeEntries,
|
|
2694
|
+
plan: planBody,
|
|
2695
|
+
rootDir,
|
|
2696
|
+
});
|
|
2697
|
+
} else {
|
|
2698
|
+
const preserveInventory = await deriveLiveApiPreserveInventory(planBody);
|
|
2699
|
+
applied = runResolvedVocabularyPlan({
|
|
2700
|
+
apply: true,
|
|
2701
|
+
includeEntries: input.includeEntries,
|
|
2702
|
+
plan: planBody,
|
|
2703
|
+
preserveInventory,
|
|
2704
|
+
rootDir,
|
|
2705
|
+
});
|
|
2706
|
+
}
|
|
2707
|
+
if (applied.isErr()) {
|
|
2708
|
+
return applied;
|
|
2709
|
+
}
|
|
2710
|
+
const completionReport = await runPlanArtifactDryRun({
|
|
2711
|
+
artifact: loaded.value.artifact,
|
|
2712
|
+
includeEntries: input.includeEntries,
|
|
2713
|
+
rootDir,
|
|
2714
|
+
});
|
|
2715
|
+
if (completionReport.isErr()) {
|
|
2716
|
+
return completionReport;
|
|
2717
|
+
}
|
|
2718
|
+
// Keep the pre-apply occurrence evidence that explains what this run changed,
|
|
2719
|
+
// while carrying the completed counters and a separate post-apply source
|
|
2720
|
+
// stamp so a later no-op apply can still be recognized as a replay.
|
|
2721
|
+
const history = writeRegradeHistory({
|
|
2722
|
+
artifact: loaded.value.artifact,
|
|
2723
|
+
completedReport: completionReport.value,
|
|
2724
|
+
planPath: loaded.value.path,
|
|
2725
|
+
report: historyReportForAppliedPlan(dryRunReport.value, applied.value),
|
|
2726
|
+
rootDir,
|
|
2727
|
+
});
|
|
2728
|
+
if (history.isErr()) {
|
|
2729
|
+
return history;
|
|
2730
|
+
}
|
|
2731
|
+
return validateRegradeReport(
|
|
2732
|
+
reportWithHistorySummary(
|
|
2733
|
+
reportWithPlanSummary(applied.value, loaded.value.artifact, status),
|
|
2734
|
+
history.value
|
|
2735
|
+
)
|
|
2736
|
+
);
|
|
2737
|
+
};
|
|
2738
|
+
|
|
2739
|
+
/**
|
|
2740
|
+
* Pull a graduated transition back from consolidated history into an active
|
|
2741
|
+
* plan for adjustment. The pulled-back artifact is authored intent only —
|
|
2742
|
+
* plan body, provenance, and any staged expansion; the run ledger stays
|
|
2743
|
+
* behind in the graduated history file, which adjust never touches. The
|
|
2744
|
+
* transition's stable id is preserved so the re-run's apply appends to the
|
|
2745
|
+
* same consolidated history spine instead of forking it.
|
|
2746
|
+
*/
|
|
2747
|
+
const runAdjustRegrade = async (
|
|
2748
|
+
input: RegradeAdjustInput,
|
|
2749
|
+
rootDir: string,
|
|
2750
|
+
shouldDryRun: boolean
|
|
2751
|
+
): Promise<TrailsResult<RegradePlanArtifact, Error>> => {
|
|
2752
|
+
const historyPath = resolveRegradeHistoryPath(rootDir, input.transition);
|
|
2753
|
+
if (historyPath.isErr()) {
|
|
2754
|
+
return historyPath;
|
|
2755
|
+
}
|
|
2756
|
+
const history = readRegradeHistoryArtifact(historyPath.value);
|
|
2757
|
+
if (history.isErr()) {
|
|
2758
|
+
return history;
|
|
2759
|
+
}
|
|
2760
|
+
const lastRun = history.value.runs.at(-1);
|
|
2761
|
+
if (lastRun === undefined) {
|
|
2762
|
+
return Result.err(
|
|
2763
|
+
new ValidationError('Regrade history has no recorded runs.', {
|
|
2764
|
+
context: { path: history.value.path },
|
|
2765
|
+
})
|
|
2766
|
+
);
|
|
2767
|
+
}
|
|
2768
|
+
const lastPlan = lastRun.plan;
|
|
2769
|
+
const activePath = regradePlanPathForPlan(rootDir, lastPlan.plan);
|
|
2770
|
+
if (existsSync(activePath)) {
|
|
2771
|
+
return Result.err(
|
|
2772
|
+
new ValidationError(
|
|
2773
|
+
'An active Regrade plan for this transition already exists; edit or apply it instead of adjusting again.',
|
|
2774
|
+
{ context: { plan: rootRelativePath(rootDir, activePath) } }
|
|
2775
|
+
)
|
|
2776
|
+
);
|
|
2777
|
+
}
|
|
2778
|
+
const draft: RegradePlanArtifact = {
|
|
2779
|
+
...(lastPlan.expansion === undefined
|
|
2780
|
+
? {}
|
|
2781
|
+
: { expansion: lastPlan.expansion }),
|
|
2782
|
+
kind: 'regrade-plan',
|
|
2783
|
+
path: rootRelativePath(rootDir, activePath),
|
|
2784
|
+
plan: lastPlan.plan,
|
|
2785
|
+
provenance: lastPlan.provenance,
|
|
2786
|
+
schemaVersion: REGRADE_PLAN_SCHEMA_VERSION,
|
|
2787
|
+
sourceHash: lastPlan.sourceHash,
|
|
2788
|
+
transitionId: history.value.id,
|
|
2789
|
+
};
|
|
2790
|
+
// Re-derive the source hash against the current tree so the later apply's
|
|
2791
|
+
// staleness gate compares with today's occurrences, not the graduated
|
|
2792
|
+
// run's.
|
|
2793
|
+
const report = await runPlanArtifactDryRun({
|
|
2794
|
+
artifact: draft,
|
|
2795
|
+
includeEntries: 'actionable',
|
|
2796
|
+
rootDir,
|
|
2797
|
+
});
|
|
2798
|
+
if (report.isErr()) {
|
|
2799
|
+
return report;
|
|
2800
|
+
}
|
|
2801
|
+
const artifact: RegradePlanArtifact = {
|
|
2802
|
+
...draft,
|
|
2803
|
+
sourceHash: regradeSourceHash(report.value),
|
|
2804
|
+
};
|
|
2805
|
+
if (shouldDryRun) {
|
|
2806
|
+
return validateRegradePlanArtifact(artifact);
|
|
2807
|
+
}
|
|
2808
|
+
return writeRegradePlanArtifact(rootDir, artifact);
|
|
2809
|
+
};
|
|
2810
|
+
|
|
2811
|
+
const listRegradePlans = async (
|
|
2812
|
+
rootDir: string
|
|
2813
|
+
): Promise<TrailsResult<z.output<typeof regradePlansOutputSchema>, Error>> => {
|
|
2814
|
+
const plans: z.output<typeof regradePlanSummarySchema>[] = [];
|
|
2815
|
+
for (const path of collectActiveRegradePlanPaths(rootDir)) {
|
|
2816
|
+
const artifact = readRegradePlanArtifact(path);
|
|
2817
|
+
if (artifact.isErr()) {
|
|
2818
|
+
return artifact;
|
|
2819
|
+
}
|
|
2820
|
+
const report = await runPlanArtifactDryRun({
|
|
2821
|
+
artifact: artifact.value,
|
|
2822
|
+
includeEntries: 'actionable',
|
|
2823
|
+
rootDir,
|
|
2824
|
+
});
|
|
2825
|
+
if (report.isErr()) {
|
|
2826
|
+
return report;
|
|
2827
|
+
}
|
|
2828
|
+
const expansionPending = pendingExpansionCandidateCount(artifact.value);
|
|
2829
|
+
const body = artifact.value.plan;
|
|
2830
|
+
plans.push({
|
|
2831
|
+
...(body.kind === 'class' ? { classIds: [...body.classIds] } : {}),
|
|
2832
|
+
...(expansionPending === 0 ? {} : { expansionPending }),
|
|
2833
|
+
...(body.kind === 'vocabulary' ? { from: body.from, to: body.to } : {}),
|
|
2834
|
+
kind: body.kind,
|
|
2835
|
+
path: artifact.value.path,
|
|
2836
|
+
schemaVersion: artifact.value.schemaVersion,
|
|
2837
|
+
status: planStatusForReport(artifact.value, report.value),
|
|
2838
|
+
});
|
|
2839
|
+
}
|
|
2840
|
+
return Result.ok({ plans });
|
|
2841
|
+
};
|
|
2842
|
+
|
|
2843
|
+
const runClassModeRegrade = (
|
|
2844
|
+
input: RegradeInput,
|
|
2845
|
+
rootDir: string,
|
|
2846
|
+
configScope?: RegradeConfigScope | undefined
|
|
2847
|
+
): Promise<TrailsResult<RegradeReport, Error>> => {
|
|
2848
|
+
const collection = classModeCollection(input, configScope);
|
|
2849
|
+
return runClassRegradeCore({
|
|
2850
|
+
apply: input.apply,
|
|
2851
|
+
...(input.classIds === undefined ? {} : { classIds: input.classIds }),
|
|
2852
|
+
...(collection === undefined ? {} : { collection }),
|
|
2853
|
+
includeEntries: input.includeEntries,
|
|
2854
|
+
rootDir,
|
|
2855
|
+
});
|
|
2856
|
+
};
|
|
2857
|
+
|
|
2858
|
+
export const regradeTrail = trail('regrade', {
|
|
2859
|
+
args: ['from', 'to'],
|
|
2860
|
+
description: 'Run downstream migration checks and safe rewrites',
|
|
2861
|
+
implementation: async (input, ctx) => {
|
|
2862
|
+
const rootDirResult = resolveTrailRootDir(input.rootDir, ctx.cwd);
|
|
2863
|
+
if (rootDirResult.isErr()) {
|
|
2864
|
+
return rootDirResult;
|
|
2865
|
+
}
|
|
2866
|
+
|
|
2867
|
+
const configResult = await loadRegradeConfig({
|
|
2868
|
+
...(input.configPath === undefined
|
|
2869
|
+
? {}
|
|
2870
|
+
: { configPath: input.configPath }),
|
|
2871
|
+
env: ctx.env,
|
|
2872
|
+
rootDir: rootDirResult.value,
|
|
2873
|
+
});
|
|
2874
|
+
if (configResult.isErr()) {
|
|
2875
|
+
return configResult;
|
|
2876
|
+
}
|
|
2877
|
+
const configScope = configResult.value.config?.scope;
|
|
2878
|
+
|
|
2879
|
+
const reportResult = hasVocabularyInput(input)
|
|
2880
|
+
? await runVocabularyCommandRegrade(
|
|
2881
|
+
input,
|
|
2882
|
+
rootDirResult.value,
|
|
2883
|
+
configScope
|
|
2884
|
+
)
|
|
2885
|
+
: await runClassModeRegrade(input, rootDirResult.value, configScope);
|
|
2886
|
+
if (reportResult.isErr()) {
|
|
2887
|
+
return Result.err(reportResult.error);
|
|
2888
|
+
}
|
|
2889
|
+
const outputResult = validateOutput(
|
|
2890
|
+
regradeReportOutput,
|
|
2891
|
+
reportResult.value
|
|
2892
|
+
);
|
|
2893
|
+
if (outputResult.isErr()) {
|
|
2894
|
+
return Result.err(outputResult.error);
|
|
2895
|
+
}
|
|
2896
|
+
return Result.ok(outputResult.value);
|
|
2897
|
+
},
|
|
2898
|
+
input: regradeInputSchema,
|
|
2899
|
+
intent: 'write',
|
|
2900
|
+
output: regradeReportOutput,
|
|
2901
|
+
permit: 'public',
|
|
2902
|
+
});
|
|
2903
|
+
|
|
2904
|
+
export const planRegradeTrail = trail('plan.regrade', {
|
|
2905
|
+
args: ['from', 'to'],
|
|
2906
|
+
cli: { path: ['regrade', 'plan'] },
|
|
2907
|
+
description: 'Write or update a reviewed Regrade plan',
|
|
2908
|
+
implementation: async (input, ctx) => {
|
|
2909
|
+
const rootDirResult = resolveTrailRootDir(input.rootDir, ctx.cwd);
|
|
2910
|
+
if (rootDirResult.isErr()) {
|
|
2911
|
+
return rootDirResult;
|
|
2912
|
+
}
|
|
2913
|
+
|
|
2914
|
+
const configResult = await loadRegradeConfig({
|
|
2915
|
+
...(input.configPath === undefined
|
|
2916
|
+
? {}
|
|
2917
|
+
: { configPath: input.configPath }),
|
|
2918
|
+
env: ctx.env,
|
|
2919
|
+
rootDir: rootDirResult.value,
|
|
2920
|
+
});
|
|
2921
|
+
if (configResult.isErr()) {
|
|
2922
|
+
return configResult;
|
|
2923
|
+
}
|
|
2924
|
+
|
|
2925
|
+
const result = await runPlanRegrade(
|
|
2926
|
+
input,
|
|
2927
|
+
rootDirResult.value,
|
|
2928
|
+
configResult.value.config?.scope,
|
|
2929
|
+
ctx.dryRun === true
|
|
2930
|
+
);
|
|
2931
|
+
if (result.isErr()) {
|
|
2932
|
+
return result;
|
|
2933
|
+
}
|
|
2934
|
+
const output = regradePlanArtifactSchema.safeParse(result.value);
|
|
2935
|
+
if (!output.success) {
|
|
2936
|
+
return Result.err(
|
|
2937
|
+
new ValidationError('Invalid Regrade plan output.', {
|
|
2938
|
+
context: { issues: output.error.issues },
|
|
2939
|
+
})
|
|
2940
|
+
);
|
|
2941
|
+
}
|
|
2942
|
+
return Result.ok(output.data);
|
|
2943
|
+
},
|
|
2944
|
+
input: regradePlanInputSchema,
|
|
2945
|
+
intent: 'write',
|
|
2946
|
+
output: regradePlanArtifactSchema,
|
|
2947
|
+
permit: 'public',
|
|
2948
|
+
});
|
|
2949
|
+
|
|
2950
|
+
export const listRegradesTrail = trail('list.regrades', {
|
|
2951
|
+
cli: { path: ['regrade', 'plans'] },
|
|
2952
|
+
description: 'List active Regrade plans and freshness status',
|
|
2953
|
+
implementation: async (input, ctx) => {
|
|
2954
|
+
const rootDirResult = resolveTrailRootDir(input.rootDir, ctx.cwd);
|
|
2955
|
+
if (rootDirResult.isErr()) {
|
|
2956
|
+
return rootDirResult;
|
|
2957
|
+
}
|
|
2958
|
+
const result = await listRegradePlans(rootDirResult.value);
|
|
2959
|
+
if (result.isErr()) {
|
|
2960
|
+
return result;
|
|
2961
|
+
}
|
|
2962
|
+
return Result.ok(result.value);
|
|
2963
|
+
},
|
|
2964
|
+
input: z.object({
|
|
2965
|
+
rootDir: z.string().optional().describe('Workspace root directory'),
|
|
2966
|
+
}),
|
|
2967
|
+
intent: 'read',
|
|
2968
|
+
output: regradePlansOutputSchema,
|
|
2969
|
+
permit: 'public',
|
|
2970
|
+
});
|
|
2971
|
+
|
|
2972
|
+
export const checkRegradeTrail = trail('check.regrade', {
|
|
2973
|
+
cli: { path: ['regrade', 'check'] },
|
|
2974
|
+
description: 'Check a saved Regrade plan gate without writing source',
|
|
2975
|
+
implementation: async (input, ctx) => {
|
|
2976
|
+
const rootDirResult = resolveTrailRootDir(input.rootDir, ctx.cwd);
|
|
2977
|
+
if (rootDirResult.isErr()) {
|
|
2978
|
+
return rootDirResult;
|
|
2979
|
+
}
|
|
2980
|
+
const result = await runCheckRegradePlan(input, rootDirResult.value);
|
|
2981
|
+
if (result.isErr()) {
|
|
2982
|
+
return result;
|
|
2983
|
+
}
|
|
2984
|
+
const checked = {
|
|
2985
|
+
...result.value,
|
|
2986
|
+
check: {
|
|
2987
|
+
plan:
|
|
2988
|
+
result.value.plan?.path ??
|
|
2989
|
+
result.value.history?.path ??
|
|
2990
|
+
input.plan ??
|
|
2991
|
+
'',
|
|
2992
|
+
status: 'passed' as const,
|
|
2993
|
+
},
|
|
2994
|
+
};
|
|
2995
|
+
const output = validateOutput(regradeCheckOutputSchema, checked);
|
|
2996
|
+
if (output.isErr()) {
|
|
2997
|
+
return Result.err(output.error);
|
|
2998
|
+
}
|
|
2999
|
+
return Result.ok(output.value);
|
|
3000
|
+
},
|
|
3001
|
+
input: regradePlanReferenceInputSchema,
|
|
3002
|
+
intent: 'read',
|
|
3003
|
+
output: regradeCheckOutputSchema,
|
|
3004
|
+
permit: 'public',
|
|
3005
|
+
});
|
|
3006
|
+
|
|
3007
|
+
export const previewRegradeTrail = trail('preview.regrade', {
|
|
3008
|
+
cli: { path: ['regrade', 'preview'] },
|
|
3009
|
+
description: 'Preview a saved Regrade plan without writing source',
|
|
3010
|
+
implementation: async (input, ctx) => {
|
|
3011
|
+
const rootDirResult = resolveTrailRootDir(input.rootDir, ctx.cwd);
|
|
3012
|
+
if (rootDirResult.isErr()) {
|
|
3013
|
+
return rootDirResult;
|
|
3014
|
+
}
|
|
3015
|
+
const result = await runPreviewRegradePlan(input, rootDirResult.value);
|
|
3016
|
+
if (result.isErr()) {
|
|
3017
|
+
return result;
|
|
3018
|
+
}
|
|
3019
|
+
const output = validateOutput(regradeReportOutput, result.value);
|
|
3020
|
+
if (output.isErr()) {
|
|
3021
|
+
return Result.err(output.error);
|
|
3022
|
+
}
|
|
3023
|
+
return Result.ok(output.value);
|
|
3024
|
+
},
|
|
3025
|
+
input: regradePlanReferenceInputSchema,
|
|
3026
|
+
intent: 'read',
|
|
3027
|
+
output: regradeReportOutput,
|
|
3028
|
+
permit: 'public',
|
|
3029
|
+
});
|
|
3030
|
+
|
|
3031
|
+
export const applyRegradeTrail = trail('apply.regrade', {
|
|
3032
|
+
cli: { path: ['regrade', 'apply'] },
|
|
3033
|
+
description: 'Apply a saved Regrade plan and move it to history',
|
|
3034
|
+
implementation: async (input, ctx) => {
|
|
3035
|
+
const rootDirResult = resolveTrailRootDir(input.rootDir, ctx.cwd);
|
|
3036
|
+
if (rootDirResult.isErr()) {
|
|
3037
|
+
return rootDirResult;
|
|
3038
|
+
}
|
|
3039
|
+
const result = await runApplyRegradePlan(
|
|
3040
|
+
input,
|
|
3041
|
+
rootDirResult.value,
|
|
3042
|
+
ctx.dryRun === true
|
|
3043
|
+
);
|
|
3044
|
+
if (result.isErr()) {
|
|
3045
|
+
return result;
|
|
3046
|
+
}
|
|
3047
|
+
const output = validateOutput(regradeReportOutput, result.value);
|
|
3048
|
+
if (output.isErr()) {
|
|
3049
|
+
return Result.err(output.error);
|
|
3050
|
+
}
|
|
3051
|
+
return Result.ok(output.value);
|
|
3052
|
+
},
|
|
3053
|
+
input: regradeApplyPlanInputSchema,
|
|
3054
|
+
intent: 'write',
|
|
3055
|
+
output: regradeReportOutput,
|
|
3056
|
+
permit: 'public',
|
|
3057
|
+
});
|
|
3058
|
+
|
|
3059
|
+
export const adjustRegradeTrail = trail('adjust.regrade', {
|
|
3060
|
+
args: ['transition'],
|
|
3061
|
+
cli: { path: ['regrade', 'adjust'] },
|
|
3062
|
+
description:
|
|
3063
|
+
'Pull a graduated Regrade transition back to an active plan for adjustment',
|
|
3064
|
+
implementation: async (input, ctx) => {
|
|
3065
|
+
const rootDirResult = resolveTrailRootDir(input.rootDir, ctx.cwd);
|
|
3066
|
+
if (rootDirResult.isErr()) {
|
|
3067
|
+
return rootDirResult;
|
|
3068
|
+
}
|
|
3069
|
+
const result = await runAdjustRegrade(
|
|
3070
|
+
input,
|
|
3071
|
+
rootDirResult.value,
|
|
3072
|
+
ctx.dryRun === true
|
|
3073
|
+
);
|
|
3074
|
+
if (result.isErr()) {
|
|
3075
|
+
return result;
|
|
3076
|
+
}
|
|
3077
|
+
const output = regradePlanArtifactSchema.safeParse(result.value);
|
|
3078
|
+
if (!output.success) {
|
|
3079
|
+
return Result.err(
|
|
3080
|
+
new ValidationError('Invalid Regrade plan output.', {
|
|
3081
|
+
context: { issues: output.error.issues },
|
|
3082
|
+
})
|
|
3083
|
+
);
|
|
3084
|
+
}
|
|
3085
|
+
return Result.ok(output.data);
|
|
3086
|
+
},
|
|
3087
|
+
input: regradeAdjustInputSchema,
|
|
3088
|
+
intent: 'write',
|
|
3089
|
+
output: regradePlanArtifactSchema,
|
|
3090
|
+
permit: 'public',
|
|
3091
|
+
});
|