Files
clean-architecture-frontend…/scripts/check-registries.mjs
T

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