Files
clean-architecture-frontend…/scripts/lib/registry-compatibility.ts
T

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),
});
}