@slope-dev/slope 1.61.0 → 1.62.1
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/dist/cli/commands/doctor.d.ts.map +1 -1
- package/dist/cli/commands/doctor.js +21 -0
- package/dist/cli/commands/doctor.js.map +1 -1
- package/dist/cli/commands/hook.d.ts.map +1 -1
- package/dist/cli/commands/hook.js +17 -0
- package/dist/cli/commands/hook.js.map +1 -1
- package/dist/cli/commands/init.d.ts.map +1 -1
- package/dist/cli/commands/init.js +7 -0
- package/dist/cli/commands/init.js.map +1 -1
- package/dist/cli/commands/pr.d.ts +11 -3
- package/dist/cli/commands/pr.d.ts.map +1 -1
- package/dist/cli/commands/pr.js +81 -31
- package/dist/cli/commands/pr.js.map +1 -1
- package/dist/cli/commands/review-run.d.ts +40 -6
- package/dist/cli/commands/review-run.d.ts.map +1 -1
- package/dist/cli/commands/review-run.js +235 -105
- package/dist/cli/commands/review-run.js.map +1 -1
- package/dist/cli/commands/review-state.d.ts.map +1 -1
- package/dist/cli/commands/review-state.js +16 -3
- package/dist/cli/commands/review-state.js.map +1 -1
- package/dist/cli/commands/roadmap.d.ts.map +1 -1
- package/dist/cli/commands/roadmap.js +93 -3
- package/dist/cli/commands/roadmap.js.map +1 -1
- package/dist/cli/harness-hook-status.d.ts +8 -0
- package/dist/cli/harness-hook-status.d.ts.map +1 -0
- package/dist/cli/harness-hook-status.js +53 -0
- package/dist/cli/harness-hook-status.js.map +1 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/cli/registry.js +6 -0
- package/dist/cli/registry.js.map +1 -1
- package/dist/cli/review-diff.d.ts +72 -0
- package/dist/cli/review-diff.d.ts.map +1 -0
- package/dist/cli/review-diff.js +416 -0
- package/dist/cli/review-diff.js.map +1 -0
- package/dist/cli/roadmap-source-migration.d.ts +113 -0
- package/dist/cli/roadmap-source-migration.d.ts.map +1 -0
- package/dist/cli/roadmap-source-migration.js +721 -0
- package/dist/cli/roadmap-source-migration.js.map +1 -0
- package/dist/cli/roadmap-source-store.d.ts +7 -0
- package/dist/cli/roadmap-source-store.d.ts.map +1 -1
- package/dist/cli/roadmap-source-store.js +11 -2
- package/dist/cli/roadmap-source-store.js.map +1 -1
- package/dist/core/index.d.ts +2 -0
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +1 -0
- package/dist/core/index.js.map +1 -1
- package/dist/core/roadmap-migration.d.ts +101 -0
- package/dist/core/roadmap-migration.d.ts.map +1 -0
- package/dist/core/roadmap-migration.js +630 -0
- package/dist/core/roadmap-migration.js.map +1 -0
- package/dist/core/roadmap-sources.d.ts.map +1 -1
- package/dist/core/roadmap-sources.js +5 -1
- package/dist/core/roadmap-sources.js.map +1 -1
- package/package.json +1 -1
- package/templates/codex/plugins/slope/.codex-plugin/plugin.json +1 -1
|
@@ -0,0 +1,721 @@
|
|
|
1
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
2
|
+
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync, } from 'node:fs';
|
|
3
|
+
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
4
|
+
import { parseDocument, stringify } from 'yaml';
|
|
5
|
+
import { discoverScorecardFiles, normalizeDiagnosticPath, normalizeScorecard, parseRoadmapSourceDocument, parseRoadmapSourceProject, parseSprintNumber, serializeRoadmapProjection, validateRoadmapSourceFederation, validateScorecard, } from '../core/index.js';
|
|
6
|
+
import { computeRoadmapMigrationDigest, parseRoadmapMigrationMapping, planRoadmapMigration, } from '../core/roadmap-migration.js';
|
|
7
|
+
import { atomicWriteFileSync, withFileLockSync } from './atomic-write.js';
|
|
8
|
+
import { loadConfig } from './config.js';
|
|
9
|
+
import { loadRoadmapSourceStore, resolveRoadmapSourceManifest, validateRoadmapSourceStore, } from './roadmap-source-store.js';
|
|
10
|
+
const AUDIT_RELATIVE_PATH = 'migration/audit.json';
|
|
11
|
+
const NON_CORE_RELATIVE_PATH = 'migration/non-core.json';
|
|
12
|
+
const RECEIPT_RELATIVE_PATH = 'migration/receipt.json';
|
|
13
|
+
const JOURNAL_RELATIVE_PATH = '.federation/migration-journal.json';
|
|
14
|
+
const BACKUP_RELATIVE_PATH = '.federation/migration-backup.json';
|
|
15
|
+
function sha256(value) {
|
|
16
|
+
return createHash('sha256').update(value).digest('hex');
|
|
17
|
+
}
|
|
18
|
+
function textSha256(value) {
|
|
19
|
+
const text = Buffer.isBuffer(value) ? value.toString('utf8') : value;
|
|
20
|
+
return sha256(text.replace(/\r\n/g, '\n'));
|
|
21
|
+
}
|
|
22
|
+
function json(value) {
|
|
23
|
+
return `${JSON.stringify(value, null, 2)}\n`;
|
|
24
|
+
}
|
|
25
|
+
function portablePath(cwd, path) {
|
|
26
|
+
return normalizeDiagnosticPath(relative(cwd, path));
|
|
27
|
+
}
|
|
28
|
+
function samePath(left, right) {
|
|
29
|
+
return process.platform === 'win32'
|
|
30
|
+
? resolve(left).toLowerCase() === resolve(right).toLowerCase()
|
|
31
|
+
: resolve(left) === resolve(right);
|
|
32
|
+
}
|
|
33
|
+
function ensureWithin(root, path, label) {
|
|
34
|
+
const resolvedRoot = resolve(root);
|
|
35
|
+
const resolvedPath = resolve(path);
|
|
36
|
+
const rel = relative(resolvedRoot, resolvedPath);
|
|
37
|
+
if (rel === '..' || rel.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`) || isAbsolute(rel)) {
|
|
38
|
+
throw new Error(`${label} escapes ${normalizeDiagnosticPath(resolvedRoot)}: ${normalizeDiagnosticPath(resolvedPath)}`);
|
|
39
|
+
}
|
|
40
|
+
let existing = resolvedPath;
|
|
41
|
+
while (!existsSync(existing) && dirname(existing) !== existing)
|
|
42
|
+
existing = dirname(existing);
|
|
43
|
+
const realRoot = realpathSync(resolvedRoot);
|
|
44
|
+
const realExisting = realpathSync(existing);
|
|
45
|
+
const realRel = relative(realRoot, realExisting);
|
|
46
|
+
if (realRel === '..'
|
|
47
|
+
|| realRel.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`)
|
|
48
|
+
|| isAbsolute(realRel)) {
|
|
49
|
+
throw new Error(`${label} resolves outside ${normalizeDiagnosticPath(realRoot)}: ${normalizeDiagnosticPath(realExisting)}`);
|
|
50
|
+
}
|
|
51
|
+
return resolvedPath;
|
|
52
|
+
}
|
|
53
|
+
function atomicWriteBytes(path, bytes) {
|
|
54
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
55
|
+
const temporary = join(dirname(path), `.${randomBytes(10).toString('hex')}.migration.tmp`);
|
|
56
|
+
let fd;
|
|
57
|
+
try {
|
|
58
|
+
fd = openSync(temporary, 'wx');
|
|
59
|
+
writeFileSync(fd, bytes);
|
|
60
|
+
closeSync(fd);
|
|
61
|
+
fd = undefined;
|
|
62
|
+
renameSync(temporary, path);
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
if (fd !== undefined)
|
|
66
|
+
closeSync(fd);
|
|
67
|
+
if (existsSync(temporary))
|
|
68
|
+
unlinkSync(temporary);
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
function readMapping(cwd, mappingFlag) {
|
|
73
|
+
if (!mappingFlag)
|
|
74
|
+
return {};
|
|
75
|
+
const path = ensureWithin(cwd, resolve(cwd, mappingFlag), 'migration mapping path');
|
|
76
|
+
if (!existsSync(path))
|
|
77
|
+
throw new Error(`Roadmap migration mapping not found: ${portablePath(cwd, path)}`);
|
|
78
|
+
const bytes = readFileSync(path);
|
|
79
|
+
let raw;
|
|
80
|
+
try {
|
|
81
|
+
const document = parseDocument(bytes.toString('utf8'), { schema: 'core', uniqueKeys: true });
|
|
82
|
+
if (document.errors.length > 0)
|
|
83
|
+
throw new Error(document.errors[0].message);
|
|
84
|
+
if (document.warnings.length > 0)
|
|
85
|
+
throw new Error(document.warnings[0].message);
|
|
86
|
+
raw = document.toJS({ maxAliasCount: 100 });
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
throw new Error(`Could not parse migration mapping ${portablePath(cwd, path)}: ${error.message}`);
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
mapping: parseRoadmapMigrationMapping(raw),
|
|
93
|
+
path: portablePath(cwd, path),
|
|
94
|
+
sha: sha256(bytes),
|
|
95
|
+
textSha: textSha256(bytes),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
function collectScorecardEvidence(cwd, explicit = {}) {
|
|
99
|
+
const selected = new Map();
|
|
100
|
+
const inspect = (candidate, expectedSprint) => {
|
|
101
|
+
const absolutePath = ensureWithin(cwd, resolve(cwd, candidate), 'scorecard evidence path');
|
|
102
|
+
const path = portablePath(cwd, absolutePath);
|
|
103
|
+
if (!existsSync(absolutePath)) {
|
|
104
|
+
return { sprint: expectedSprint, evidence: { path, valid: false, reason: 'scorecard evidence file does not exist' } };
|
|
105
|
+
}
|
|
106
|
+
const bytes = readFileSync(absolutePath);
|
|
107
|
+
const artifact = { path, absolutePath, sha256: sha256(bytes), textSha256: textSha256(bytes) };
|
|
108
|
+
let raw;
|
|
109
|
+
try {
|
|
110
|
+
raw = JSON.parse(bytes.toString('utf8'));
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return { sprint: expectedSprint, evidence: { path, valid: false, reason: 'scorecard evidence is not valid JSON' }, artifact };
|
|
114
|
+
}
|
|
115
|
+
const sprint = parseSprintNumber(raw.sprint_number ?? raw.sprint);
|
|
116
|
+
const sprintKey = expectedSprint ?? (sprint == null ? undefined : String(sprint));
|
|
117
|
+
if (!sprintKey)
|
|
118
|
+
return { evidence: { path, valid: false, reason: 'scorecard evidence has no sprint number' }, artifact };
|
|
119
|
+
const validation = validateScorecard(normalizeScorecard(raw));
|
|
120
|
+
const sprintMatches = sprint != null && Number(sprintKey) === sprint;
|
|
121
|
+
const valid = validation.valid && sprintMatches;
|
|
122
|
+
return { sprint: sprintKey, artifact, evidence: {
|
|
123
|
+
path,
|
|
124
|
+
valid,
|
|
125
|
+
...(!valid ? {
|
|
126
|
+
reason: !sprintMatches
|
|
127
|
+
? `scorecard records ${String(raw.sprint_number ?? raw.sprint ?? 'no sprint')} instead of Sprint ${sprintKey}`
|
|
128
|
+
: validation.errors.slice(0, 3).map(issue => issue.message).join('; '),
|
|
129
|
+
} : {}),
|
|
130
|
+
} };
|
|
131
|
+
};
|
|
132
|
+
const config = { ...loadConfig(cwd), minSprint: 0 };
|
|
133
|
+
for (const discovered of discoverScorecardFiles(config, cwd)) {
|
|
134
|
+
const inspected = inspect(discovered);
|
|
135
|
+
if (inspected.sprint)
|
|
136
|
+
selected.set(inspected.sprint, { evidence: inspected.evidence, artifact: inspected.artifact });
|
|
137
|
+
}
|
|
138
|
+
for (const [sprint, path] of Object.entries(explicit).sort(([left], [right]) => Number(left) - Number(right))) {
|
|
139
|
+
const inspected = inspect(path, sprint);
|
|
140
|
+
if (inspected.artifact)
|
|
141
|
+
selected.set(sprint, { evidence: inspected.evidence, artifact: inspected.artifact });
|
|
142
|
+
else
|
|
143
|
+
selected.delete(sprint);
|
|
144
|
+
}
|
|
145
|
+
const evidence = Object.fromEntries([...selected.entries()].sort(([left], [right]) => Number(left) - Number(right))
|
|
146
|
+
.map(([sprint, item]) => [sprint, item.evidence]));
|
|
147
|
+
const artifactsByPath = new Map();
|
|
148
|
+
for (const item of selected.values())
|
|
149
|
+
if (item.artifact)
|
|
150
|
+
artifactsByPath.set(item.artifact.path, item.artifact);
|
|
151
|
+
const artifacts = [...artifactsByPath.values()].sort((a, b) => a.path.localeCompare(b.path));
|
|
152
|
+
return { evidence, artifacts };
|
|
153
|
+
}
|
|
154
|
+
function renderMigration(cwd, sourcePath, manifestPath, recordedAt, plan, inputs) {
|
|
155
|
+
const sourceRoot = dirname(manifestPath);
|
|
156
|
+
const project = {
|
|
157
|
+
version: '1',
|
|
158
|
+
name: plan.normalized_roadmap.name,
|
|
159
|
+
...(plan.normalized_roadmap.description
|
|
160
|
+
? { description: plan.normalized_roadmap.description }
|
|
161
|
+
: {}),
|
|
162
|
+
output: normalizeDiagnosticPath(relative(sourceRoot, sourcePath)),
|
|
163
|
+
sources: plan.sources.map(source => ({ path: source.path, kind: source.kind })),
|
|
164
|
+
};
|
|
165
|
+
const projectYaml = stringify(project, { lineWidth: 0 });
|
|
166
|
+
const parsedProject = parseRoadmapSourceProject(projectYaml, portablePath(cwd, manifestPath));
|
|
167
|
+
const loadedSources = plan.sources.map(source => {
|
|
168
|
+
const absolutePath = ensureWithin(cwd, resolve(sourceRoot, ...source.path.split('/')), 'migration bundle path');
|
|
169
|
+
const document = {
|
|
170
|
+
version: '1',
|
|
171
|
+
phase: source.phase,
|
|
172
|
+
sprints: source.sprints,
|
|
173
|
+
...(Object.keys(source.scorecards).length > 0 ? { scorecards: source.scorecards } : {}),
|
|
174
|
+
};
|
|
175
|
+
const contents = stringify(document, { lineWidth: 0 });
|
|
176
|
+
return {
|
|
177
|
+
entry: { path: source.path, kind: source.kind },
|
|
178
|
+
document: parseRoadmapSourceDocument(contents, source.path),
|
|
179
|
+
absolutePath,
|
|
180
|
+
};
|
|
181
|
+
});
|
|
182
|
+
const stagedValidation = validateRoadmapSourceFederation(parsedProject, loadedSources);
|
|
183
|
+
if (!stagedValidation.valid) {
|
|
184
|
+
throw new Error([
|
|
185
|
+
'Rendered roadmap migration sources are invalid:',
|
|
186
|
+
...stagedValidation.errors.slice(0, 10).map(issue => ` - ${issue.source ? `${issue.source}: ` : ''}${issue.message}`),
|
|
187
|
+
...(stagedValidation.errors.length > 10 ? [` - ... ${stagedValidation.errors.length - 10} additional error(s)`] : []),
|
|
188
|
+
].join('\n'));
|
|
189
|
+
}
|
|
190
|
+
const projection = serializeRoadmapProjection(stagedValidation.roadmap);
|
|
191
|
+
if (sha256(projection) !== plan.expected_projection_sha256) {
|
|
192
|
+
throw new Error('Rendered roadmap migration projection does not match the planner fidelity digest.');
|
|
193
|
+
}
|
|
194
|
+
const artifacts = loadedSources.map((source, index) => {
|
|
195
|
+
const contents = stringify({
|
|
196
|
+
version: '1',
|
|
197
|
+
phase: plan.sources[index].phase,
|
|
198
|
+
sprints: plan.sources[index].sprints,
|
|
199
|
+
...(Object.keys(plan.sources[index].scorecards).length > 0 ? { scorecards: plan.sources[index].scorecards } : {}),
|
|
200
|
+
}, { lineWidth: 0 });
|
|
201
|
+
return {
|
|
202
|
+
role: 'bundle',
|
|
203
|
+
path: portablePath(cwd, source.absolutePath),
|
|
204
|
+
absolutePath: source.absolutePath,
|
|
205
|
+
contents,
|
|
206
|
+
sha256: sha256(contents),
|
|
207
|
+
};
|
|
208
|
+
});
|
|
209
|
+
const auditPath = ensureWithin(cwd, join(sourceRoot, AUDIT_RELATIVE_PATH), 'migration audit path');
|
|
210
|
+
const nonCorePath = ensureWithin(cwd, join(sourceRoot, NON_CORE_RELATIVE_PATH), 'migration non-core path');
|
|
211
|
+
const receiptPath = ensureWithin(cwd, join(sourceRoot, RECEIPT_RELATIVE_PATH), 'migration receipt path');
|
|
212
|
+
const auditContents = json({
|
|
213
|
+
version: 1,
|
|
214
|
+
kind: 'roadmap_migration_audit',
|
|
215
|
+
recorded_at: recordedAt,
|
|
216
|
+
plan_sha256: plan.plan_sha256,
|
|
217
|
+
source_sha256: plan.source_sha256,
|
|
218
|
+
mapping_sha256: plan.mapping_sha256,
|
|
219
|
+
diagnostics: plan.diagnostics,
|
|
220
|
+
diagnostics_total: plan.diagnostics_total,
|
|
221
|
+
diagnostics_omitted: plan.diagnostics_omitted,
|
|
222
|
+
audit: plan.audit,
|
|
223
|
+
source_classification: plan.sources.map(source => ({
|
|
224
|
+
path: source.path,
|
|
225
|
+
classification: source.classification,
|
|
226
|
+
reasons: source.classification_reasons,
|
|
227
|
+
})),
|
|
228
|
+
});
|
|
229
|
+
const nonCoreContents = json({
|
|
230
|
+
version: 1,
|
|
231
|
+
kind: 'roadmap_migration_non_core_export',
|
|
232
|
+
source_sha256: plan.source_sha256,
|
|
233
|
+
fields: plan.non_core.fields,
|
|
234
|
+
});
|
|
235
|
+
artifacts.push({ role: 'audit', path: portablePath(cwd, auditPath), absolutePath: auditPath, contents: auditContents, sha256: sha256(auditContents) }, { role: 'non_core', path: portablePath(cwd, nonCorePath), absolutePath: nonCorePath, contents: nonCoreContents, sha256: sha256(nonCoreContents) }, { role: 'projection', path: portablePath(cwd, sourcePath), absolutePath: sourcePath, contents: projection, sha256: sha256(projection) }, { role: 'manifest', path: portablePath(cwd, manifestPath), absolutePath: manifestPath, contents: projectYaml, sha256: sha256(projectYaml) });
|
|
236
|
+
const migrationId = plan.plan_sha256.slice(0, 16);
|
|
237
|
+
const receiptOutputs = artifacts.map(artifact => {
|
|
238
|
+
if (artifact.role === 'receipt')
|
|
239
|
+
throw new Error('Receipt cannot include its own digest.');
|
|
240
|
+
return { role: artifact.role, path: artifact.path, sha256: artifact.sha256 };
|
|
241
|
+
});
|
|
242
|
+
const receiptWithoutIntegrity = {
|
|
243
|
+
version: 1,
|
|
244
|
+
kind: 'roadmap_migration_receipt',
|
|
245
|
+
digest_kind: 'text_crlf_normalized_sha256',
|
|
246
|
+
migration_id: migrationId,
|
|
247
|
+
recorded_at: recordedAt,
|
|
248
|
+
plan_sha256: plan.plan_sha256,
|
|
249
|
+
source: {
|
|
250
|
+
path: portablePath(cwd, sourcePath),
|
|
251
|
+
original_sha256: plan.source_sha256,
|
|
252
|
+
projection_sha256: sha256(projection),
|
|
253
|
+
},
|
|
254
|
+
manifest: { path: portablePath(cwd, manifestPath), sha256: sha256(projectYaml) },
|
|
255
|
+
audit_path: portablePath(cwd, auditPath),
|
|
256
|
+
non_core_path: portablePath(cwd, nonCorePath),
|
|
257
|
+
receipt_path: portablePath(cwd, receiptPath),
|
|
258
|
+
inputs: {
|
|
259
|
+
...(inputs.mappingPath && inputs.mappingTextSha256
|
|
260
|
+
? { mapping: { path: inputs.mappingPath, sha256: inputs.mappingTextSha256 } }
|
|
261
|
+
: {}),
|
|
262
|
+
scorecards: inputs.evidenceArtifacts.map(artifact => ({ path: artifact.path, sha256: artifact.textSha256 })),
|
|
263
|
+
},
|
|
264
|
+
summary: {
|
|
265
|
+
sources: plan.sources.length,
|
|
266
|
+
archives: plan.sources.filter(source => source.classification === 'archive').length,
|
|
267
|
+
history_unverified: plan.sources.filter(source => source.classification === 'history_unverified').length,
|
|
268
|
+
},
|
|
269
|
+
outputs: receiptOutputs.map(output => {
|
|
270
|
+
const artifact = artifacts.find(candidate => candidate.path === output.path);
|
|
271
|
+
return { ...output, sha256: textSha256(artifact.contents) };
|
|
272
|
+
}),
|
|
273
|
+
};
|
|
274
|
+
const receipt = {
|
|
275
|
+
...receiptWithoutIntegrity,
|
|
276
|
+
integrity_sha256: computeRoadmapMigrationDigest(receiptWithoutIntegrity),
|
|
277
|
+
};
|
|
278
|
+
const receiptContents = json(receipt);
|
|
279
|
+
artifacts.splice(artifacts.length - 2, 0, {
|
|
280
|
+
role: 'receipt',
|
|
281
|
+
path: portablePath(cwd, receiptPath),
|
|
282
|
+
absolutePath: receiptPath,
|
|
283
|
+
contents: receiptContents,
|
|
284
|
+
sha256: sha256(receiptContents),
|
|
285
|
+
});
|
|
286
|
+
const paths = new Set();
|
|
287
|
+
for (const artifact of artifacts) {
|
|
288
|
+
const key = process.platform === 'win32' ? artifact.absolutePath.toLowerCase() : artifact.absolutePath;
|
|
289
|
+
if (paths.has(key))
|
|
290
|
+
throw new Error(`Roadmap migration output collision: ${artifact.path}`);
|
|
291
|
+
paths.add(key);
|
|
292
|
+
}
|
|
293
|
+
return { artifacts, receipt };
|
|
294
|
+
}
|
|
295
|
+
function basePaths(input) {
|
|
296
|
+
const cwd = realpathSync(resolve(input.cwd));
|
|
297
|
+
const config = loadConfig(cwd);
|
|
298
|
+
const configuredPath = ensureWithin(cwd, resolve(cwd, config.roadmapPath), 'configured roadmap path');
|
|
299
|
+
const sourcePath = ensureWithin(cwd, resolve(cwd, input.path ?? config.roadmapPath), 'migration roadmap path');
|
|
300
|
+
if (!samePath(sourcePath, configuredPath)) {
|
|
301
|
+
throw new Error(`Migration --path must equal configured roadmapPath ${portablePath(cwd, configuredPath)}.`);
|
|
302
|
+
}
|
|
303
|
+
if (!existsSync(sourcePath))
|
|
304
|
+
throw new Error(`Roadmap migration source not found: ${portablePath(cwd, sourcePath)}`);
|
|
305
|
+
if (realpathSync(sourcePath) !== realpathSync(configuredPath)) {
|
|
306
|
+
throw new Error('Migration --path and configured roadmapPath do not resolve to the same file.');
|
|
307
|
+
}
|
|
308
|
+
const manifestPath = resolveRoadmapSourceManifest(cwd, input.source);
|
|
309
|
+
const sourceRoot = dirname(manifestPath);
|
|
310
|
+
if (samePath(sourcePath, manifestPath))
|
|
311
|
+
throw new Error('Migration source and manifest paths must be different.');
|
|
312
|
+
return {
|
|
313
|
+
input: { ...input, cwd },
|
|
314
|
+
sourcePath,
|
|
315
|
+
sourceRelativePath: portablePath(cwd, sourcePath),
|
|
316
|
+
manifestPath,
|
|
317
|
+
manifestRelativePath: portablePath(cwd, manifestPath),
|
|
318
|
+
sourceRoot,
|
|
319
|
+
journalPath: ensureWithin(cwd, join(sourceRoot, JOURNAL_RELATIVE_PATH), 'migration journal path'),
|
|
320
|
+
backupPath: ensureWithin(cwd, join(sourceRoot, BACKUP_RELATIVE_PATH), 'migration backup path'),
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
function parseReceipt(base) {
|
|
324
|
+
const receiptPath = ensureWithin(base.input.cwd, join(base.sourceRoot, RECEIPT_RELATIVE_PATH), 'migration receipt path');
|
|
325
|
+
if (!existsSync(receiptPath))
|
|
326
|
+
return null;
|
|
327
|
+
let receipt;
|
|
328
|
+
try {
|
|
329
|
+
receipt = JSON.parse(readFileSync(receiptPath, 'utf8'));
|
|
330
|
+
}
|
|
331
|
+
catch {
|
|
332
|
+
return null;
|
|
333
|
+
}
|
|
334
|
+
if (!receipt || typeof receipt !== 'object' || !receipt.source || !receipt.manifest || !receipt.inputs || !receipt.summary
|
|
335
|
+
|| !Array.isArray(receipt.outputs) || typeof receipt.integrity_sha256 !== 'string')
|
|
336
|
+
return null;
|
|
337
|
+
const { integrity_sha256: receiptIntegrity, ...receiptPayload } = receipt;
|
|
338
|
+
if (receipt.version !== 1 || receipt.kind !== 'roadmap_migration_receipt'
|
|
339
|
+
|| receipt.digest_kind !== 'text_crlf_normalized_sha256'
|
|
340
|
+
|| receipt.receipt_path !== portablePath(base.input.cwd, receiptPath)
|
|
341
|
+
|| receipt.manifest.path !== base.manifestRelativePath
|
|
342
|
+
|| receipt.source.path !== base.sourceRelativePath
|
|
343
|
+
|| receiptIntegrity !== computeRoadmapMigrationDigest(receiptPayload)) {
|
|
344
|
+
return null;
|
|
345
|
+
}
|
|
346
|
+
if (!Array.isArray(receipt.inputs.scorecards))
|
|
347
|
+
return null;
|
|
348
|
+
for (const evidence of receipt.inputs.scorecards) {
|
|
349
|
+
if (!evidence || typeof evidence.path !== 'string' || typeof evidence.sha256 !== 'string')
|
|
350
|
+
return null;
|
|
351
|
+
const path = ensureWithin(base.input.cwd, resolve(base.input.cwd, ...evidence.path.split('/')), 'receipt scorecard path');
|
|
352
|
+
if (!existsSync(path) || textSha256(readFileSync(path)) !== evidence.sha256)
|
|
353
|
+
return null;
|
|
354
|
+
}
|
|
355
|
+
if (base.input.mapping) {
|
|
356
|
+
const supplied = readMapping(base.input.cwd, base.input.mapping);
|
|
357
|
+
if (!receipt.inputs.mapping || supplied.path !== receipt.inputs.mapping.path
|
|
358
|
+
|| supplied.textSha !== receipt.inputs.mapping.sha256)
|
|
359
|
+
return null;
|
|
360
|
+
}
|
|
361
|
+
const roles = new Map();
|
|
362
|
+
const outputPaths = new Set();
|
|
363
|
+
for (const output of receipt.outputs) {
|
|
364
|
+
if (!output || typeof output.path !== 'string' || typeof output.sha256 !== 'string'
|
|
365
|
+
|| !/^[a-f0-9]{64}$/i.test(output.sha256)
|
|
366
|
+
|| !['bundle', 'audit', 'non_core', 'projection', 'manifest'].includes(output.role))
|
|
367
|
+
return null;
|
|
368
|
+
if (output.role !== 'bundle' && roles.has(output.role))
|
|
369
|
+
return null;
|
|
370
|
+
if (output.role !== 'bundle')
|
|
371
|
+
roles.set(output.role, output);
|
|
372
|
+
const path = ensureWithin(base.input.cwd, resolve(base.input.cwd, ...output.path.split('/')), 'receipt output path');
|
|
373
|
+
const pathKey = process.platform === 'win32' ? path.toLowerCase() : path;
|
|
374
|
+
if (outputPaths.has(pathKey))
|
|
375
|
+
return null;
|
|
376
|
+
outputPaths.add(pathKey);
|
|
377
|
+
if (!existsSync(path) || textSha256(readFileSync(path)) !== output.sha256)
|
|
378
|
+
return null;
|
|
379
|
+
}
|
|
380
|
+
if (roles.get('manifest')?.path !== receipt.manifest.path
|
|
381
|
+
|| roles.get('manifest')?.sha256 !== receipt.manifest.sha256
|
|
382
|
+
|| roles.get('projection')?.path !== receipt.source.path
|
|
383
|
+
|| roles.get('projection')?.sha256 !== receipt.source.projection_sha256
|
|
384
|
+
|| roles.get('audit')?.path !== receipt.audit_path
|
|
385
|
+
|| roles.get('non_core')?.path !== receipt.non_core_path
|
|
386
|
+
|| receipt.summary.sources !== receipt.outputs.filter(output => output.role === 'bundle').length
|
|
387
|
+
|| !Number.isInteger(receipt.summary.archives) || receipt.summary.archives < 0
|
|
388
|
+
|| !Number.isInteger(receipt.summary.history_unverified) || receipt.summary.history_unverified < 0
|
|
389
|
+
|| receipt.summary.archives + receipt.summary.history_unverified > receipt.summary.sources)
|
|
390
|
+
return null;
|
|
391
|
+
if (textSha256(readFileSync(receiptPath)) !== textSha256(json(receipt)))
|
|
392
|
+
return null;
|
|
393
|
+
try {
|
|
394
|
+
const store = loadRoadmapSourceStore(base.input.cwd, base.manifestRelativePath);
|
|
395
|
+
if (!validateRoadmapSourceStore(store).valid)
|
|
396
|
+
return null;
|
|
397
|
+
const receiptBundles = new Set(receipt.outputs.filter(output => output.role === 'bundle').map(output => output.path));
|
|
398
|
+
if (store.sources.some(source => !source.absolutePath || !receiptBundles.has(portablePath(base.input.cwd, source.absolutePath))))
|
|
399
|
+
return null;
|
|
400
|
+
}
|
|
401
|
+
catch {
|
|
402
|
+
return null;
|
|
403
|
+
}
|
|
404
|
+
return receipt;
|
|
405
|
+
}
|
|
406
|
+
function readBackup(base) {
|
|
407
|
+
let backup;
|
|
408
|
+
try {
|
|
409
|
+
backup = JSON.parse(readFileSync(base.backupPath, 'utf8'));
|
|
410
|
+
}
|
|
411
|
+
catch (error) {
|
|
412
|
+
throw new Error(`Could not read roadmap migration backup: ${error.message}`);
|
|
413
|
+
}
|
|
414
|
+
if (typeof backup.bytes_base64 !== 'string')
|
|
415
|
+
throw new Error('Roadmap migration backup is malformed.');
|
|
416
|
+
const bytes = Buffer.from(backup.bytes_base64, 'base64');
|
|
417
|
+
if (backup.version !== 1 || backup.kind !== 'roadmap_migration_backup'
|
|
418
|
+
|| backup.source_path !== base.sourceRelativePath || sha256(bytes) !== backup.sha256) {
|
|
419
|
+
throw new Error('Roadmap migration backup failed integrity validation.');
|
|
420
|
+
}
|
|
421
|
+
return { backup, bytes };
|
|
422
|
+
}
|
|
423
|
+
export function prepareRoadmapSourceMigration(input) {
|
|
424
|
+
const base = basePaths(input);
|
|
425
|
+
const journalExists = existsSync(base.journalPath);
|
|
426
|
+
const backupExists = existsSync(base.backupPath);
|
|
427
|
+
if (existsSync(base.manifestPath)) {
|
|
428
|
+
const receipt = parseReceipt(base);
|
|
429
|
+
if (receipt)
|
|
430
|
+
return { ...base, status: 'unchanged', receipt };
|
|
431
|
+
if (journalExists && backupExists)
|
|
432
|
+
return { ...base, status: 'recovery_required' };
|
|
433
|
+
throw new Error(`Modular roadmap manifest already exists at ${base.manifestRelativePath}; refusing to replace hand-authored sources.`);
|
|
434
|
+
}
|
|
435
|
+
if (journalExists && !backupExists) {
|
|
436
|
+
throw new Error('Roadmap migration journal exists without its required backup; refusing unsafe recovery.');
|
|
437
|
+
}
|
|
438
|
+
if (backupExists)
|
|
439
|
+
return { ...base, status: 'recovery_required' };
|
|
440
|
+
const sourceBytes = readFileSync(base.sourcePath);
|
|
441
|
+
const sourceText = sourceBytes.toString('utf8');
|
|
442
|
+
const mapping = readMapping(base.input.cwd, input.mapping);
|
|
443
|
+
const evidence = collectScorecardEvidence(base.input.cwd, mapping.mapping?.scorecards);
|
|
444
|
+
const plan = planRoadmapMigration(sourceText, { mapping: mapping.mapping, evidence: evidence.evidence });
|
|
445
|
+
if (plan.source_sha256 !== sha256(sourceBytes)) {
|
|
446
|
+
throw new Error('Roadmap migration planner source digest does not match the exact input bytes.');
|
|
447
|
+
}
|
|
448
|
+
if (!plan.applicable)
|
|
449
|
+
return { ...base, status: 'blocked', plan };
|
|
450
|
+
const durableEvidencePaths = new Set(plan.sources.flatMap(source => Object.values(source.scorecards)));
|
|
451
|
+
const durableEvidenceArtifacts = evidence.artifacts.filter(artifact => durableEvidencePaths.has(artifact.path));
|
|
452
|
+
const recordedAt = input.recordedAt ?? new Date().toISOString();
|
|
453
|
+
const rendered = renderMigration(base.input.cwd, base.sourcePath, base.manifestPath, recordedAt, plan, {
|
|
454
|
+
mappingPath: mapping.path,
|
|
455
|
+
mappingTextSha256: mapping.textSha,
|
|
456
|
+
evidenceArtifacts: durableEvidenceArtifacts,
|
|
457
|
+
});
|
|
458
|
+
const simulated = {
|
|
459
|
+
cwd: base.input.cwd,
|
|
460
|
+
manifestPath: base.manifestPath,
|
|
461
|
+
sourceRoot: base.sourceRoot,
|
|
462
|
+
outputPath: base.sourcePath,
|
|
463
|
+
project: parseRoadmapSourceProject(rendered.artifacts.find(artifact => artifact.role === 'manifest').contents, base.manifestRelativePath),
|
|
464
|
+
sources: rendered.artifacts.filter(artifact => artifact.role === 'bundle').map(artifact => ({
|
|
465
|
+
entry: plan.sources.find(source => samePath(join(base.sourceRoot, source.path), artifact.absolutePath)),
|
|
466
|
+
document: parseRoadmapSourceDocument(artifact.contents, artifact.path),
|
|
467
|
+
absolutePath: artifact.absolutePath,
|
|
468
|
+
})).map(item => ({ entry: { path: item.entry.path, kind: item.entry.kind }, document: item.document, absolutePath: item.absolutePath })),
|
|
469
|
+
roadmap: validateRoadmapSourceFederation(parseRoadmapSourceProject(rendered.artifacts.find(artifact => artifact.role === 'manifest').contents, base.manifestRelativePath), rendered.artifacts.filter(artifact => artifact.role === 'bundle').map((artifact, index) => ({
|
|
470
|
+
entry: { path: plan.sources[index].path, kind: plan.sources[index].kind },
|
|
471
|
+
document: parseRoadmapSourceDocument(artifact.contents, artifact.path),
|
|
472
|
+
absolutePath: artifact.absolutePath,
|
|
473
|
+
}))).roadmap,
|
|
474
|
+
projection: rendered.artifacts.find(artifact => artifact.role === 'projection').contents,
|
|
475
|
+
};
|
|
476
|
+
const stagedValidation = validateRoadmapSourceStore(simulated, { checkProjection: false, checkArchiveEvidence: true });
|
|
477
|
+
if (!stagedValidation.valid) {
|
|
478
|
+
throw new Error([
|
|
479
|
+
'Staged roadmap migration failed archive evidence validation:',
|
|
480
|
+
...stagedValidation.errors.slice(0, 10).map(issue => ` - ${issue.source ? `${issue.source}: ` : ''}${issue.message}`),
|
|
481
|
+
].join('\n'));
|
|
482
|
+
}
|
|
483
|
+
return {
|
|
484
|
+
...base,
|
|
485
|
+
status: 'ready',
|
|
486
|
+
recordedAt,
|
|
487
|
+
sourceBytes,
|
|
488
|
+
sourceSha256: sha256(sourceBytes),
|
|
489
|
+
...(mapping.path ? { mappingPath: mapping.path } : {}),
|
|
490
|
+
...(mapping.sha ? { mappingSha256: mapping.sha } : {}),
|
|
491
|
+
evidenceSha256: computeRoadmapMigrationDigest(durableEvidenceArtifacts.map(artifact => ({ path: artifact.path, sha256: artifact.sha256 }))),
|
|
492
|
+
evidenceArtifacts: durableEvidenceArtifacts,
|
|
493
|
+
plan,
|
|
494
|
+
artifacts: rendered.artifacts,
|
|
495
|
+
receipt: rendered.receipt,
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
function readTransaction(base) {
|
|
499
|
+
let journal;
|
|
500
|
+
let backup;
|
|
501
|
+
try {
|
|
502
|
+
journal = JSON.parse(readFileSync(base.journalPath, 'utf8'));
|
|
503
|
+
backup = JSON.parse(readFileSync(base.backupPath, 'utf8'));
|
|
504
|
+
}
|
|
505
|
+
catch (error) {
|
|
506
|
+
throw new Error(`Could not read roadmap migration recovery metadata: ${error.message}`);
|
|
507
|
+
}
|
|
508
|
+
if (typeof backup.bytes_base64 !== 'string')
|
|
509
|
+
throw new Error('Roadmap migration recovery backup is malformed.');
|
|
510
|
+
const bytes = Buffer.from(backup.bytes_base64, 'base64');
|
|
511
|
+
const { integrity_sha256: journalIntegrity, ...journalPayload } = journal;
|
|
512
|
+
if (journal.version !== 1 || journal.kind !== 'roadmap_migration_journal'
|
|
513
|
+
|| backup.version !== 1 || backup.kind !== 'roadmap_migration_backup'
|
|
514
|
+
|| journal.source_path !== base.sourceRelativePath || backup.source_path !== base.sourceRelativePath
|
|
515
|
+
|| journal.manifest_path !== base.manifestRelativePath || !Array.isArray(journal.outputs)
|
|
516
|
+
|| typeof journalIntegrity !== 'string'
|
|
517
|
+
|| journalIntegrity !== computeRoadmapMigrationDigest(journalPayload)
|
|
518
|
+
|| backup.sha256 !== journal.source_original_sha256 || sha256(bytes) !== backup.sha256) {
|
|
519
|
+
throw new Error('Roadmap migration recovery metadata failed integrity validation.');
|
|
520
|
+
}
|
|
521
|
+
const allowedRoles = new Set(['bundle', 'audit', 'non_core', 'receipt', 'projection', 'manifest']);
|
|
522
|
+
const outputPaths = new Set();
|
|
523
|
+
let manifestOutput = false;
|
|
524
|
+
let projectionOutput = false;
|
|
525
|
+
for (const output of journal.outputs) {
|
|
526
|
+
if (!output || !allowedRoles.has(output.role) || typeof output.path !== 'string'
|
|
527
|
+
|| typeof output.sha256 !== 'string' || !/^[a-f0-9]{64}$/i.test(output.sha256)) {
|
|
528
|
+
throw new Error('Roadmap migration journal contains an invalid output record.');
|
|
529
|
+
}
|
|
530
|
+
const path = ensureWithin(base.input.cwd, resolve(base.input.cwd, ...output.path.split('/')), 'journal output path');
|
|
531
|
+
const key = process.platform === 'win32' ? path.toLowerCase() : path;
|
|
532
|
+
if (outputPaths.has(key))
|
|
533
|
+
throw new Error('Roadmap migration journal contains duplicate output paths.');
|
|
534
|
+
outputPaths.add(key);
|
|
535
|
+
if (output.role === 'manifest')
|
|
536
|
+
manifestOutput = output.path === base.manifestRelativePath;
|
|
537
|
+
if (output.role === 'projection')
|
|
538
|
+
projectionOutput = output.path === base.sourceRelativePath
|
|
539
|
+
&& output.sha256 === journal.projection_sha256;
|
|
540
|
+
}
|
|
541
|
+
if (!manifestOutput || !projectionOutput) {
|
|
542
|
+
throw new Error('Roadmap migration journal does not bind the expected manifest and projection outputs.');
|
|
543
|
+
}
|
|
544
|
+
return { journal, backup, bytes };
|
|
545
|
+
}
|
|
546
|
+
function rollbackTransaction(base) {
|
|
547
|
+
if (!existsSync(base.journalPath)) {
|
|
548
|
+
const { backup, bytes } = readBackup(base);
|
|
549
|
+
const currentDigest = sha256(readFileSync(base.sourcePath));
|
|
550
|
+
if (currentDigest !== backup.sha256) {
|
|
551
|
+
throw new Error(`Cannot recover backup-only transaction after roadmap source changed: ${base.sourceRelativePath}`);
|
|
552
|
+
}
|
|
553
|
+
if (!readFileSync(base.sourcePath).equals(bytes)) {
|
|
554
|
+
throw new Error(`Backup-only transaction does not match roadmap source bytes: ${base.sourceRelativePath}`);
|
|
555
|
+
}
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
const { journal, bytes } = readTransaction(base);
|
|
559
|
+
for (const output of [...journal.outputs].reverse()) {
|
|
560
|
+
const path = ensureWithin(base.input.cwd, resolve(base.input.cwd, ...output.path.split('/')), 'journal output path');
|
|
561
|
+
if (samePath(path, base.sourcePath))
|
|
562
|
+
continue;
|
|
563
|
+
if (!existsSync(path))
|
|
564
|
+
continue;
|
|
565
|
+
if (sha256(readFileSync(path)) !== output.sha256) {
|
|
566
|
+
throw new Error(`Cannot roll back changed migration output: ${output.path}`);
|
|
567
|
+
}
|
|
568
|
+
unlinkSync(path);
|
|
569
|
+
}
|
|
570
|
+
const currentDigest = sha256(readFileSync(base.sourcePath));
|
|
571
|
+
if (currentDigest === journal.projection_sha256) {
|
|
572
|
+
atomicWriteBytes(base.sourcePath, bytes);
|
|
573
|
+
}
|
|
574
|
+
else if (currentDigest !== journal.source_original_sha256) {
|
|
575
|
+
throw new Error(`Cannot restore changed roadmap source: ${base.sourceRelativePath}`);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
function cleanupTransaction(base) {
|
|
579
|
+
if (existsSync(base.journalPath))
|
|
580
|
+
unlinkSync(base.journalPath);
|
|
581
|
+
if (existsSync(base.backupPath))
|
|
582
|
+
unlinkSync(base.backupPath);
|
|
583
|
+
}
|
|
584
|
+
function verifyEvidence(prepared) {
|
|
585
|
+
for (const artifact of prepared.evidenceArtifacts) {
|
|
586
|
+
if (!existsSync(artifact.absolutePath) || sha256(readFileSync(artifact.absolutePath)) !== artifact.sha256) {
|
|
587
|
+
throw new Error(`Scorecard evidence changed during migration: ${artifact.path}`);
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
function resultForReceipt(receipt, status) {
|
|
592
|
+
return {
|
|
593
|
+
status,
|
|
594
|
+
receipt,
|
|
595
|
+
sources: receipt.summary.sources,
|
|
596
|
+
archives: receipt.summary.archives,
|
|
597
|
+
historyUnverified: receipt.summary.history_unverified,
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
export function applyRoadmapSourceMigration(prepared, hooks = {}) {
|
|
601
|
+
const lock = join(prepared.sourceRoot, '.federation');
|
|
602
|
+
return withFileLockSync(lock, () => {
|
|
603
|
+
if (prepared.status === 'unchanged') {
|
|
604
|
+
const fresh = prepareRoadmapSourceMigration(prepared.input);
|
|
605
|
+
if (fresh.status !== 'unchanged')
|
|
606
|
+
throw new Error('Roadmap migration receipt changed before idempotent verification.');
|
|
607
|
+
cleanupTransaction(fresh);
|
|
608
|
+
return resultForReceipt(fresh.receipt, 'unchanged');
|
|
609
|
+
}
|
|
610
|
+
if (prepared.status === 'recovery_required') {
|
|
611
|
+
rollbackTransaction(prepared);
|
|
612
|
+
cleanupTransaction(prepared);
|
|
613
|
+
const recovered = prepareRoadmapSourceMigration(prepared.input);
|
|
614
|
+
if (recovered.status !== 'ready') {
|
|
615
|
+
throw new Error(`Roadmap migration recovery completed but the fresh plan is ${recovered.status}.`);
|
|
616
|
+
}
|
|
617
|
+
return applyPreparedUnderLock(recovered, hooks);
|
|
618
|
+
}
|
|
619
|
+
if (prepared.status === 'blocked') {
|
|
620
|
+
throw new Error('Roadmap migration has unresolved ownership repairs; provide --mapping before apply.');
|
|
621
|
+
}
|
|
622
|
+
return applyPreparedUnderLock(prepared, hooks);
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
function applyPreparedUnderLock(prepared, hooks) {
|
|
626
|
+
const fresh = prepareRoadmapSourceMigration({ ...prepared.input, recordedAt: prepared.recordedAt });
|
|
627
|
+
if (fresh.status !== 'ready'
|
|
628
|
+
|| fresh.sourceSha256 !== prepared.sourceSha256
|
|
629
|
+
|| fresh.mappingPath !== prepared.mappingPath
|
|
630
|
+
|| fresh.mappingSha256 !== prepared.mappingSha256
|
|
631
|
+
|| fresh.evidenceSha256 !== prepared.evidenceSha256
|
|
632
|
+
|| fresh.plan.plan_sha256 !== prepared.plan.plan_sha256
|
|
633
|
+
|| computeRoadmapMigrationDigest(fresh.artifacts.map(artifact => ({
|
|
634
|
+
role: artifact.role, path: artifact.path, sha256: artifact.sha256,
|
|
635
|
+
}))) !== computeRoadmapMigrationDigest(prepared.artifacts.map(artifact => ({
|
|
636
|
+
role: artifact.role, path: artifact.path, sha256: artifact.sha256,
|
|
637
|
+
})))) {
|
|
638
|
+
throw new Error('Roadmap migration inputs changed before the federation lock; rerun dry-run and apply.');
|
|
639
|
+
}
|
|
640
|
+
for (const artifact of fresh.artifacts) {
|
|
641
|
+
if (artifact.role === 'projection')
|
|
642
|
+
continue;
|
|
643
|
+
if (existsSync(artifact.absolutePath)) {
|
|
644
|
+
throw new Error(`Roadmap migration output already exists: ${artifact.path}`);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
verifyEvidence(fresh);
|
|
648
|
+
if (sha256(readFileSync(fresh.sourcePath)) !== fresh.sourceSha256) {
|
|
649
|
+
throw new Error('Roadmap migration source changed before transaction preparation.');
|
|
650
|
+
}
|
|
651
|
+
const backup = {
|
|
652
|
+
version: 1,
|
|
653
|
+
kind: 'roadmap_migration_backup',
|
|
654
|
+
source_path: fresh.sourceRelativePath,
|
|
655
|
+
sha256: fresh.sourceSha256,
|
|
656
|
+
bytes_base64: fresh.sourceBytes.toString('base64'),
|
|
657
|
+
};
|
|
658
|
+
const projection = fresh.artifacts.find(artifact => artifact.role === 'projection');
|
|
659
|
+
const journalPayload = {
|
|
660
|
+
version: 1,
|
|
661
|
+
kind: 'roadmap_migration_journal',
|
|
662
|
+
migration_id: fresh.receipt.migration_id,
|
|
663
|
+
recorded_at: fresh.recordedAt,
|
|
664
|
+
source_path: fresh.sourceRelativePath,
|
|
665
|
+
source_original_sha256: fresh.sourceSha256,
|
|
666
|
+
projection_sha256: projection.sha256,
|
|
667
|
+
manifest_path: fresh.manifestRelativePath,
|
|
668
|
+
outputs: fresh.artifacts.map(artifact => ({ role: artifact.role, path: artifact.path, sha256: artifact.sha256 })),
|
|
669
|
+
};
|
|
670
|
+
const journal = {
|
|
671
|
+
...journalPayload,
|
|
672
|
+
integrity_sha256: computeRoadmapMigrationDigest(journalPayload),
|
|
673
|
+
};
|
|
674
|
+
atomicWriteFileSync(fresh.backupPath, json(backup));
|
|
675
|
+
atomicWriteFileSync(fresh.journalPath, json(journal));
|
|
676
|
+
try {
|
|
677
|
+
const manifest = fresh.artifacts.find(artifact => artifact.role === 'manifest');
|
|
678
|
+
for (const artifact of fresh.artifacts) {
|
|
679
|
+
if (artifact.role === 'manifest')
|
|
680
|
+
continue;
|
|
681
|
+
ensureWithin(fresh.input.cwd, artifact.absolutePath, `migration ${artifact.role} path`);
|
|
682
|
+
if (artifact.role === 'projection' && sha256(readFileSync(fresh.sourcePath)) !== fresh.sourceSha256) {
|
|
683
|
+
throw new Error('Roadmap migration source changed before projection commit.');
|
|
684
|
+
}
|
|
685
|
+
atomicWriteFileSync(artifact.absolutePath, artifact.contents);
|
|
686
|
+
hooks.afterWrite?.(artifact.role, artifact.path);
|
|
687
|
+
}
|
|
688
|
+
verifyEvidence(fresh);
|
|
689
|
+
atomicWriteFileSync(manifest.absolutePath, manifest.contents);
|
|
690
|
+
hooks.afterWrite?.(manifest.role, manifest.path);
|
|
691
|
+
const store = loadRoadmapSourceStore(fresh.input.cwd, fresh.manifestRelativePath);
|
|
692
|
+
const validation = validateRoadmapSourceStore(store);
|
|
693
|
+
if (!validation.valid) {
|
|
694
|
+
throw new Error([
|
|
695
|
+
'Committed roadmap migration failed validation:',
|
|
696
|
+
...validation.errors.slice(0, 10).map(issue => ` - ${issue.source ? `${issue.source}: ` : ''}${issue.message}`),
|
|
697
|
+
].join('\n'));
|
|
698
|
+
}
|
|
699
|
+
const receipt = parseReceipt(fresh);
|
|
700
|
+
if (!receipt)
|
|
701
|
+
throw new Error('Committed roadmap migration receipt or output digests failed verification.');
|
|
702
|
+
cleanupTransaction(fresh);
|
|
703
|
+
return {
|
|
704
|
+
status: 'applied',
|
|
705
|
+
receipt,
|
|
706
|
+
sources: fresh.plan.sources.length,
|
|
707
|
+
archives: fresh.plan.sources.filter(source => source.classification === 'archive').length,
|
|
708
|
+
historyUnverified: fresh.plan.sources.filter(source => source.classification === 'history_unverified').length,
|
|
709
|
+
};
|
|
710
|
+
}
|
|
711
|
+
catch (error) {
|
|
712
|
+
try {
|
|
713
|
+
rollbackTransaction(fresh);
|
|
714
|
+
}
|
|
715
|
+
catch (rollbackError) {
|
|
716
|
+
throw new Error(`${error.message}\nAutomatic rollback also failed: ${rollbackError.message}`);
|
|
717
|
+
}
|
|
718
|
+
throw error;
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
//# sourceMappingURL=roadmap-source-migration.js.map
|