692 lines
21 KiB
TypeScript
692 lines
21 KiB
TypeScript
import {
|
|
access,
|
|
mkdir,
|
|
readFile,
|
|
readdir,
|
|
} from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
|
|
import {
|
|
canonicalizeRegistryValue,
|
|
diffRegistrySnapshots,
|
|
registrySnapshotDigest,
|
|
validateBreakingEvidence,
|
|
verifyRegistryBaselineApproval,
|
|
} from "./lib/registry-compatibility.ts";
|
|
import { assertMatchesJsonSchema } from "./lib/json-schema.ts";
|
|
import { registrySnapshotArtifactSchema } from "./contracts/release-artifacts.ts";
|
|
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
|
|
|
|
type RegistryRow = Record<string, unknown>;
|
|
type RegistryRows = Record<string, RegistryRow>;
|
|
type RegistryReference = Readonly<{
|
|
registryId: string;
|
|
field: string;
|
|
targetField: string;
|
|
}>;
|
|
type RegistryConsumer = Readonly<{ path: string; token: string }>;
|
|
type RegistrySnapshotProjection = Readonly<{
|
|
singletonRowKey: string;
|
|
canonicalArrayKeyFields: Readonly<Record<string, readonly string[]>>;
|
|
}>;
|
|
type RegistrySpecification = Readonly<{
|
|
registryId: string;
|
|
owner: string;
|
|
path: string;
|
|
exportName: string;
|
|
declaredRows?: unknown;
|
|
rowsPath?: string;
|
|
rowKeyFields?: readonly string[];
|
|
requiredFields: readonly string[];
|
|
fieldTypes?: Readonly<Record<string, string>>;
|
|
keyField?: string;
|
|
uniqueFields?: readonly string[];
|
|
uniqueFieldSets?: readonly (readonly string[])[];
|
|
snapshotProjection?: RegistrySnapshotProjection;
|
|
allowedValues?: Readonly<Record<string, readonly unknown[]>>;
|
|
references?: readonly RegistryReference[];
|
|
breakingFields?: readonly string[];
|
|
consumers?: readonly RegistryConsumer[];
|
|
consumerIdentityField?: string;
|
|
consumerDirectories?: readonly string[];
|
|
orphanExemptRows?: readonly string[];
|
|
}>;
|
|
type RegistryGovernance = Readonly<{
|
|
registries: readonly RegistrySpecification[];
|
|
sourceDirectories?: readonly string[];
|
|
}>;
|
|
type RegistrySnapshot = Record<string, unknown>;
|
|
type CompatibilitySummary = Readonly<{
|
|
impact: string;
|
|
changes: readonly unknown[];
|
|
}>;
|
|
|
|
function argumentValue(
|
|
name: string,
|
|
fallback: string | undefined,
|
|
): string | undefined {
|
|
const index = process.argv.indexOf(name);
|
|
return index >= 0 && process.argv[index + 1]
|
|
? process.argv[index + 1]
|
|
: fallback;
|
|
}
|
|
|
|
const defaultGovernancePath = "config/contracts/registry-governance.json";
|
|
const governancePath =
|
|
argumentValue("--governance", defaultGovernancePath) ??
|
|
defaultGovernancePath;
|
|
const artifactPath =
|
|
argumentValue("--artifact", "artifacts/quality/registries.json") ??
|
|
"artifacts/quality/registries.json";
|
|
const usesRepositoryBaseline =
|
|
governancePath === defaultGovernancePath &&
|
|
!process.argv.includes("--no-baseline");
|
|
const baselinePath = argumentValue(
|
|
"--baseline",
|
|
usesRepositoryBaseline
|
|
? "config/contracts/registry-baseline.json"
|
|
: undefined,
|
|
);
|
|
const approvalPath = argumentValue(
|
|
"--approval",
|
|
usesRepositoryBaseline
|
|
? "config/contracts/registry-baseline.approval.json"
|
|
: undefined,
|
|
);
|
|
const evidencePath = argumentValue(
|
|
"--compatibility-evidence",
|
|
usesRepositoryBaseline
|
|
? "config/contracts/registry-change-evidence.json"
|
|
: undefined,
|
|
);
|
|
const governance = JSON.parse(
|
|
await readFile(governancePath, "utf8"),
|
|
) as RegistryGovernance;
|
|
const failures: string[] = [];
|
|
const owners = new Map<string, string>();
|
|
const snapshots: RegistrySnapshot[] = [];
|
|
const rowsByRegistry = new Map<string, RegistryRows>();
|
|
const sourcesByRegistry = new Map<string, string>();
|
|
const registryExtensions = [".ts", ".tsx", ".mts", ".cts"];
|
|
|
|
async function resolveRegistrySource(
|
|
declaredPath: string,
|
|
): Promise<string | null> {
|
|
const extension = path.extname(declaredPath);
|
|
const basePath = extension
|
|
? declaredPath.slice(0, -extension.length)
|
|
: declaredPath;
|
|
const candidates: string[] = [];
|
|
for (const candidateExtension of registryExtensions) {
|
|
const candidate = `${basePath}${candidateExtension}`;
|
|
try {
|
|
await access(candidate);
|
|
candidates.push(candidate);
|
|
} catch {
|
|
// Continue through the supported TypeScript source extensions.
|
|
}
|
|
}
|
|
if (candidates.length > 1) {
|
|
failures.push(
|
|
`ambiguous registry source ${declaredPath}: ${candidates.join(", ")}`,
|
|
);
|
|
return null;
|
|
}
|
|
return candidates[0] ?? null;
|
|
}
|
|
|
|
function runtimeType(value: unknown): string {
|
|
if (value === null) return "null";
|
|
if (Array.isArray(value)) return "array";
|
|
if (Number.isInteger(value)) return "integer";
|
|
return typeof value;
|
|
}
|
|
|
|
function matchesDeclaredType(value: unknown, declaration: string): boolean {
|
|
const actual = runtimeType(value);
|
|
return declaration
|
|
.split("|")
|
|
.some(
|
|
(candidate) =>
|
|
candidate === actual ||
|
|
(candidate === "number" && actual === "integer"),
|
|
);
|
|
}
|
|
|
|
type FieldLookup = Readonly<{
|
|
found: boolean;
|
|
value: unknown;
|
|
}>;
|
|
|
|
function lookupField(value: unknown, fieldPath: string): FieldLookup {
|
|
let current = value;
|
|
for (const segment of fieldPath.split(".")) {
|
|
if (
|
|
!current ||
|
|
typeof current !== "object" ||
|
|
Array.isArray(current) ||
|
|
!Object.hasOwn(current, segment)
|
|
) {
|
|
return Object.freeze({ found: false, value: undefined });
|
|
}
|
|
current = (current as Record<string, unknown>)[segment];
|
|
}
|
|
return Object.freeze({ found: true, value: current });
|
|
}
|
|
|
|
function projectRegistryRows(
|
|
specification: RegistrySpecification,
|
|
exportedValue: unknown,
|
|
): RegistryRows | null {
|
|
const selected = specification.rowsPath
|
|
? lookupField(exportedValue, specification.rowsPath)
|
|
: Object.freeze({ found: true, value: exportedValue });
|
|
if (!selected.found) {
|
|
failures.push(
|
|
`${specification.registryId} is missing rows path ${specification.rowsPath}`,
|
|
);
|
|
return null;
|
|
}
|
|
if (!Array.isArray(selected.value)) {
|
|
if (
|
|
!selected.value ||
|
|
typeof selected.value !== "object"
|
|
) {
|
|
failures.push(`${specification.registryId} is not an object registry`);
|
|
return null;
|
|
}
|
|
return selected.value as RegistryRows;
|
|
}
|
|
|
|
const projected: RegistryRows = {};
|
|
const keyOccurrences = new Map<string, number>();
|
|
for (const [index, row] of selected.value.entries()) {
|
|
if (!row || typeof row !== "object" || Array.isArray(row)) {
|
|
projected[String(index)] = row as RegistryRow;
|
|
continue;
|
|
}
|
|
const keyValues = (specification.rowKeyFields ?? []).map(
|
|
(field) => lookupField(row, field).value,
|
|
);
|
|
const baseKey =
|
|
keyValues.length > 0
|
|
? JSON.stringify(keyValues.map(canonicalizeRegistryValue))
|
|
: String(index);
|
|
const occurrence = keyOccurrences.get(baseKey) ?? 0;
|
|
keyOccurrences.set(baseKey, occurrence + 1);
|
|
projected[occurrence === 0 ? baseKey : `${baseKey}#${occurrence + 1}`] =
|
|
row as RegistryRow;
|
|
}
|
|
return projected;
|
|
}
|
|
|
|
function snapshotKeyField(
|
|
value: unknown,
|
|
fieldPath: string,
|
|
): FieldLookup {
|
|
return fieldPath === "$value"
|
|
? Object.freeze({ found: true, value })
|
|
: lookupField(value, fieldPath);
|
|
}
|
|
|
|
function replaceProjectedField(
|
|
row: RegistryRow,
|
|
fieldPath: string,
|
|
value: unknown,
|
|
): boolean {
|
|
const segments = fieldPath.split(".");
|
|
const finalSegment = segments.pop();
|
|
if (!finalSegment) return false;
|
|
let current = row;
|
|
for (const segment of segments) {
|
|
const next = current[segment];
|
|
if (!next || typeof next !== "object" || Array.isArray(next)) {
|
|
return false;
|
|
}
|
|
current = next as RegistryRow;
|
|
}
|
|
current[finalSegment] = value;
|
|
return true;
|
|
}
|
|
|
|
function projectSnapshotRows(
|
|
specification: RegistrySpecification,
|
|
exportedValue: unknown,
|
|
validatedRows: RegistryRows,
|
|
): RegistryRows | null {
|
|
const projection = specification.snapshotProjection;
|
|
if (!projection) return validatedRows;
|
|
if (
|
|
!exportedValue ||
|
|
typeof exportedValue !== "object" ||
|
|
Array.isArray(exportedValue)
|
|
) {
|
|
failures.push(
|
|
`${specification.registryId} snapshot projection requires an object export`,
|
|
);
|
|
return null;
|
|
}
|
|
|
|
const canonicalRow = canonicalizeRegistryValue(exportedValue) as RegistryRow;
|
|
for (const [fieldPath, keyFields] of Object.entries(
|
|
projection.canonicalArrayKeyFields,
|
|
)) {
|
|
const selected = lookupField(exportedValue, fieldPath);
|
|
if (!selected.found || !Array.isArray(selected.value)) {
|
|
failures.push(
|
|
`${specification.registryId} snapshot field ${fieldPath} must be an array`,
|
|
);
|
|
return null;
|
|
}
|
|
const keyedItems: Array<{ identity: string; value: unknown }> = [];
|
|
const identities = new Set<string>();
|
|
for (const [index, item] of selected.value.entries()) {
|
|
const keyValues = keyFields.map((field) => snapshotKeyField(item, field));
|
|
const missingIndex = keyValues.findIndex((field) => !field.found);
|
|
if (missingIndex >= 0) {
|
|
failures.push(
|
|
`${specification.registryId} snapshot field ${fieldPath}[${index}] is missing key ${keyFields[missingIndex]}`,
|
|
);
|
|
return null;
|
|
}
|
|
const identity = JSON.stringify(
|
|
keyValues.map((field) => canonicalizeRegistryValue(field.value)),
|
|
);
|
|
if (identities.has(identity)) {
|
|
failures.push(
|
|
`${specification.registryId} snapshot field ${fieldPath} duplicates key ${identity}`,
|
|
);
|
|
return null;
|
|
}
|
|
identities.add(identity);
|
|
keyedItems.push({
|
|
identity,
|
|
value: canonicalizeRegistryValue(item),
|
|
});
|
|
}
|
|
keyedItems.sort((left, right) =>
|
|
left.identity.localeCompare(right.identity),
|
|
);
|
|
if (
|
|
!replaceProjectedField(
|
|
canonicalRow,
|
|
fieldPath,
|
|
keyedItems.map((item) => item.value),
|
|
)
|
|
) {
|
|
failures.push(
|
|
`${specification.registryId} snapshot field ${fieldPath} cannot be projected`,
|
|
);
|
|
return null;
|
|
}
|
|
}
|
|
return Object.freeze({
|
|
[projection.singletonRowKey]: canonicalRow,
|
|
});
|
|
}
|
|
|
|
async function filesBelow(directory: string): Promise<string[]> {
|
|
try {
|
|
const entries = await readdir(directory, { withFileTypes: true });
|
|
const groups = await Promise.all(
|
|
entries.map((entry) => {
|
|
const target = path.join(directory, entry.name);
|
|
return entry.isDirectory() ? filesBelow(target) : [target];
|
|
}),
|
|
);
|
|
return groups.flat().filter((file) =>
|
|
/\.(?:ts|tsx|mts|cts)$/.test(file),
|
|
);
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
for (const specification of governance.registries) {
|
|
if (owners.has(specification.registryId)) {
|
|
failures.push(`duplicate owner for ${specification.registryId}`);
|
|
}
|
|
owners.set(specification.registryId, specification.owner);
|
|
|
|
let rows = specification.declaredRows;
|
|
const sourcePath = await resolveRegistrySource(specification.path);
|
|
try {
|
|
if (!sourcePath) throw new Error("missing registry source");
|
|
const registryModule = (await import(
|
|
`${pathToFileURL(path.resolve(sourcePath)).href}?registry-check=${Date.now()}`
|
|
)) as Record<string, unknown>;
|
|
rows = registryModule[specification.exportName];
|
|
} catch {
|
|
if (!rows) failures.push(`missing registry source ${specification.path}`);
|
|
}
|
|
|
|
const registryRows = projectRegistryRows(specification, rows);
|
|
if (!registryRows) {
|
|
continue;
|
|
}
|
|
rowsByRegistry.set(specification.registryId, registryRows);
|
|
sourcesByRegistry.set(
|
|
specification.registryId,
|
|
sourcePath ?? specification.path,
|
|
);
|
|
|
|
for (const [rowName, row] of Object.entries(registryRows)) {
|
|
if (!row || typeof row !== "object" || Array.isArray(row)) {
|
|
failures.push(`${specification.registryId}.${rowName} is not an object`);
|
|
continue;
|
|
}
|
|
for (const field of specification.requiredFields) {
|
|
if (!lookupField(row, field).found) {
|
|
failures.push(`${specification.registryId}.${rowName} missing ${field}`);
|
|
}
|
|
}
|
|
for (const [field, declaredType] of Object.entries(
|
|
specification.fieldTypes ?? {},
|
|
)) {
|
|
if (
|
|
lookupField(row, field).found &&
|
|
!matchesDeclaredType(
|
|
lookupField(row, field).value,
|
|
String(declaredType),
|
|
)
|
|
) {
|
|
failures.push(
|
|
`${specification.registryId}.${rowName}.${field} expected ${declaredType}, received ${runtimeType(lookupField(row, field).value)}`,
|
|
);
|
|
}
|
|
}
|
|
if (
|
|
specification.keyField &&
|
|
lookupField(row, specification.keyField).value !== rowName
|
|
) {
|
|
failures.push(
|
|
`${specification.registryId}.${rowName}.${specification.keyField} must match its registry key`,
|
|
);
|
|
}
|
|
}
|
|
|
|
for (const field of specification.uniqueFields ?? []) {
|
|
const values = new Map<string, string>();
|
|
for (const [rowName, row] of Object.entries(registryRows)) {
|
|
if (!row || typeof row !== "object" || Array.isArray(row)) continue;
|
|
const selected = lookupField(row, field);
|
|
if (!selected.found) continue;
|
|
const value = selected.value;
|
|
const identity = JSON.stringify(canonicalizeRegistryValue(value));
|
|
if (values.has(identity)) {
|
|
failures.push(
|
|
`${specification.registryId}.${rowName} duplicates ${field}=${String(value)} from ${values.get(identity)}`,
|
|
);
|
|
} else {
|
|
values.set(identity, rowName);
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const fields of specification.uniqueFieldSets ?? []) {
|
|
const values = new Map<string, string>();
|
|
for (const [rowName, row] of Object.entries(registryRows)) {
|
|
if (!row || typeof row !== "object" || Array.isArray(row)) continue;
|
|
const selected = fields.map((field) => lookupField(row, field));
|
|
if (selected.some((field) => !field.found)) continue;
|
|
const identity = JSON.stringify(
|
|
selected.map((field) => canonicalizeRegistryValue(field.value)),
|
|
);
|
|
const previous = values.get(identity);
|
|
if (previous) {
|
|
failures.push(
|
|
`${specification.registryId}.${rowName} duplicates ${fields.join("+")}=${identity} from ${previous}`,
|
|
);
|
|
} else {
|
|
values.set(identity, rowName);
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const [field, allowed] of Object.entries(
|
|
specification.allowedValues ?? {},
|
|
)) {
|
|
for (const [rowName, row] of Object.entries(registryRows)) {
|
|
if (!row || typeof row !== "object" || Array.isArray(row)) continue;
|
|
if (
|
|
!allowed.some((value) =>
|
|
Object.is(value, lookupField(row, field).value),
|
|
)
|
|
) {
|
|
failures.push(
|
|
`${specification.registryId}.${rowName}.${field} has unknown value ${String(lookupField(row, field).value)}`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
const contract = Object.freeze({
|
|
requiredFields: specification.requiredFields,
|
|
fieldTypes: specification.fieldTypes ?? {},
|
|
uniqueFields: specification.uniqueFields ?? [],
|
|
allowedValues: specification.allowedValues ?? {},
|
|
references: specification.references ?? [],
|
|
keyField: specification.keyField ?? null,
|
|
breakingFields: specification.breakingFields ?? [],
|
|
...(specification.rowsPath
|
|
? { rowsPath: specification.rowsPath }
|
|
: {}),
|
|
...(specification.rowKeyFields
|
|
? { rowKeyFields: specification.rowKeyFields }
|
|
: {}),
|
|
...(specification.uniqueFieldSets
|
|
? { uniqueFieldSets: specification.uniqueFieldSets }
|
|
: {}),
|
|
...(specification.snapshotProjection
|
|
? { snapshotProjection: specification.snapshotProjection }
|
|
: {}),
|
|
});
|
|
const snapshotRows = projectSnapshotRows(specification, rows, registryRows);
|
|
if (!snapshotRows) continue;
|
|
snapshots.push({
|
|
registryId: specification.registryId,
|
|
owner: specification.owner,
|
|
source: sourcePath ?? specification.path,
|
|
rowCount: Object.keys(snapshotRows).length,
|
|
contract,
|
|
rows: canonicalizeRegistryValue(snapshotRows),
|
|
});
|
|
}
|
|
|
|
for (const specification of governance.registries) {
|
|
const rows = rowsByRegistry.get(specification.registryId);
|
|
if (!rows) continue;
|
|
for (const reference of specification.references ?? []) {
|
|
const targetRows = rowsByRegistry.get(reference.registryId);
|
|
if (!targetRows) {
|
|
failures.push(
|
|
`${specification.registryId} references unknown registry ${reference.registryId}`,
|
|
);
|
|
continue;
|
|
}
|
|
const targetValues = new Set(
|
|
Object.values(targetRows)
|
|
.filter((row) => row && typeof row === "object" && !Array.isArray(row))
|
|
.map((row) => lookupField(row, reference.targetField).value)
|
|
.filter((value) => value !== undefined && value !== null),
|
|
);
|
|
for (const [rowName, row] of Object.entries(rows)) {
|
|
if (!row || typeof row !== "object" || Array.isArray(row)) continue;
|
|
const value = lookupField(row, reference.field).value;
|
|
if (
|
|
value !== undefined &&
|
|
value !== null &&
|
|
!targetValues.has(value)
|
|
) {
|
|
failures.push(
|
|
`${specification.registryId}.${rowName}.${reference.field} references unknown ${reference.registryId}.${reference.targetField}=${String(value)}`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const consumer of specification.consumers ?? []) {
|
|
try {
|
|
const source = await readFile(consumer.path, "utf8");
|
|
if (!source.includes(consumer.token)) {
|
|
failures.push(
|
|
`${specification.registryId} consumer ${consumer.path} is missing ${consumer.token}`,
|
|
);
|
|
}
|
|
} catch {
|
|
failures.push(
|
|
`${specification.registryId} consumer source is missing: ${consumer.path}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (specification.consumerIdentityField) {
|
|
const consumerFiles = (
|
|
await Promise.all(
|
|
(specification.consumerDirectories ?? []).map(filesBelow),
|
|
)
|
|
).flat();
|
|
const sourcePath = sourcesByRegistry.get(specification.registryId);
|
|
const consumerText = (
|
|
await Promise.all(
|
|
consumerFiles
|
|
.filter((file) => file !== sourcePath)
|
|
.map((file) => readFile(file, "utf8")),
|
|
)
|
|
).join("\n");
|
|
const exemptions = new Set(specification.orphanExemptRows ?? []);
|
|
for (const [rowName, row] of Object.entries(rows)) {
|
|
if (
|
|
!row ||
|
|
typeof row !== "object" ||
|
|
Array.isArray(row) ||
|
|
exemptions.has(rowName)
|
|
) {
|
|
continue;
|
|
}
|
|
const identity = lookupField(
|
|
row,
|
|
specification.consumerIdentityField,
|
|
).value;
|
|
if (
|
|
(typeof identity !== "string" &&
|
|
typeof identity !== "number") ||
|
|
!consumerText.includes(String(identity))
|
|
) {
|
|
failures.push(
|
|
`${specification.registryId}.${rowName} has no executable consumer for ${specification.consumerIdentityField}=${String(identity)}`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const sourceFiles = governance.sourceDirectories ?? [
|
|
"src/application",
|
|
"src/presentation",
|
|
"src/domain",
|
|
];
|
|
const adHocPatterns = [
|
|
{ name: "direct fetch", expression: /\bfetch\s*\(/ },
|
|
{
|
|
name: "direct localStorage",
|
|
expression: /\blocalStorage\.(?:get|set|remove)Item/,
|
|
},
|
|
{ name: "direct import.meta.env", expression: /\bimport\.meta\.env\./ },
|
|
{ name: "raw API path", expression: /["']\/api\// },
|
|
];
|
|
|
|
for (const sourceDirectory of sourceFiles) {
|
|
for (const file of await filesBelow(sourceDirectory)) {
|
|
const content = await readFile(file, "utf8");
|
|
for (const pattern of adHocPatterns) {
|
|
if (pattern.expression.test(content)) {
|
|
failures.push(`ad-hoc ${pattern.name} in ${file}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const currentSnapshot = canonicalizeRegistryValue({
|
|
schemaVersion: 2,
|
|
registries: snapshots,
|
|
}) as Readonly<Record<string, unknown>>;
|
|
let baselineDigest: string | null = null;
|
|
const currentDigest = registrySnapshotDigest(currentSnapshot);
|
|
let compatibility: CompatibilitySummary = {
|
|
impact: "not-evaluated",
|
|
changes: [],
|
|
};
|
|
|
|
if (baselinePath && approvalPath && evidencePath) {
|
|
try {
|
|
const baseline = JSON.parse(
|
|
await readFile(baselinePath, "utf8"),
|
|
) as Record<string, unknown>;
|
|
const approval = JSON.parse(
|
|
await readFile(approvalPath, "utf8"),
|
|
) as Record<string, unknown>;
|
|
const approvalResult = verifyRegistryBaselineApproval(baseline, approval);
|
|
baselineDigest = approvalResult.actualDigest;
|
|
if (!approvalResult.passed) {
|
|
failures.push(
|
|
`registry baseline approval digest mismatch: approved=${approvalResult.approvedDigest} actual=${approvalResult.actualDigest}`,
|
|
);
|
|
}
|
|
const registryDiff = diffRegistrySnapshots(baseline, currentSnapshot);
|
|
compatibility = registryDiff;
|
|
const evidence = JSON.parse(
|
|
await readFile(evidencePath, "utf8"),
|
|
) as Record<string, unknown>;
|
|
const evidenceResult = validateBreakingEvidence(registryDiff, evidence);
|
|
failures.push(...evidenceResult.failures);
|
|
} catch (error) {
|
|
failures.push(
|
|
`registry compatibility evidence unavailable: ${
|
|
error instanceof Error ? error.name : "unknown"
|
|
}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
const report = {
|
|
schemaVersion: 2,
|
|
generatedAt: new Date().toISOString(),
|
|
baselineDigest,
|
|
currentDigest,
|
|
compatibility,
|
|
failures,
|
|
registries: snapshots,
|
|
};
|
|
if (usesRepositoryBaseline && failures.length === 0) {
|
|
try {
|
|
assertMatchesJsonSchema(
|
|
JSON.parse(
|
|
await readFile(
|
|
"schemas/artifacts/registry-snapshot.schema.json",
|
|
"utf8",
|
|
),
|
|
),
|
|
report,
|
|
"registry snapshot",
|
|
);
|
|
} catch {
|
|
failures.push("registry snapshot JSON Schema mismatch");
|
|
}
|
|
}
|
|
await mkdir(path.dirname(artifactPath), { recursive: true });
|
|
await writeValidatedJsonArtifact({
|
|
path: artifactPath,
|
|
schema: registrySnapshotArtifactSchema,
|
|
value: report,
|
|
});
|
|
|
|
if (failures.length > 0) {
|
|
process.stderr.write(`Registry governance failed:\n${failures.join("\n")}\n`);
|
|
process.exit(1);
|
|
}
|
|
process.stdout.write(
|
|
`Registry governance: ${snapshots.length} registries PASS; compatibility=${compatibility.impact}\n`,
|
|
);
|