frontend-project-context 1.3.0 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +39 -2
- package/README.md +94 -16
- package/UPGRADING.md +47 -2
- package/docs/04-PROGRAM-DESIGN.md +40 -4
- package/docs/05-ACCEPTANCE-CONTRACT.md +33 -3
- package/docs/08-INSTALLATION-AND-DISTRIBUTION.md +36 -6
- package/docs/14-FORMAL-RELEASE-READINESS.md +46 -0
- package/docs/18-BRANCH-AWARE-STAGED-CONTEXT-DESIGN.md +62 -2
- package/docs/19-POST-1.3.1-AI-TAKEOVER-EVIDENCE-AND-UPGRADE-PLAN.md +579 -0
- package/docs/20-PHASE-A-AI-TAKEOVER-AND-HEALTH-CLOSURE-DESIGN.md +535 -0
- package/docs/21-PHASE-B-EVIDENCE-FEEDBACK-PROTOCOL-DESIGN.md +347 -0
- package/docs/22-PHASE-C-TARGET-UPGRADE-PROTOCOL-DESIGN.md +398 -0
- package/docs/README.md +21 -5
- package/docs/USER-AND-AI-OPERATION-MANUAL.md +797 -0
- package/examples/README.md +38 -0
- package/examples/package.json +6 -2
- package/migration-manifest.json +88 -0
- package/package.json +3 -2
- package/schemas/action-plan.schema.json +31 -3
- package/schemas/capabilities.schema.json +50 -18
- package/schemas/evidence-bundle.schema.json +64 -0
- package/schemas/evidence-input.schema.json +82 -0
- package/schemas/migration-manifest.schema.json +29 -0
- package/schemas/migration-plan.schema.json +32 -0
- package/schemas/project-status.schema.json +75 -0
- package/schemas/projection-lock.schema.json +48 -0
- package/schemas/review-bundle.schema.json +3 -3
- package/schemas/upgrade-assessment.schema.json +48 -0
- package/schemas/upgrade-result-bundle.schema.json +35 -0
- package/src/project-context/ai-entry.mjs +320 -0
- package/src/project-context/capabilities.mjs +44 -17
- package/src/project-context/checker.mjs +20 -3
- package/src/project-context/cli.mjs +84 -7
- package/src/project-context/contract-schema.mjs +30 -16
- package/src/project-context/dashboard-model.mjs +4 -4
- package/src/project-context/dashboard-renderer.mjs +3 -3
- package/src/project-context/discovery.mjs +6 -1
- package/src/project-context/evidence-schema.mjs +209 -0
- package/src/project-context/evidence.mjs +99 -0
- package/src/project-context/exchange-schema.mjs +21 -11
- package/src/project-context/exchange.mjs +26 -4
- package/src/project-context/maintenance.mjs +2 -2
- package/src/project-context/migration-manifest.mjs +166 -0
- package/src/project-context/project-status.mjs +157 -0
- package/src/project-context/projection-store.mjs +8 -1
- package/src/project-context/task-context-schema.mjs +237 -1
- package/src/project-context/task-context.mjs +154 -13
- package/src/project-context/upgrade-schema.mjs +215 -0
- package/src/project-context/upgrade.mjs +494 -0
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
import { lstat, readFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { AI_ENTRY_RENDERER_VERSION, inspectAiEntry, publishAiEntry } from "./ai-entry.mjs";
|
|
4
|
+
import { PERMANENT_BOUNDARIES } from "./capabilities.mjs";
|
|
5
|
+
import { sha256 } from "./canonical-json.mjs";
|
|
6
|
+
import { sourceStatus } from "./contract-schema.mjs";
|
|
7
|
+
import { fail } from "./errors.mjs";
|
|
8
|
+
import { BUILT_IN_MIGRATIONS, loadMigrationManifest } from "./migration-manifest.mjs";
|
|
9
|
+
import { normalizeRelativePath, resolveExistingInside } from "./path-policy.mjs";
|
|
10
|
+
import { inspectProjectInitialization, loadProject } from "./project-store.mjs";
|
|
11
|
+
import { publishProjection } from "./projection-store.mjs";
|
|
12
|
+
import { parseProjectionMarker, RENDERER_VERSION, renderProjection } from "./renderer.mjs";
|
|
13
|
+
import { findConflicts, validateOverrides } from "./scope-compiler.mjs";
|
|
14
|
+
import {
|
|
15
|
+
finalizeMigrationPlan,
|
|
16
|
+
finalizeUpgradeAssessment,
|
|
17
|
+
finalizeUpgradeResult,
|
|
18
|
+
validateMigrationPlan,
|
|
19
|
+
validateUpgradeAssessment,
|
|
20
|
+
validateUpgradeResult,
|
|
21
|
+
} from "./upgrade-schema.mjs";
|
|
22
|
+
|
|
23
|
+
const ARTIFACT_DIRECTORY = ".project-context/";
|
|
24
|
+
const STORE_FILES = new Set([
|
|
25
|
+
".project-context/contract.json",
|
|
26
|
+
".project-context/sources.lock.json",
|
|
27
|
+
".project-context/projections.lock.json",
|
|
28
|
+
]);
|
|
29
|
+
const HOST_ACCEPTANCE = Object.freeze([
|
|
30
|
+
"dependency-and-lockfile",
|
|
31
|
+
"independent-new-window",
|
|
32
|
+
"project-tests-or-ci",
|
|
33
|
+
]);
|
|
34
|
+
const REPAIRABLE_FINDINGS = new Set(["ai-entry-renderer-stale", "projection-renderer-stale"]);
|
|
35
|
+
|
|
36
|
+
function snapshots(project) {
|
|
37
|
+
if (!project) return null;
|
|
38
|
+
return {
|
|
39
|
+
contract: project.contractDigest,
|
|
40
|
+
sourcesLock: project.sourcesLockDigest,
|
|
41
|
+
projectionsLock: project.projectionsLockDigest,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function pathFor(manifest, fromVersion) {
|
|
46
|
+
const selected = manifest.paths.find((entry) => entry.fromVersion === fromVersion);
|
|
47
|
+
if (!selected) fail("upgrade-source-version-unsupported", `unsupported upgrade source version: ${fromVersion}`, { exitCode: 2 });
|
|
48
|
+
return selected;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function ensureSourceVersion(manifest, fromVersion) {
|
|
52
|
+
if (!manifest.upgradeFrom.includes(fromVersion)) {
|
|
53
|
+
fail("upgrade-source-version-unsupported", `unsupported upgrade source version: ${fromVersion}`, {
|
|
54
|
+
exitCode: 2,
|
|
55
|
+
details: { supported: manifest.upgradeFrom },
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function storeCompatibility(project, manifest) {
|
|
61
|
+
if (!project) return Object.fromEntries(Object.keys(manifest.stores).map((name) => [name, "not-applicable"]));
|
|
62
|
+
const versions = {
|
|
63
|
+
contract: project.contract.schemaVersion,
|
|
64
|
+
sourceLock: project.sourcesLock.schemaVersion,
|
|
65
|
+
projectionLock: project.projectionsLock.schemaVersion,
|
|
66
|
+
proposal: 1,
|
|
67
|
+
};
|
|
68
|
+
return Object.fromEntries(Object.entries(versions).map(([name, version]) => [
|
|
69
|
+
name,
|
|
70
|
+
manifest.stores[name].readable.includes(version) ? "compatible" : "unsupported",
|
|
71
|
+
]));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function rendererCompatibility(project, manifest) {
|
|
75
|
+
if (!project) return { aiEntry: "not-applicable", projection: "not-applicable" };
|
|
76
|
+
const entries = project.projectionsLock.projections;
|
|
77
|
+
const aiEntries = entries.filter((entry) => entry.ownership === "region" && entry.target === "ai-entry");
|
|
78
|
+
const projections = entries.filter((entry) => entry.ownership !== "region");
|
|
79
|
+
const state = (selected, matrix) => {
|
|
80
|
+
if (selected.length === 0) return "compatible";
|
|
81
|
+
if (selected.some((entry) => !matrix.readable.includes(entry.rendererVersion))) return "unsupported";
|
|
82
|
+
return selected.some((entry) => entry.rendererVersion !== matrix.written) ? "republish" : "compatible";
|
|
83
|
+
};
|
|
84
|
+
return {
|
|
85
|
+
aiEntry: state(aiEntries, manifest.renderers.aiEntry),
|
|
86
|
+
projection: state(projections, manifest.renderers.projection),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function protocolCompatibility(manifest) {
|
|
91
|
+
return Object.fromEntries(Object.keys(manifest.protocols).sort().map((name) => {
|
|
92
|
+
const change = manifest.consumerChanges[name];
|
|
93
|
+
return [name, change?.state === "invalidate" ? "invalidate" : change?.state === "regenerate" ? "regenerate" : "compatible"];
|
|
94
|
+
}));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function selectedMigrationIds(project, migrationPath, manifest) {
|
|
98
|
+
if (!project) return [];
|
|
99
|
+
const available = new Set(migrationPath.migrationIds);
|
|
100
|
+
const selected = [];
|
|
101
|
+
if (
|
|
102
|
+
available.has("upgrade.republish-ai-entry.v1") &&
|
|
103
|
+
project.projectionsLock.projections.some((entry) => entry.ownership === "region" && entry.target === "ai-entry" && entry.rendererVersion !== manifest.renderers.aiEntry.written)
|
|
104
|
+
) selected.push("upgrade.republish-ai-entry.v1");
|
|
105
|
+
if (
|
|
106
|
+
available.has("upgrade.republish-projection.v1") &&
|
|
107
|
+
project.projectionsLock.projections.some((entry) => entry.ownership !== "region" && entry.rendererVersion !== manifest.renderers.projection.written)
|
|
108
|
+
) selected.push("upgrade.republish-projection.v1");
|
|
109
|
+
return selected.sort((left, right) => left.localeCompare(right));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function assessmentEntry(status) {
|
|
113
|
+
return {
|
|
114
|
+
state: status?.entry?.state ?? "absent",
|
|
115
|
+
path: status?.entry?.path ?? null,
|
|
116
|
+
rendererVersion: status?.entry?.rendererVersion ?? null,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function inspectUpgradeState(root, project) {
|
|
121
|
+
const findings = [];
|
|
122
|
+
const contractSources = new Map(project.contract.sources.map((source) => [source.id, source]));
|
|
123
|
+
const lockedSources = new Map(project.sourcesLock.sources.map((entry) => [entry.id, entry.digest]));
|
|
124
|
+
for (const source of project.contract.sources) {
|
|
125
|
+
if (sourceStatus(source) === "deprecated") continue;
|
|
126
|
+
const locked = lockedSources.get(source.id);
|
|
127
|
+
if (!locked) findings.push({ code: "source-lock-missing", source: source.id });
|
|
128
|
+
else if (source.digest !== undefined && source.digest !== locked) findings.push({ code: "source-lock-mismatch", source: source.id });
|
|
129
|
+
}
|
|
130
|
+
for (const entry of project.sourcesLock.sources) {
|
|
131
|
+
const source = contractSources.get(entry.id);
|
|
132
|
+
if (!source) findings.push({ code: "source-lock-orphan", source: entry.id });
|
|
133
|
+
else if (sourceStatus(source) === "deprecated") findings.push({ code: "source-lock-deprecated", source: entry.id });
|
|
134
|
+
}
|
|
135
|
+
for (const item of project.contract.items) if (item.status === "proposed") findings.push({ code: "item-approval-pending", item: item.id });
|
|
136
|
+
findings.push(...validateOverrides(project.contract.items), ...findConflicts(project.contract.items));
|
|
137
|
+
|
|
138
|
+
const regionEntries = project.projectionsLock.projections
|
|
139
|
+
.filter((entry) => entry.ownership === "region" && entry.target === "ai-entry")
|
|
140
|
+
.sort((left, right) => left.path.localeCompare(right.path));
|
|
141
|
+
let entry = { state: "absent", path: null, rendererVersion: null };
|
|
142
|
+
if (regionEntries.length > 0) {
|
|
143
|
+
entry = await inspectAiEntry(root, project, regionEntries[0].path);
|
|
144
|
+
if (entry.state === "conflict") findings.push({ code: "ai-entry-ownership-conflict", path: entry.path });
|
|
145
|
+
else if (entry.state === "stale") findings.push({ code: "ai-entry-renderer-stale", path: entry.path });
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
for (const lockEntry of project.projectionsLock.projections.filter((candidate) => candidate.ownership !== "region")) {
|
|
149
|
+
let content;
|
|
150
|
+
try {
|
|
151
|
+
const candidate = path.join(root, lockEntry.path);
|
|
152
|
+
if ((await lstat(candidate)).isSymbolicLink()) throw new Error("managed target is a symlink");
|
|
153
|
+
const resolved = await resolveExistingInside(root, lockEntry.path);
|
|
154
|
+
content = await readFile(resolved.absolute, "utf8");
|
|
155
|
+
} catch (error) {
|
|
156
|
+
findings.push({ code: error?.code === "source-missing" || error?.code === "ENOENT" ? "projection-missing" : "projection-unreadable", path: lockEntry.path });
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
const marker = parseProjectionMarker(content);
|
|
160
|
+
if (!marker || sha256(content) !== lockEntry.contentDigest || marker.target !== lockEntry.target || marker.rendererVersion !== lockEntry.rendererVersion) {
|
|
161
|
+
findings.push({ code: "projection-ownership-conflict", path: lockEntry.path });
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
if (lockEntry.rendererVersion !== RENDERER_VERSION) {
|
|
165
|
+
findings.push({ code: "projection-renderer-stale", path: lockEntry.path });
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
if (lockEntry.contractDigest !== project.contractDigest || marker.contractDigest !== project.contractDigest) {
|
|
169
|
+
findings.push({ code: "projection-stale", path: lockEntry.path });
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
const expected = renderProjection(project.contract, lockEntry.paths, lockEntry.target);
|
|
173
|
+
if (expected.content !== content || expected.bundleDigest !== lockEntry.bundleDigest) findings.push({ code: "projection-diverged", path: lockEntry.path });
|
|
174
|
+
}
|
|
175
|
+
findings.sort((left, right) => left.code.localeCompare(right.code) || String(left.path ?? left.source ?? left.item ?? "").localeCompare(String(right.path ?? right.source ?? right.item ?? "")));
|
|
176
|
+
const conflict = findings.some((finding) => finding.code.includes("ownership-conflict"));
|
|
177
|
+
return {
|
|
178
|
+
health: conflict ? "conflict" : findings.length > 0 ? "attention" : "clean",
|
|
179
|
+
entry,
|
|
180
|
+
findingCodes: [...new Set(findings.map((finding) => finding.code))].sort((left, right) => left.localeCompare(right)),
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export async function buildUpgradeAssessment(root, fromVersion, dependencies = {}) {
|
|
185
|
+
const manifest = await loadMigrationManifest(dependencies.manifest);
|
|
186
|
+
ensureSourceVersion(manifest, fromVersion);
|
|
187
|
+
const migrationPath = pathFor(manifest, fromVersion);
|
|
188
|
+
const initialization = await inspectProjectInitialization(root);
|
|
189
|
+
let project = null;
|
|
190
|
+
let status;
|
|
191
|
+
if (initialization.status === "initialized") {
|
|
192
|
+
try {
|
|
193
|
+
project = await loadProject(root);
|
|
194
|
+
status = await inspectUpgradeState(root, project);
|
|
195
|
+
} catch (error) {
|
|
196
|
+
project = null;
|
|
197
|
+
status = {
|
|
198
|
+
health: "invalid",
|
|
199
|
+
entry: { state: "absent", path: null, rendererVersion: null },
|
|
200
|
+
findingCodes: [error?.code?.startsWith?.("schema-") ? "upgrade-store-unsupported" : "project-state-invalid"],
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
} else {
|
|
204
|
+
status = {
|
|
205
|
+
health: initialization.status,
|
|
206
|
+
entry: { state: "absent", path: null, rendererVersion: null },
|
|
207
|
+
findingCodes: initialization.status === "partial" ? ["project-state-partial"] : [],
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
if (initialization.status === "initialized" && !project) {
|
|
211
|
+
status ??= { health: "invalid", entry: { state: "absent", path: null, rendererVersion: null }, findingCodes: ["project-state-invalid"] };
|
|
212
|
+
}
|
|
213
|
+
const stores = storeCompatibility(project, manifest);
|
|
214
|
+
const renderers = rendererCompatibility(project, manifest);
|
|
215
|
+
const actionable = selectedMigrationIds(project, migrationPath, manifest);
|
|
216
|
+
const unsupportedStore = Object.values(stores).includes("unsupported");
|
|
217
|
+
const unsupportedRenderer = Object.values(renderers).includes("unsupported");
|
|
218
|
+
if (unsupportedRenderer && !status.findingCodes.includes("upgrade-renderer-unsupported")) status.findingCodes.push("upgrade-renderer-unsupported");
|
|
219
|
+
const onlyRepairable = status.findingCodes.every((code) => REPAIRABLE_FINDINGS.has(code));
|
|
220
|
+
let state;
|
|
221
|
+
if (initialization.status === "uninitialized") state = "not-applicable";
|
|
222
|
+
else if (status.health === "clean") state = "core-complete";
|
|
223
|
+
else if (!unsupportedStore && !unsupportedRenderer && actionable.length > 0 && onlyRepairable) state = "ready-for-plan";
|
|
224
|
+
else state = "blocked";
|
|
225
|
+
const chosenRollback = actionable.length > 0 ? "forward-only" : migrationPath.rollbackClass;
|
|
226
|
+
return validateUpgradeAssessment(finalizeUpgradeAssessment({
|
|
227
|
+
schemaVersion: 1,
|
|
228
|
+
kind: "upgrade-assessment",
|
|
229
|
+
product: { name: "frontend-project-context" },
|
|
230
|
+
fromVersion,
|
|
231
|
+
targetVersion: manifest.package.version,
|
|
232
|
+
fromVersionEvidence: "host-asserted",
|
|
233
|
+
manifestSchemaVersion: manifest.schemaVersion,
|
|
234
|
+
manifestDigest: manifest.manifestDigest,
|
|
235
|
+
initialization: {
|
|
236
|
+
state: initialization.status === "initialized" && !project ? "invalid" : initialization.status,
|
|
237
|
+
present: [...initialization.present].sort((left, right) => left.localeCompare(right)),
|
|
238
|
+
missing: [...initialization.missing].sort((left, right) => left.localeCompare(right)),
|
|
239
|
+
},
|
|
240
|
+
health: status.health,
|
|
241
|
+
entryState: assessmentEntry(status),
|
|
242
|
+
findingCodes: [...new Set(status.findingCodes)].sort((left, right) => left.localeCompare(right)),
|
|
243
|
+
snapshots: snapshots(project),
|
|
244
|
+
compatibility: { stores, renderers, protocols: protocolCompatibility(manifest) },
|
|
245
|
+
migrationPath: actionable,
|
|
246
|
+
rollbackClass: chosenRollback,
|
|
247
|
+
requiresHumanReview: migrationPath.requiresHumanReview || chosenRollback === "forward-only",
|
|
248
|
+
state,
|
|
249
|
+
boundaries: { ...PERMANENT_BOUNDARIES },
|
|
250
|
+
}));
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async function readArtifact(root, relativePath, kind) {
|
|
254
|
+
let normalized;
|
|
255
|
+
try {
|
|
256
|
+
normalized = normalizeRelativePath(relativePath, { label: `${kind} path` });
|
|
257
|
+
} catch (error) {
|
|
258
|
+
fail(`upgrade-${kind}-invalid`, `${kind} path must stay inside the project`, { cause: error });
|
|
259
|
+
}
|
|
260
|
+
if (!normalized.startsWith(ARTIFACT_DIRECTORY) || STORE_FILES.has(normalized) || !normalized.endsWith(".json")) {
|
|
261
|
+
fail(`upgrade-${kind}-invalid`, `${kind} must be a non-store JSON file inside .project-context/`);
|
|
262
|
+
}
|
|
263
|
+
const candidate = path.join(root, normalized);
|
|
264
|
+
try {
|
|
265
|
+
if ((await lstat(candidate)).isSymbolicLink()) fail(`upgrade-${kind}-invalid`, `${kind} cannot be a symlink`);
|
|
266
|
+
const resolved = await resolveExistingInside(root, normalized);
|
|
267
|
+
return JSON.parse(await readFile(resolved.absolute, "utf8"));
|
|
268
|
+
} catch (error) {
|
|
269
|
+
if (error?.code?.startsWith?.("upgrade-")) throw error;
|
|
270
|
+
fail(`upgrade-${kind}-invalid`, `cannot read ${kind}`, { cause: error, details: { path: normalized } });
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async function currentTargetDigest(root, target) {
|
|
275
|
+
try {
|
|
276
|
+
const resolved = await resolveExistingInside(root, target);
|
|
277
|
+
return sha256(await readFile(resolved.absolute));
|
|
278
|
+
} catch (error) {
|
|
279
|
+
if (error?.code === "source-missing") return null;
|
|
280
|
+
throw error;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function actionCandidates(project, assessment) {
|
|
285
|
+
const entries = [...project.projectionsLock.projections].sort((left, right) => left.path.localeCompare(right.path));
|
|
286
|
+
const actions = [];
|
|
287
|
+
if (assessment.migrationPath.includes("upgrade.republish-ai-entry.v1")) {
|
|
288
|
+
for (const entry of entries.filter((candidate) => candidate.ownership === "region" && candidate.target === "ai-entry" && candidate.rendererVersion !== AI_ENTRY_RENDERER_VERSION)) {
|
|
289
|
+
actions.push({
|
|
290
|
+
id: `republish-ai-entry:${entry.path}`,
|
|
291
|
+
kind: "republish-ai-entry",
|
|
292
|
+
migrationId: "upgrade.republish-ai-entry.v1",
|
|
293
|
+
targets: [entry.path],
|
|
294
|
+
writes: true,
|
|
295
|
+
semanticImpact: "managed-rendering",
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
if (assessment.migrationPath.includes("upgrade.republish-projection.v1")) {
|
|
300
|
+
for (const entry of entries.filter((candidate) => candidate.ownership !== "region" && candidate.rendererVersion !== RENDERER_VERSION)) {
|
|
301
|
+
actions.push({
|
|
302
|
+
id: `republish-projection:${entry.path}`,
|
|
303
|
+
kind: "republish-projection",
|
|
304
|
+
migrationId: "upgrade.republish-projection.v1",
|
|
305
|
+
targets: [entry.path],
|
|
306
|
+
writes: true,
|
|
307
|
+
semanticImpact: "managed-rendering",
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
return actions;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
async function validateAssessmentCurrent(root, assessment, manifest, dependencies = {}) {
|
|
315
|
+
if (assessment.manifestDigest !== manifest.manifestDigest) fail("upgrade-assessment-stale", "assessment manifest digest is stale", { exitCode: 1 });
|
|
316
|
+
const current = await buildUpgradeAssessment(root, assessment.fromVersion, dependencies);
|
|
317
|
+
if (current.assessmentDigest !== assessment.assessmentDigest) {
|
|
318
|
+
fail("upgrade-assessment-stale", "assessment no longer matches current Project Context bytes", {
|
|
319
|
+
exitCode: 1,
|
|
320
|
+
details: { expected: assessment.assessmentDigest, actual: current.assessmentDigest },
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export async function buildMigrationPlan(root, assessmentInput, dependencies = {}) {
|
|
326
|
+
const assessment = validateUpgradeAssessment(assessmentInput);
|
|
327
|
+
const manifest = await loadMigrationManifest(dependencies.manifest);
|
|
328
|
+
await validateAssessmentCurrent(root, assessment, manifest, dependencies);
|
|
329
|
+
if (assessment.state === "not-applicable" || assessment.state === "blocked") {
|
|
330
|
+
fail("upgrade-project-state-blocked", `upgrade assessment is ${assessment.state}`, { exitCode: 1, details: { findingCodes: assessment.findingCodes } });
|
|
331
|
+
}
|
|
332
|
+
const project = await loadProject(root);
|
|
333
|
+
const candidates = actionCandidates(project, assessment);
|
|
334
|
+
const nextAction = candidates[0] ?? {
|
|
335
|
+
id: "verify-core-migration-complete",
|
|
336
|
+
kind: "verify-complete",
|
|
337
|
+
migrationId: null,
|
|
338
|
+
targets: [],
|
|
339
|
+
writes: false,
|
|
340
|
+
semanticImpact: "none",
|
|
341
|
+
};
|
|
342
|
+
const targetDigests = [];
|
|
343
|
+
for (const target of nextAction.targets) targetDigests.push({ path: target, beforeDigest: await currentTargetDigest(root, target) });
|
|
344
|
+
const runtime = nextAction.migrationId ? manifest.builtInMigrations.find((entry) => entry.id === nextAction.migrationId) : null;
|
|
345
|
+
if (nextAction.migrationId && (!runtime || BUILT_IN_MIGRATIONS[nextAction.migrationId] !== nextAction.kind)) {
|
|
346
|
+
fail("upgrade-migration-unsupported", `migration is not compiled into this runtime: ${nextAction.migrationId}`, { exitCode: 1 });
|
|
347
|
+
}
|
|
348
|
+
const remainingMigrationIds = [...new Set(candidates.slice(1).map((entry) => entry.migrationId))].sort((left, right) => left.localeCompare(right));
|
|
349
|
+
const migrationPath = pathFor(manifest, assessment.fromVersion);
|
|
350
|
+
const rollbackClass = runtime?.rollbackClass ?? assessment.rollbackClass;
|
|
351
|
+
return validateMigrationPlan(finalizeMigrationPlan({
|
|
352
|
+
schemaVersion: 1,
|
|
353
|
+
kind: "target-upgrade-plan",
|
|
354
|
+
fromVersion: assessment.fromVersion,
|
|
355
|
+
targetVersion: assessment.targetVersion,
|
|
356
|
+
manifestDigest: manifest.manifestDigest,
|
|
357
|
+
assessmentDigest: assessment.assessmentDigest,
|
|
358
|
+
snapshots: assessment.snapshots,
|
|
359
|
+
targetDigests,
|
|
360
|
+
nextAction,
|
|
361
|
+
remainingMigrationIds,
|
|
362
|
+
acceptance: { core: migrationPath.acceptance, hostRequired: [...HOST_ACCEPTANCE] },
|
|
363
|
+
rollback: {
|
|
364
|
+
class: rollbackClass,
|
|
365
|
+
reverseMigrationId: runtime?.reverseMigrationId ?? null,
|
|
366
|
+
externalRestoreRequired: nextAction.writes && runtime?.reverseMigrationId === null,
|
|
367
|
+
},
|
|
368
|
+
requiresHumanReview: migrationPath.requiresHumanReview || nextAction.writes && runtime?.reverseMigrationId === null,
|
|
369
|
+
authority: "human-explicit-write-required",
|
|
370
|
+
}));
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
export async function buildMigrationPlanFile(root, assessmentPath, dependencies = {}) {
|
|
374
|
+
return buildMigrationPlan(root, await readArtifact(root, assessmentPath, "assessment"), dependencies);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
async function validatePlanCurrent(root, plan, manifest, dependencies = {}) {
|
|
378
|
+
if (plan.manifestDigest !== manifest.manifestDigest) fail("upgrade-plan-stale", "plan manifest digest is stale", { exitCode: 1 });
|
|
379
|
+
const current = await buildUpgradeAssessment(root, plan.fromVersion, dependencies);
|
|
380
|
+
if (current.assessmentDigest !== plan.assessmentDigest) fail("upgrade-plan-stale", "plan assessment baseline is stale", { exitCode: 1 });
|
|
381
|
+
const currentSnapshots = current.snapshots;
|
|
382
|
+
if (JSON.stringify(currentSnapshots) !== JSON.stringify(plan.snapshots)) fail("upgrade-plan-stale", "plan store snapshots are stale", { exitCode: 1 });
|
|
383
|
+
for (const target of plan.targetDigests) {
|
|
384
|
+
const actual = await currentTargetDigest(root, target.path);
|
|
385
|
+
if (actual !== target.beforeDigest) fail("upgrade-plan-stale", `plan target changed: ${target.path}`, { exitCode: 1, details: { path: target.path } });
|
|
386
|
+
}
|
|
387
|
+
const expectedPlan = await buildMigrationPlan(root, current, dependencies);
|
|
388
|
+
if (expectedPlan.planDigest !== plan.planDigest) fail("upgrade-plan-invalid", "plan is not the current compiler-selected single work unit");
|
|
389
|
+
return current;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
async function targetStates(root, targets, before) {
|
|
393
|
+
const values = [];
|
|
394
|
+
for (const target of targets) values.push({ path: target, beforeDigest: before.get(target) ?? null, afterDigest: await currentTargetDigest(root, target) });
|
|
395
|
+
return values;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
async function afterSnapshots(root) {
|
|
399
|
+
try {
|
|
400
|
+
return snapshots(await loadProject(root));
|
|
401
|
+
} catch {
|
|
402
|
+
return null;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function resultBundle(plan, manifest, options) {
|
|
407
|
+
return validateUpgradeResult(finalizeUpgradeResult({
|
|
408
|
+
schemaVersion: 1,
|
|
409
|
+
kind: "upgrade-result-bundle",
|
|
410
|
+
product: { name: "frontend-project-context" },
|
|
411
|
+
fromVersion: plan.fromVersion,
|
|
412
|
+
targetVersion: plan.targetVersion,
|
|
413
|
+
manifestDigest: manifest.manifestDigest,
|
|
414
|
+
planDigest: plan.planDigest,
|
|
415
|
+
mode: options.mode,
|
|
416
|
+
action: plan.nextAction,
|
|
417
|
+
written: options.written,
|
|
418
|
+
targets: options.targets,
|
|
419
|
+
snapshots: { before: plan.snapshots, after: options.afterSnapshots },
|
|
420
|
+
actionResult: options.actionResult,
|
|
421
|
+
findingCodes: options.findingCodes,
|
|
422
|
+
coreMigration: options.coreMigration,
|
|
423
|
+
hostAcceptance: [...HOST_ACCEPTANCE],
|
|
424
|
+
overallUpgrade: "host-validation-required",
|
|
425
|
+
}));
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
export async function applyMigrationPlan(root, planInput, options = {}, dependencies = {}) {
|
|
429
|
+
const plan = validateMigrationPlan(planInput);
|
|
430
|
+
const manifest = await loadMigrationManifest(dependencies.manifest);
|
|
431
|
+
const assessment = await validatePlanCurrent(root, plan, manifest, dependencies);
|
|
432
|
+
const action = plan.nextAction;
|
|
433
|
+
const before = new Map(plan.targetDigests.map((entry) => [entry.path, entry.beforeDigest]));
|
|
434
|
+
if (action.kind === "verify-complete") {
|
|
435
|
+
const result = resultBundle(plan, manifest, {
|
|
436
|
+
mode: options.write ? "write" : "preview",
|
|
437
|
+
written: false,
|
|
438
|
+
targets: [],
|
|
439
|
+
afterSnapshots: assessment.snapshots,
|
|
440
|
+
actionResult: "verified",
|
|
441
|
+
findingCodes: [],
|
|
442
|
+
coreMigration: options.write ? "complete" : "previewed",
|
|
443
|
+
});
|
|
444
|
+
return { result, exitCode: 0 };
|
|
445
|
+
}
|
|
446
|
+
if (!action.migrationId || BUILT_IN_MIGRATIONS[action.migrationId] !== action.kind) {
|
|
447
|
+
fail("upgrade-migration-unsupported", `migration is not compiled into this runtime: ${action.migrationId ?? "missing"}`, { exitCode: 1 });
|
|
448
|
+
}
|
|
449
|
+
const project = await loadProject(root);
|
|
450
|
+
let operation;
|
|
451
|
+
try {
|
|
452
|
+
if (action.kind === "republish-ai-entry") {
|
|
453
|
+
operation = await publishAiEntry(root, project, { output: action.targets[0], write: options.write }, dependencies.aiEntry);
|
|
454
|
+
} else if (action.kind === "republish-projection") {
|
|
455
|
+
const entry = project.projectionsLock.projections.find((candidate) => candidate.path === action.targets[0]);
|
|
456
|
+
if (!entry || entry.ownership === "region") fail("upgrade-plan-stale", "projection target no longer exists", { exitCode: 1 });
|
|
457
|
+
operation = await publishProjection(root, project, { target: entry.target, output: entry.path, paths: entry.paths, write: options.write }, dependencies.projection);
|
|
458
|
+
} else {
|
|
459
|
+
fail("upgrade-migration-unsupported", `unsupported migration action: ${action.kind}`, { exitCode: 1 });
|
|
460
|
+
}
|
|
461
|
+
} catch (error) {
|
|
462
|
+
if (["ai-entry-ownership-conflict", "managed-file-ownership-conflict", "ai-entry-path-conflict"].includes(error?.code)) {
|
|
463
|
+
fail("upgrade-ownership-conflict", error.message, { exitCode: 1, cause: error, details: error.details });
|
|
464
|
+
}
|
|
465
|
+
if (["ai-entry-state-changed", "projection-state-changed", "project-state-changed"].includes(error?.code)) {
|
|
466
|
+
fail("upgrade-plan-stale", error.message, { exitCode: 1, cause: error, details: error.details });
|
|
467
|
+
}
|
|
468
|
+
const result = resultBundle(plan, manifest, {
|
|
469
|
+
mode: options.write ? "write" : "preview",
|
|
470
|
+
written: false,
|
|
471
|
+
targets: await targetStates(root, action.targets, before),
|
|
472
|
+
afterSnapshots: await afterSnapshots(root),
|
|
473
|
+
actionResult: `failed:${error?.code ?? "internal-error"}`,
|
|
474
|
+
findingCodes: ["upgrade-write-failed"],
|
|
475
|
+
coreMigration: "blocked",
|
|
476
|
+
});
|
|
477
|
+
return { result, exitCode: 2 };
|
|
478
|
+
}
|
|
479
|
+
const nextAssessment = options.write ? await buildUpgradeAssessment(root, plan.fromVersion, dependencies) : assessment;
|
|
480
|
+
const result = resultBundle(plan, manifest, {
|
|
481
|
+
mode: options.write ? "write" : "preview",
|
|
482
|
+
written: Boolean(options.write && operation.written),
|
|
483
|
+
targets: await targetStates(root, action.targets, before),
|
|
484
|
+
afterSnapshots: await afterSnapshots(root),
|
|
485
|
+
actionResult: operation.action,
|
|
486
|
+
findingCodes: options.write ? nextAssessment.findingCodes : [],
|
|
487
|
+
coreMigration: options.write ? nextAssessment.state === "core-complete" ? "complete" : "applied" : "previewed",
|
|
488
|
+
});
|
|
489
|
+
return { result, exitCode: 0 };
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
export async function applyMigrationPlanFile(root, planPath, options = {}, dependencies = {}) {
|
|
493
|
+
return applyMigrationPlan(root, await readArtifact(root, planPath, "plan"), options, dependencies);
|
|
494
|
+
}
|