feat: harden test and registry evidence

This commit is contained in:
donghyeon-ka
2026-07-26 17:15:26 +09:00
parent 3f634eb655
commit 98d4fd4960
68 changed files with 6167 additions and 250 deletions
+321
View File
@@ -0,0 +1,321 @@
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),
});
}