diff --git a/config/compatibility/fixtures.json b/config/compatibility/fixtures.json new file mode 100644 index 0000000..fbe5d0c --- /dev/null +++ b/config/compatibility/fixtures.json @@ -0,0 +1,63 @@ +{ + "schemaVersion": 1, + "families": { + "api": { + "additive": { + "before": { "required": ["id"], "properties": { "id": {} } }, + "after": { + "required": ["id"], + "properties": { "id": {}, "displayName": {} } + } + }, + "breaking": { + "before": { "required": ["id"], "properties": { "id": {} } }, + "after": { + "required": ["id", "name"], + "properties": { "id": {}, "name": {} } + } + } + }, + "config": { + "additive": { + "before": { "required": ["APP_ENV"], "properties": { "APP_ENV": {} } }, + "after": { + "required": ["APP_ENV"], + "properties": { "APP_ENV": {}, "OPTIONAL_FLAG": {} } + } + }, + "breaking": { + "before": { "required": ["APP_ENV"], "properties": { "APP_ENV": {} } }, + "after": { + "required": ["APP_ENV", "NEW_REQUIRED"], + "properties": { "APP_ENV": {}, "NEW_REQUIRED": {} } + } + } + }, + "storage": { + "additive": { + "before": { "properties": { "theme": {} } }, + "after": { "properties": { "theme": {}, "contrast": {} } } + }, + "breaking": { + "before": { "properties": { "theme": {} } }, + "after": { "properties": {} } + } + }, + "release": { + "additive": { + "before": { "required": ["buildId"], "properties": { "buildId": {} } }, + "after": { + "required": ["buildId"], + "properties": { "buildId": {}, "builtAt": {} } + } + }, + "breaking": { + "before": { "required": ["buildId"], "properties": { "buildId": {} } }, + "after": { + "required": ["buildId", "assetManifestHash"], + "properties": { "buildId": {}, "assetManifestHash": {} } + } + } + } + } +} diff --git a/docs/contracts/compatibility.md b/docs/contracts/compatibility.md new file mode 100644 index 0000000..de3548f --- /dev/null +++ b/docs/contracts/compatibility.md @@ -0,0 +1,11 @@ +# Contract compatibility and rollback rules + +The blocking tuple is `(buildId, configSchemaVersion, apiContractVersion, +assetManifestHash, releaseId)`. Versions are parsed numerically. + +1. additive changes preserve current required fields +2. breaking changes require a major version bump +3. persisted cache is discarded unless an explicit tested migration exists +4. an incompatible config or API contract blocks product mount +5. rollback restores HTML, assets, runtime config, API compatibility, and + release manifest as one coherent set diff --git a/package.json b/package.json index 8aecda8..f02955a 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,8 @@ "generate:supply-chain": "node scripts/generate-supply-chain.mjs", "scan:security": "node scripts/security-scan.mjs", "check:browser-security": "node scripts/check-browser-security.mjs", - "check:registries": "node scripts/check-registries.mjs" + "check:registries": "node scripts/check-registries.mjs", + "verify:compatibility": "node scripts/check-compatibility.mjs" }, "dependencies": { "@tanstack/react-query": "5.101.4", diff --git a/scripts/check-compatibility.mjs b/scripts/check-compatibility.mjs new file mode 100644 index 0000000..2b32bd1 --- /dev/null +++ b/scripts/check-compatibility.mjs @@ -0,0 +1,43 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; + +import { classifyObjectSchemaChange } from "../src/application/policies/compatibility.js"; + +const fixtures = JSON.parse( + await readFile("config/compatibility/fixtures.json", "utf8"), +); +const results = []; + +for (const [family, cases] of Object.entries(fixtures.families)) { + for (const expected of ["additive", "breaking"]) { + const fixture = cases[expected]; + const actual = classifyObjectSchemaChange(fixture.before, fixture.after); + results.push({ family, expected, actual, passed: actual === expected }); + } +} + +await mkdir("artifacts/release", { recursive: true }); +await writeFile( + "artifacts/release/compatibility.json", + `${JSON.stringify( + { + schemaVersion: 1, + generatedAt: new Date().toISOString(), + rules: [ + "additive changes preserve required fields", + "breaking changes require version bump and migration, discard, fallback, or rollback", + "config and API major versions must match", + "incompatible persisted cache is discarded by default", + "rollback uses a coherent compatibility tuple", + ], + results, + }, + null, + 2, + )}\n`, +); + +if (results.some((result) => !result.passed)) { + process.stderr.write("Compatibility fixture classification failed.\n"); + process.exit(1); +} +process.stdout.write("Compatibility fixtures: PASS\n"); diff --git a/src/application/policies/compatibility.js b/src/application/policies/compatibility.js new file mode 100644 index 0000000..a304abd --- /dev/null +++ b/src/application/policies/compatibility.js @@ -0,0 +1,102 @@ +export const COMPATIBILITY_TUPLE_FIELDS = Object.freeze([ + "buildId", + "configSchemaVersion", + "apiContractVersion", + "assetManifestHash", + "releaseId", +]); + +/** @param {string} version */ +export function parseNumericVersion(version) { + const match = /^(\d+)(?:\.(\d+))?(?:\.(\d+))?$/.exec(version); + if (!match) return null; + return { + major: Number(match[1]), + minor: Number(match[2] ?? 0), + patch: Number(match[3] ?? 0), + }; +} + +/** @param {string} supported @param {string} actual */ +export function isVersionCompatible(supported, actual) { + const expected = parseNumericVersion(supported); + const candidate = parseNumericVersion(actual); + if (!expected || !candidate) return false; + return ( + expected.major === candidate.major && + candidate.minor >= expected.minor + ); +} + +/** + * @param {{ + * frontend: { + * buildId: string, + * configSchemaVersion: string, + * apiContractVersion: string, + * assetManifestHash: string, + * releaseId: string + * }, + * runtime: { + * buildId: string, + * configSchemaVersion: string, + * apiContractVersion: string, + * assetManifestHash: string, + * releaseId: string + * } + * }} input + */ +export function verifyCompatibilityTuple(input) { + const mismatches = []; + if (input.frontend.buildId !== input.runtime.buildId) mismatches.push("buildId"); + if ( + !isVersionCompatible( + input.frontend.configSchemaVersion, + input.runtime.configSchemaVersion, + ) + ) { + mismatches.push("configSchemaVersion"); + } + if ( + !isVersionCompatible( + input.frontend.apiContractVersion, + input.runtime.apiContractVersion, + ) + ) { + mismatches.push("apiContractVersion"); + } + if (input.frontend.assetManifestHash !== input.runtime.assetManifestHash) { + mismatches.push("assetManifestHash"); + } + + const releaseWarning = + input.frontend.releaseId === input.runtime.releaseId + ? null + : "releaseId"; + return Object.freeze({ + compatible: mismatches.length === 0, + mismatches: Object.freeze(mismatches), + warnings: Object.freeze(releaseWarning ? [releaseWarning] : []), + }); +} + +/** + * @param {{ required?: string[], properties?: Record }} before + * @param {{ required?: string[], properties?: Record }} after + */ +export function classifyObjectSchemaChange(before, after) { + const beforeRequired = new Set(before.required ?? []); + const afterRequired = new Set(after.required ?? []); + const removedProperties = Object.keys(before.properties ?? {}).filter( + (key) => !(key in (after.properties ?? {})), + ); + const addedRequired = [...afterRequired].filter( + (key) => !beforeRequired.has(key), + ); + if (removedProperties.length > 0 || addedRequired.length > 0) return "breaking"; + + const addedProperties = Object.keys(after.properties ?? {}).filter( + (key) => !(key in (before.properties ?? {})), + ); + return addedProperties.length > 0 ? "additive" : "none"; +} diff --git a/tests/unit/compatibility.test.js b/tests/unit/compatibility.test.js new file mode 100644 index 0000000..d46eafc --- /dev/null +++ b/tests/unit/compatibility.test.js @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; + +import { + classifyObjectSchemaChange, + isVersionCompatible, + parseNumericVersion, + verifyCompatibilityTuple, +} from "../../src/application/policies/compatibility.js"; + +describe("contract compatibility", () => { + it("uses numeric version parsing rather than lexical comparison", () => { + expect(parseNumericVersion("1.10.0")).toEqual({ major: 1, minor: 10, patch: 0 }); + expect(isVersionCompatible("1.9", "1.10")).toBe(true); + expect(isVersionCompatible("1.9", "2.0")).toBe(false); + expect(isVersionCompatible("next", "1.0")).toBe(false); + }); + + it("distinguishes additive and breaking object changes", () => { + const base = { required: ["id"], properties: { id: {} } }; + expect( + classifyObjectSchemaChange(base, { + required: ["id"], + properties: { id: {}, name: {} }, + }), + ).toBe("additive"); + expect( + classifyObjectSchemaChange(base, { + required: ["id", "name"], + properties: { id: {}, name: {} }, + }), + ).toBe("breaking"); + }); + + it("treats release ID mismatch as a warning when the blocking tuple is coherent", () => { + const frontend = { + buildId: "build-a", + configSchemaVersion: "1.0", + apiContractVersion: "1.0", + assetManifestHash: "hash-a", + releaseId: "release-a", + }; + expect( + verifyCompatibilityTuple({ + frontend, + runtime: { ...frontend, releaseId: "release-b" }, + }), + ).toEqual({ + compatible: true, + mismatches: [], + warnings: ["releaseId"], + }); + }); + + it("blocks mixed build, config, API, or asset tuples", () => { + const frontend = { + buildId: "build-a", + configSchemaVersion: "1.0", + apiContractVersion: "1.0", + assetManifestHash: "hash-a", + releaseId: "release-a", + }; + expect( + verifyCompatibilityTuple({ + frontend, + runtime: { + ...frontend, + buildId: "build-b", + configSchemaVersion: "2.0", + assetManifestHash: "hash-b", + }, + }), + ).toMatchObject({ + compatible: false, + mismatches: ["buildId", "configSchemaVersion", "assetManifestHash"], + }); + }); +});