337 lines
9.8 KiB
TypeScript
337 lines
9.8 KiB
TypeScript
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<string, unknown> & {
|
|
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<Record<string, unknown>>,
|
|
after: Readonly<Record<string, unknown>>,
|
|
): RegistryDiff {
|
|
const changes: RegistryChange[] = [];
|
|
let impact: CompatibilityImpact = "none";
|
|
const beforeRegistryRows = (before.registries ?? []) as RegistryRecord[];
|
|
const beforeRegistries = new Map<string, RegistryRecord>(
|
|
beforeRegistryRows.map((registry) => [
|
|
String(registry.registryId),
|
|
registry,
|
|
]),
|
|
);
|
|
const afterRegistryRows = (after.registries ?? []) as RegistryRecord[];
|
|
const afterRegistries = new Map<string, RegistryRecord>(
|
|
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<string, unknown>;
|
|
const currentContract = (current.contract ?? {}) as Record<string, unknown>;
|
|
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<string, unknown>
|
|
>;
|
|
const afterRows = (current.rows ?? {}) as Record<
|
|
string,
|
|
Record<string, unknown>
|
|
>;
|
|
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<Record<string, unknown>>,
|
|
approval: Readonly<Record<string, unknown>>,
|
|
) {
|
|
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<Record<string, unknown>>,
|
|
) {
|
|
const entries = (evidenceFile.changes ?? []) as Array<Record<string, unknown>>;
|
|
const evidence = new Map<string, Record<string, unknown>>(
|
|
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),
|
|
});
|
|
}
|