412 lines
13 KiB
JavaScript
412 lines
13 KiB
JavaScript
import {
|
|
access,
|
|
mkdir,
|
|
readFile,
|
|
readdir,
|
|
writeFile,
|
|
} from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
|
|
import {
|
|
canonicalizeRegistryValue,
|
|
diffRegistrySnapshots,
|
|
registrySnapshotDigest,
|
|
validateBreakingEvidence,
|
|
verifyRegistryBaselineApproval,
|
|
} from "./lib/registry-compatibility.mjs";
|
|
|
|
/** @param {string} name @param {string | undefined} fallback */
|
|
function argumentValue(name, fallback) {
|
|
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 =
|
|
/** @type {string} */ (
|
|
argumentValue("--governance", defaultGovernancePath)
|
|
);
|
|
const artifactPath =
|
|
/** @type {string} */ (
|
|
argumentValue("--artifact", "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"));
|
|
const failures = [];
|
|
const owners = new Map();
|
|
const snapshots = [];
|
|
const rowsByRegistry = new Map();
|
|
const sourcesByRegistry = new Map();
|
|
const registryExtensions = [".js", ".jsx", ".mjs", ".ts", ".tsx", ".mts"];
|
|
|
|
/** @param {string} declaredPath */
|
|
async function resolveRegistrySource(declaredPath) {
|
|
const extension = path.extname(declaredPath);
|
|
const basePath = extension
|
|
? declaredPath.slice(0, -extension.length)
|
|
: declaredPath;
|
|
const candidates = [];
|
|
for (const candidateExtension of registryExtensions) {
|
|
const candidate = `${basePath}${candidateExtension}`;
|
|
try {
|
|
await access(candidate);
|
|
candidates.push(candidate);
|
|
} catch {
|
|
// A TypeScript migration may replace the declared extension.
|
|
}
|
|
}
|
|
if (candidates.length > 1) {
|
|
failures.push(
|
|
`ambiguous registry source ${declaredPath}: ${candidates.join(", ")}`,
|
|
);
|
|
return null;
|
|
}
|
|
return candidates[0] ?? null;
|
|
}
|
|
|
|
/** @param {unknown} value */
|
|
function runtimeType(value) {
|
|
if (value === null) return "null";
|
|
if (Array.isArray(value)) return "array";
|
|
if (Number.isInteger(value)) return "integer";
|
|
return typeof value;
|
|
}
|
|
|
|
/** @param {unknown} value @param {string} declaration */
|
|
function matchesDeclaredType(value, declaration) {
|
|
const actual = runtimeType(value);
|
|
return declaration
|
|
.split("|")
|
|
.some(
|
|
(candidate) =>
|
|
candidate === actual ||
|
|
(candidate === "number" && actual === "integer"),
|
|
);
|
|
}
|
|
|
|
/** @param {string} directory @returns {Promise<string[]>} */
|
|
async function filesBelow(directory) {
|
|
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) =>
|
|
/\.(?:js|jsx|mjs|ts|tsx|mts)$/.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 module = await import(
|
|
`${pathToFileURL(path.resolve(sourcePath)).href}?registry-check=${Date.now()}`
|
|
);
|
|
rows = module[specification.exportName];
|
|
} catch {
|
|
if (!rows) failures.push(`missing registry source ${specification.path}`);
|
|
}
|
|
|
|
if (!rows || typeof rows !== "object" || Array.isArray(rows)) {
|
|
failures.push(`${specification.registryId} is not an object registry`);
|
|
continue;
|
|
}
|
|
|
|
rowsByRegistry.set(specification.registryId, rows);
|
|
sourcesByRegistry.set(
|
|
specification.registryId,
|
|
sourcePath ?? specification.path,
|
|
);
|
|
|
|
for (const [rowName, row] of Object.entries(rows)) {
|
|
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 (!(field in row)) {
|
|
failures.push(`${specification.registryId}.${rowName} missing ${field}`);
|
|
}
|
|
}
|
|
for (const [field, declaredType] of Object.entries(
|
|
specification.fieldTypes ?? {},
|
|
)) {
|
|
if (
|
|
field in row &&
|
|
!matchesDeclaredType(row[field], String(declaredType))
|
|
) {
|
|
failures.push(
|
|
`${specification.registryId}.${rowName}.${field} expected ${declaredType}, received ${runtimeType(row[field])}`,
|
|
);
|
|
}
|
|
}
|
|
if (
|
|
specification.keyField &&
|
|
row[specification.keyField] !== rowName
|
|
) {
|
|
failures.push(
|
|
`${specification.registryId}.${rowName}.${specification.keyField} must match its registry key`,
|
|
);
|
|
}
|
|
}
|
|
|
|
for (const field of specification.uniqueFields ?? []) {
|
|
const values = new Map();
|
|
for (const [rowName, row] of Object.entries(rows)) {
|
|
if (!row || typeof row !== "object" || Array.isArray(row)) continue;
|
|
const value = row[field];
|
|
if (value === undefined) continue;
|
|
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 [field, allowed] of Object.entries(
|
|
specification.allowedValues ?? {},
|
|
)) {
|
|
for (const [rowName, row] of Object.entries(rows)) {
|
|
if (!row || typeof row !== "object" || Array.isArray(row)) continue;
|
|
if (
|
|
!allowed.some(
|
|
/** @param {unknown} value */
|
|
(value) => Object.is(value, row[field]),
|
|
)
|
|
) {
|
|
failures.push(
|
|
`${specification.registryId}.${rowName}.${field} has unknown value ${String(row[field])}`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
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 ?? [],
|
|
});
|
|
snapshots.push({
|
|
registryId: specification.registryId,
|
|
owner: specification.owner,
|
|
source: sourcePath ?? specification.path,
|
|
rowCount: Object.keys(rows).length,
|
|
contract,
|
|
rows: canonicalizeRegistryValue(rows),
|
|
});
|
|
}
|
|
|
|
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) => row[reference.targetField])
|
|
.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 = row[reference.field];
|
|
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 = row[specification.consumerIdentityField];
|
|
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 =
|
|
/** @type {Readonly<Record<string, unknown>>} */ (
|
|
canonicalizeRegistryValue({
|
|
schemaVersion: 2,
|
|
registries: snapshots,
|
|
})
|
|
);
|
|
let baselineDigest = null;
|
|
let currentDigest = registrySnapshotDigest(currentSnapshot);
|
|
let compatibility =
|
|
/** @type {{impact: string, changes: readonly Record<string, unknown>[]}} */ ({
|
|
impact: "not-evaluated",
|
|
changes: [],
|
|
});
|
|
|
|
if (baselinePath && approvalPath && evidencePath) {
|
|
try {
|
|
const baseline = JSON.parse(await readFile(baselinePath, "utf8"));
|
|
const approval = JSON.parse(await readFile(approvalPath, "utf8"));
|
|
const approvalResult = verifyRegistryBaselineApproval(baseline, approval);
|
|
baselineDigest = approvalResult.actualDigest;
|
|
if (!approvalResult.passed) {
|
|
failures.push(
|
|
`registry baseline approval digest mismatch: approved=${approvalResult.approvedDigest} actual=${approvalResult.actualDigest}`,
|
|
);
|
|
}
|
|
compatibility = diffRegistrySnapshots(baseline, currentSnapshot);
|
|
const evidence = JSON.parse(await readFile(evidencePath, "utf8"));
|
|
const evidenceResult = validateBreakingEvidence(compatibility, 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,
|
|
};
|
|
await mkdir(path.dirname(artifactPath), { recursive: true });
|
|
await writeFile(artifactPath, `${JSON.stringify(report, null, 2)}\n`);
|
|
|
|
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`,
|
|
);
|