import { createHash } from "node:crypto"; export const COMPATIBILITY_IMPACTS = Object.freeze([ "none", "additive", "behavior-change", "breaking", ] as const); type CompatibilityImpact = (typeof COMPATIBILITY_IMPACTS)[number]; type RegistryRecord = Record & { registryId?: unknown; contract?: unknown; rows?: unknown; }; type RegistryChange = { changeId: string; registryId: string; rowName: string; field: string; kind: string; impact: CompatibilityImpact; before?: unknown; after?: unknown; }; type RegistryDiff = Readonly<{ impact: CompatibilityImpact; changes: readonly RegistryChange[]; }>; const impactRank = new Map( COMPATIBILITY_IMPACTS.map((impact, index) => [impact, index]), ); export function canonicalizeRegistryValue(value: unknown): unknown { if (Array.isArray(value)) { const projected: unknown[] = value.map(canonicalizeRegistryValue); return projected.every( (item) => item === null || ["string", "number", "boolean"].includes(typeof item), ) ? projected.sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right)), ) : projected; } if (value && typeof value === "object") { return Object.fromEntries( Object.entries(value) .sort(([left], [right]) => left.localeCompare(right)) .map(([key, item]) => [key, canonicalizeRegistryValue(item)]), ); } return value; } export function canonicalRegistryJson(value: unknown): string { return JSON.stringify(canonicalizeRegistryValue(value)) ?? "undefined"; } export function registrySnapshotDigest(snapshot: unknown): string { return createHash("sha256") .update(canonicalRegistryJson(snapshot)) .digest("hex"); } function strongestImpact( current: CompatibilityImpact, candidate: CompatibilityImpact, ): CompatibilityImpact { return (impactRank.get(candidate) ?? 0) > (impactRank.get(current) ?? 0) ? candidate : current; } function valueType(value: unknown): string { if (value === null) return "null"; if (Array.isArray(value)) return "array"; return typeof value; } function changeId( registryId: string, rowName: string, field: string, kind: string, ): string { return `${registryId}:${rowName}:${field}:${kind}`; } /** * Calculates a semantic diff. Object key and primitive-array ordering is * canonicalized before comparison and therefore cannot create a false change. */ export function diffRegistrySnapshots( before: Readonly>, after: Readonly>, ): RegistryDiff { const changes: RegistryChange[] = []; let impact: CompatibilityImpact = "none"; const beforeRegistryRows = (before.registries ?? []) as RegistryRecord[]; const beforeRegistries = new Map( beforeRegistryRows.map((registry) => [ String(registry.registryId), registry, ]), ); const afterRegistryRows = (after.registries ?? []) as RegistryRecord[]; const afterRegistries = new Map( afterRegistryRows.map((registry) => [ String(registry.registryId), registry, ]), ); const registryIds = new Set([ ...beforeRegistries.keys(), ...afterRegistries.keys(), ]); for (const registryId of [...registryIds].sort()) { const previous = beforeRegistries.get(registryId); const current = afterRegistries.get(registryId); if (!previous || !current) { const changeImpact = previous ? "breaking" : "additive"; impact = strongestImpact(impact, changeImpact); changes.push({ changeId: changeId(registryId, "*", "*", previous ? "removed" : "added"), registryId, rowName: "*", field: "*", kind: previous ? "registry-removed" : "registry-added", impact: changeImpact, }); continue; } const previousContract = (previous.contract ?? {}) as Record; const currentContract = (current.contract ?? {}) as Record; const contractFields = new Set([ ...Object.keys(previousContract), ...Object.keys(currentContract), ]); for (const field of [...contractFields].sort()) { const beforeHas = Object.hasOwn(previousContract, field); const afterHas = Object.hasOwn(currentContract, field); const beforeValue = previousContract[field]; const afterValue = currentContract[field]; if ( beforeHas && afterHas && canonicalRegistryJson(beforeValue) === canonicalRegistryJson(afterValue) ) { continue; } const kind = !beforeHas ? "contract-field-added" : !afterHas ? "contract-field-removed" : "contract-field-changed"; impact = strongestImpact(impact, "breaking"); changes.push({ changeId: changeId(registryId, "$contract", field, kind), registryId, rowName: "$contract", field, kind, impact: "breaking", before: canonicalizeRegistryValue(beforeValue), after: canonicalizeRegistryValue(afterValue), }); } const breakingFields = new Set( (currentContract.breakingFields ?? []) as string[], ); const beforeRows = (previous.rows ?? {}) as Record< string, Record >; const afterRows = (current.rows ?? {}) as Record< string, Record >; const rowNames = new Set([ ...Object.keys(beforeRows), ...Object.keys(afterRows), ]); for (const rowName of [...rowNames].sort()) { const beforeRow = beforeRows[rowName]; const afterRow = afterRows[rowName]; if (!beforeRow || !afterRow) { const changeImpact = beforeRow ? "breaking" : "additive"; impact = strongestImpact(impact, changeImpact); changes.push({ changeId: changeId( registryId, rowName, "*", beforeRow ? "removed" : "added", ), registryId, rowName, field: "*", kind: beforeRow ? "row-removed" : "row-added", impact: changeImpact, }); continue; } const fields = new Set([ ...Object.keys(beforeRow), ...Object.keys(afterRow), ]); for (const field of [...fields].sort()) { const beforeHas = Object.hasOwn(beforeRow, field); const afterHas = Object.hasOwn(afterRow, field); const beforeValue = beforeRow[field]; const afterValue = afterRow[field]; if ( beforeHas && afterHas && canonicalRegistryJson(beforeValue) === canonicalRegistryJson(afterValue) ) { continue; } let kind: string; let changeImpact: CompatibilityImpact; if (!beforeHas) { kind = "field-added"; changeImpact = "additive"; } else if (!afterHas) { kind = "field-removed"; changeImpact = "breaking"; } else if (valueType(beforeValue) !== valueType(afterValue)) { kind = "field-type-changed"; changeImpact = "breaking"; } else if ( Array.isArray(beforeValue) && Array.isArray(afterValue) && beforeValue.some( (item) => !afterValue.some( (candidate) => canonicalRegistryJson(candidate) === canonicalRegistryJson(item), ), ) ) { kind = "allowed-value-removed"; changeImpact = "breaking"; } else { kind = "field-changed"; changeImpact = breakingFields.has(field) ? "breaking" : "behavior-change"; } impact = strongestImpact(impact, changeImpact); changes.push({ changeId: changeId(registryId, rowName, field, kind), registryId, rowName, field, kind, impact: changeImpact, before: canonicalizeRegistryValue(beforeValue), after: canonicalizeRegistryValue(afterValue), }); } } } return Object.freeze({ impact, changes: Object.freeze(changes), }); } export function verifyRegistryBaselineApproval( snapshot: Readonly>, approval: Readonly>, ) { const actualDigest = registrySnapshotDigest(snapshot); const approvedDigest = approval.snapshotDigest; return Object.freeze({ passed: approval.schemaVersion === 1 && typeof approval.owner === "string" && approval.owner.length > 0 && typeof approval.approvedAt === "string" && approvedDigest === actualDigest, actualDigest, approvedDigest: typeof approvedDigest === "string" ? approvedDigest : "missing", }); } export function validateBreakingEvidence( diff: RegistryDiff, evidenceFile: Readonly>, ) { const entries = (evidenceFile.changes ?? []) as Array>; const evidence = new Map>( entries.map((entry) => [String(entry.changeId), entry]), ); const failures: string[] = []; for (const change of diff.changes.filter( (entry) => entry.impact === "breaking", )) { const entry = evidence.get(change.changeId); if (!entry) { failures.push(`breaking change missing evidence: ${change.changeId}`); continue; } for (const field of [ "versionBump", "migration", "compatibilityWindow", "rollback", "owner", ]) { const value = entry[field]; if (typeof value !== "string" || value.trim().length === 0) { failures.push( `breaking change ${change.changeId} missing non-empty ${field}`, ); } } } return Object.freeze({ passed: failures.length === 0, failures: Object.freeze(failures), }); }