import { createHash } from "node:crypto"; export const COMPATIBILITY_IMPACTS = Object.freeze([ "none", "additive", "behavior-change", "breaking", ]); const impactRank = new Map( COMPATIBILITY_IMPACTS.map((impact, index) => [impact, index]), ); /** @param {unknown} value @returns {unknown} */ export function canonicalizeRegistryValue(value) { if (Array.isArray(value)) { const projected = /** @type {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; } /** @param {unknown} value @returns {string} */ export function canonicalRegistryJson(value) { return JSON.stringify(canonicalizeRegistryValue(value)) ?? "undefined"; } /** @param {unknown} snapshot */ export function registrySnapshotDigest(snapshot) { return createHash("sha256") .update(canonicalRegistryJson(snapshot)) .digest("hex"); } /** @param {string} current @param {string} candidate */ function strongestImpact(current, candidate) { return (impactRank.get(candidate) ?? 0) > (impactRank.get(current) ?? 0) ? candidate : current; } /** @param {unknown} value */ function valueType(value) { if (value === null) return "null"; if (Array.isArray(value)) return "array"; return typeof value; } /** * @param {string} registryId * @param {string} rowName * @param {string} field * @param {string} kind */ function changeId(registryId, rowName, field, kind) { 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. * * @param {Readonly>} before * @param {Readonly>} after */ export function diffRegistrySnapshots(before, after) { const changes = /** @type {Array>} */ ([]); let impact = "none"; const beforeRegistries = /** @type {Map>} */ (new Map( /** @type {Array>} */ (before.registries ?? []).map( (registry) => [String(registry.registryId), registry], ), )); const afterRegistries = /** @type {Map>} */ (new Map( /** @type {Array>} */ (after.registries ?? []).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 = /** @type {Record} */ (previous.contract ?? {}); const currentContract = /** @type {Record} */ (current.contract ?? {}); 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( /** @type {string[]} */ ( currentContract.breakingFields ?? [] ), ); const beforeRows = /** @type {Record>} */ ( previous.rows ?? {} ); const afterRows = /** @type {Record>} */ (current.rows ?? {}); 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; let changeImpact; 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), }); } /** * @param {Readonly>} snapshot * @param {Readonly>} approval */ export function verifyRegistryBaselineApproval(snapshot, approval) { 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", }); } /** * @param {ReturnType} diff * @param {Readonly>} evidenceFile */ export function validateBreakingEvidence(diff, evidenceFile) { const evidence = new Map( /** @type {Array>} */ ( evidenceFile.changes ?? [] ).map((entry) => [entry.changeId, entry]), ); const failures = []; 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", ]) { if (typeof entry[field] !== "string" || entry[field].trim().length === 0) { failures.push( `breaking change ${change.changeId} missing non-empty ${field}`, ); } } } return Object.freeze({ passed: failures.length === 0, failures: Object.freeze(failures), }); }