322 lines
9.6 KiB
JavaScript
322 lines
9.6 KiB
JavaScript
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<Record<string, unknown>>} before
|
|
* @param {Readonly<Record<string, unknown>>} after
|
|
*/
|
|
export function diffRegistrySnapshots(before, after) {
|
|
const changes = /** @type {Array<Record<string, unknown>>} */ ([]);
|
|
let impact = "none";
|
|
const beforeRegistries =
|
|
/** @type {Map<string, Record<string, unknown>>} */ (new Map(
|
|
/** @type {Array<Record<string, unknown>>} */ (before.registries ?? []).map(
|
|
(registry) => [String(registry.registryId), registry],
|
|
),
|
|
));
|
|
const afterRegistries =
|
|
/** @type {Map<string, Record<string, unknown>>} */ (new Map(
|
|
/** @type {Array<Record<string, unknown>>} */ (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<string, unknown>} */ (previous.contract ?? {});
|
|
const currentContract =
|
|
/** @type {Record<string, unknown>} */ (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<string, Record<string, unknown>>} */ (
|
|
previous.rows ?? {}
|
|
);
|
|
const afterRows =
|
|
/** @type {Record<string, Record<string, unknown>>} */ (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<Record<string, unknown>>} snapshot
|
|
* @param {Readonly<Record<string, unknown>>} 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<typeof diffRegistrySnapshots>} diff
|
|
* @param {Readonly<Record<string, unknown>>} evidenceFile
|
|
*/
|
|
export function validateBreakingEvidence(diff, evidenceFile) {
|
|
const evidence = new Map(
|
|
/** @type {Array<Record<string, unknown>>} */ (
|
|
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),
|
|
});
|
|
}
|