diff --git a/src/capability-language/authoring-check.ts b/src/capability-language/authoring-check.ts index 8c696ad..5f65355 100644 --- a/src/capability-language/authoring-check.ts +++ b/src/capability-language/authoring-check.ts @@ -7,7 +7,12 @@ import type { convergeAuthoring } from "./authoring-converge.js"; import { execFile as callback } from "node:child_process"; import { promisify } from "node:util"; import { buildImmutableCandidate, checkerIdentity } from "./checked-build.js"; -import { planEvolution, type WorkspaceRevision, type EvolutionReview } from "../capability-model/index.js"; +import { + planEvolution, + reviewSemanticMajors, + type WorkspaceRevision, + type EvolutionReview, +} from "../capability-model/index.js"; export const checkRecordName = (directory: string) => createHash("sha256").update(directory).digest("hex") + ".json"; export async function checkAuthoring( @@ -105,7 +110,20 @@ export async function checkAuthoring( } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } - if (resource.kind === "workspace" && !options.contractOnly && report.runtime !== "execution") { + if (resource.kind === "workspace" && !options.contractOnly && report.runtime === "execution") { + report.phase = "semantic-review"; + if (options.reviews && !options.baseline) + throw new Error("Execution semantic reviews require an explicit --baseline candidate"); + const baseline = options.baseline + ? (JSON.parse(await fs.readFile(options.baseline, "utf8")) as WorkspaceRevision) + : null; + const receipts = options.reviews + ? (JSON.parse(await fs.readFile(options.reviews, "utf8")) as EvolutionReview[]) + : []; + const review = reviewSemanticMajors(baseline, JSON.parse(candidateText), receipts); + await fs.writeFile(path.join(output, "semantic-review.json"), JSON.stringify(review, null, 2)); + report.blockers.push(...review.blockers); + } else if (resource.kind === "workspace" && !options.contractOnly) { report.phase = "evolution"; await progress(); let baseline = options.baseline; @@ -154,7 +172,7 @@ export async function checkAuthoring( await fs.writeFile(temporary, JSON.stringify(value, null, 2), { flag: "wx", mode: 0o600 }); await fs.rename(temporary, filename); }; - if (report.artifactPath) { + if (report.artifactPath && report.compilation === "passed" && !report.blockers.length) { const graph = JSON.parse(await fs.readFile(path.join(report.artifactPath, "graph.json"), "utf8")); for (const checked of graph.resources) { const managed = context.resources.find( diff --git a/src/capability-model/evolution.ts b/src/capability-model/evolution.ts index 0d9b0bd..0133b80 100644 --- a/src/capability-model/evolution.ts +++ b/src/capability-model/evolution.ts @@ -367,6 +367,36 @@ export const planEvolution = ( ...(next ? { candidate: next } : {}), }); } + const semanticReview = assessSemanticReviews(baseline, candidate, options.reviews ?? [], checkerVersion, [ + ...nextRuntimes.values(), + ]); + const reviews = semanticReview.reviews; + blockers.push(...semanticReview.blockers); + return { + schemaVersion: 1, + baselineDigest: baseline ? contentDigest(baseline) : null, + candidateDigest, + checkerVersion, + runtimeActions, + storageChanges, + migrationRequired: storageChanges.filter((entry) => entry.requiresMigration).map((entry) => entry.id), + reviews, + packageChecks: runtimeActions + .filter((entry) => entry.candidate && entry.action !== "keep") + .map((entry) => ({ groupId: entry.groupId, contractDigest: entry.candidate!.digest })), + blockers, + }; +}; + +/** Review dependency contracts, without making any storage or process-placement decision. */ +function assessSemanticReviews( + baseline: WorkspaceRevision | null, + candidate: WorkspaceRevision, + receipts: readonly EvolutionReview[], + checkerVersion: string, + consumers: readonly RuntimeContract[], +): Pick { + const blockers: string[] = []; const providers = (workspace: WorkspaceRevision) => [ ...workspace.packageImports.map((entry) => ({ id: entry.packageId as string, @@ -393,7 +423,7 @@ export const planEvolution = ( } if (!old[0] || old[0].major === provider.major) continue; if (provider.major < old[0].major) blockers.push(`Semantic major decreases for ${provider.id}`); - for (const consumer of nextRuntimes.values()) { + for (const consumer of consumers) { if (consumer.packageId === provider.id || !consumer.reviewProviders.includes(provider.id)) continue; const requirement = { consumerId: consumer.groupId, @@ -408,7 +438,7 @@ export const planEvolution = ( consumer: consumer.digest, checkerVersion, }); - const accepted = (options.reviews ?? []).some( + const accepted = receipts.some( (entry) => entry.requirementDigest === requirementDigest && ["changed", "accepted-unchanged"].includes(entry.decision) && @@ -419,18 +449,27 @@ export const planEvolution = ( if (!accepted) blockers.push(`Semantic-major review required: ${consumer.groupId} consumes ${provider.id}`); } } + return { reviews, blockers }; +} + +export function reviewSemanticMajors( + baseline: WorkspaceRevision | null, + candidate: WorkspaceRevision, + receipts: readonly EvolutionReview[] = [], +) { + for (const workspace of baseline ? [baseline, candidate] : [candidate]) { + const issues = validateWorkspaceRevision(workspace); + if (issues.length) + throw new Error("Invalid semantic-review workspace: " + issues.map((issue) => issue.message).join("; ")); + } + if (baseline && baseline.workspaceId !== candidate.workspaceId) + throw new Error("Cannot review a different workspace"); + const checkerVersion = "quixos-semantic-review-v1"; + const report = assessSemanticReviews(baseline, candidate, receipts, checkerVersion, runtimeContracts(candidate)); return { - schemaVersion: 1, - baselineDigest: baseline ? contentDigest(baseline) : null, - candidateDigest, checkerVersion, - runtimeActions, - storageChanges, - migrationRequired: storageChanges.filter((entry) => entry.requiresMigration).map((entry) => entry.id), - reviews, - packageChecks: runtimeActions - .filter((entry) => entry.candidate && entry.action !== "keep") - .map((entry) => ({ groupId: entry.groupId, contractDigest: entry.candidate!.digest })), - blockers, + baselineDigest: baseline ? contentDigest(baseline) : null, + candidateDigest: contentDigest(candidate), + ...report, }; -}; +} diff --git a/test/evolution.test.ts b/test/evolution.test.ts index 93c91b1..97ccb97 100644 --- a/test/evolution.test.ts +++ b/test/evolution.test.ts @@ -4,6 +4,7 @@ import { capabilityId as id, valueType, validateWorkspaceRevision } from "../src import { contentDigest, planEvolution, + reviewSemanticMajors, runtimeContracts, storageContracts, storageChangeRequiresMigration, @@ -126,6 +127,42 @@ test("semantic review receipts cover exact consumer/provider contracts", () => { assert.equal(planEvolution(before, after, { allowLegacy: true, reviews: [receipt] }).reviews[0]!.accepted, false); }); +test("execution semantic review is independent of storage migration and process action planning", () => { + const before = makeValidCapabilityWorkspace(); + before.conformances[0]!.id = id.conformance("conformance:project:named"); + const after = structuredClone(before); + after.conformances[0]!.semanticMajor = 2; + const report = reviewSemanticMajors(before, after); + assert.equal(report.checkerVersion, "quixos-semantic-review-v1"); + assert.equal(report.reviews.length, 1); + assert.equal(report.reviews[0]!.accepted, false); + assert.equal("migrationRequired" in report, false); + assert.equal("runtimeActions" in report, false); + assert.equal(report.blockers.length, 1); + const receipt = { + requirementDigest: report.reviews[0]!.requirementDigest, + decision: "accepted-unchanged" as const, + rationale: "The consumer only displays this provider's unchanged label contract", + agentId: "fixture-agent", + }; + assert.deepEqual(reviewSemanticMajors(before, after, [receipt]).blockers, []); + const oldReceipt = { + ...receipt, + requirementDigest: planEvolution(before, after, { allowLegacy: true }).reviews[0]!.requirementDigest, + }; + assert.equal(reviewSemanticMajors(before, after, [oldReceipt]).reviews[0]!.accepted, false); + after.packageImports[0]!.source.commit = "f".repeat(40); + assert.equal(reviewSemanticMajors(before, after, [receipt]).reviews[0]!.accepted, false); + assert.deepEqual(reviewSemanticMajors(null, after).blockers, []); + assert.throws( + () => reviewSemanticMajors(before, { ...after, workspaceId: id.workspace("different") }), + /different workspace/, + ); + const earlier = structuredClone(after); + earlier.conformances[0]!.semanticMajor = 1; + assert.ok(reviewSemanticMajors(after, earlier).blockers.some((message) => message.includes("decreases"))); +}); + test("evolution enrollment is explicit and never erases legacy ownership", () => { const workspace = makeValidCapabilityWorkspace(); const report = planEvolution(workspace, workspace);