220 lines
6.8 KiB
JavaScript
220 lines
6.8 KiB
JavaScript
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
|
|
/** @param {string} name @param {string} fallback */
|
|
function argumentValue(name, fallback) {
|
|
const index = process.argv.indexOf(name);
|
|
return index >= 0 && process.argv[index + 1] ? process.argv[index + 1] : fallback;
|
|
}
|
|
|
|
const governancePath = argumentValue(
|
|
"--governance",
|
|
"config/contracts/registry-governance.json",
|
|
);
|
|
const artifactPath = argumentValue(
|
|
"--artifact",
|
|
"artifacts/quality/registries.json",
|
|
);
|
|
const governance = JSON.parse(
|
|
await readFile(governancePath, "utf8"),
|
|
);
|
|
const failures = [];
|
|
const owners = new Map();
|
|
const snapshots = [];
|
|
const rowsByRegistry = 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 migration may legitimately replace the declared extension.
|
|
}
|
|
}
|
|
if (candidates.length > 1) {
|
|
failures.push(
|
|
`ambiguous registry source ${declaredPath}: ${candidates.join(", ")}`,
|
|
);
|
|
return null;
|
|
}
|
|
return candidates[0] ?? null;
|
|
}
|
|
|
|
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);
|
|
|
|
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 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;
|
|
if (values.has(value)) {
|
|
failures.push(
|
|
`${specification.registryId}.${rowName} duplicates ${field}=${String(value)} from ${values.get(value)}`,
|
|
);
|
|
} else {
|
|
values.set(value, 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])}`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
snapshots.push({
|
|
registryId: specification.registryId,
|
|
owner: specification.owner,
|
|
source: sourcePath ?? specification.path,
|
|
rowCount: Object.keys(rows).length,
|
|
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),
|
|
);
|
|
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 && !targetValues.has(value)) {
|
|
failures.push(
|
|
`${specification.registryId}.${rowName}.${reference.field} references unknown ${reference.registryId}.${reference.targetField}=${String(value)}`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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\// },
|
|
];
|
|
|
|
/** @param {string} directory */
|
|
async function scanDirectory(directory) {
|
|
const entries = await import("node:fs/promises").then(({ readdir }) =>
|
|
readdir(directory, { withFileTypes: true }),
|
|
);
|
|
for (const entry of entries) {
|
|
const target = path.join(directory, entry.name);
|
|
if (entry.isDirectory()) {
|
|
await scanDirectory(target);
|
|
continue;
|
|
}
|
|
if (!/\.(js|jsx|mjs|ts|tsx|mts)$/.test(entry.name)) continue;
|
|
const content = await readFile(target, "utf8");
|
|
for (const pattern of adHocPatterns) {
|
|
if (pattern.expression.test(content)) {
|
|
failures.push(`ad-hoc ${pattern.name} in ${target}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const sourceDirectory of sourceFiles) {
|
|
await scanDirectory(sourceDirectory);
|
|
}
|
|
|
|
await mkdir("artifacts/quality", { recursive: true });
|
|
await writeFile(
|
|
artifactPath,
|
|
`${JSON.stringify(
|
|
{
|
|
schemaVersion: 1,
|
|
generatedAt: new Date().toISOString(),
|
|
compatibilityImpact: governance.compatibilityImpact.current,
|
|
failures,
|
|
registries: snapshots,
|
|
},
|
|
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\n`);
|