feat: harden test and registry evidence
This commit is contained in:
@@ -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),
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user