Files
projectbrain-Public/scripts/check-architecture-budget.mjs
T

97 lines
4.7 KiB
JavaScript

#!/usr/bin/env node
import { readFile, readdir, stat } from "node:fs/promises";
import { createHash } from "node:crypto";
import { resolve, relative, extname, dirname } from "node:path";
import process from "node:process";
const root = resolve(import.meta.dirname, "..");
const config = JSON.parse(await readFile(resolve(root, "config/architecture-budgets.json"), "utf8"));
const normalized = (path) => relative(root, path).replaceAll("\\", "/");
const sourceRoots = [resolve(root, "src"), resolve(root, "backend/src")];
const walk = async (directory) => {
const entries = await readdir(directory, { withFileTypes: true });
const nested = await Promise.all(entries.map((entry) => entry.isDirectory() ? walk(resolve(directory, entry.name)) : [resolve(directory, entry.name)]));
return nested.flat();
};
const failures = [];
const sources = (await Promise.all(sourceRoots.map(walk))).flat().filter((path) => [".ts", ".tsx"].includes(extname(path)));
const sourceSet = new Set(sources);
const contentByPath = new Map();
for (const path of sources) {
const name = normalized(path);
const content = await readFile(path, "utf8");
contentByPath.set(path, content);
const lines = content.split(/\r?\n/).length;
const exception = config.temporaryFileExceptions[name];
const maximum = exception?.maxLines ?? config.maximumNewSourceLines;
if (lines > maximum) failures.push(`${name}: ${lines} lines exceeds ${maximum}${exception ? " temporary ceiling" : " general ceiling"}`);
}
const resolveImport = (from, specifier) => {
if (!specifier.startsWith(".") && !specifier.startsWith("@/")) return undefined;
const base = specifier.startsWith("@/") ? resolve(root, "src", specifier.slice(2)) : resolve(dirname(from), specifier);
return [base, `${base}.ts`, `${base}.tsx`, resolve(base, "index.ts"), resolve(base, "index.tsx")].find((candidate) => sourceSet.has(candidate));
};
const graph = new Map(sources.map((path) => {
const imports = [...contentByPath.get(path).matchAll(/(?:from\s+|import\s*\(\s*|import\s+)["']([^"']+)["']/g)]
.map((match) => resolveImport(path, match[1]))
.filter(Boolean);
return [path, [...new Set(imports)]];
}));
const visiting = new Set();
const visited = new Set();
const stack = [];
const cycles = new Set();
const visit = (path) => {
if (visiting.has(path)) {
const start = stack.indexOf(path);
cycles.add([...stack.slice(start), path].map(normalized).join(" -> "));
return;
}
if (visited.has(path)) return;
visiting.add(path); stack.push(path);
for (const dependency of graph.get(path) ?? []) visit(dependency);
stack.pop(); visiting.delete(path); visited.add(path);
};
for (const path of sources) visit(path);
for (const cycle of cycles) failures.push(`circular import: ${cycle}`);
const hashes = new Map();
for (const [path, content] of contentByPath) {
if (content.split(/\r?\n/).length < 20) continue;
const hash = createHash("sha256").update(content.replaceAll(/\s+/g, " ").trim()).digest("hex");
const duplicate = hashes.get(hash);
if (duplicate) failures.push(`exact duplicate modules: ${normalized(duplicate)} and ${normalized(path)}`);
else hashes.set(hash, path);
}
const forbiddenRoots = [resolve(root, "src/shared/components/ui"), resolve(root, "src/shared/components/layout/AppLayout.tsx")];
for (const path of forbiddenRoots) {
try { await stat(path); failures.push(`${normalized(path)}: deprecated duplicate UI boundary still exists`); } catch (error) { if (error.code !== "ENOENT") throw error; }
}
const distAssets = resolve(root, "dist/assets");
try {
const assets = (await readdir(distAssets)).filter((name) => name.endsWith(".js"));
for (const asset of assets) {
const bytes = (await stat(resolve(distAssets, asset))).size;
if (/^index-/.test(asset) && bytes > config.bundle.maxEntryBytes) failures.push(`${asset}: ${bytes} bytes exceeds entry budget ${config.bundle.maxEntryBytes}`);
if (!/^vendor-|^index-|^rolldown-runtime-/.test(asset)) {
const exception = Object.entries(config.bundle.routeExceptions ?? {}).find(([prefix]) => asset.startsWith(prefix))?.[1];
const maximum = exception?.maxBytes ?? config.bundle.maxRouteBytes;
if (bytes > maximum) failures.push(`${asset}: ${bytes} bytes exceeds route budget ${maximum}${exception ? " temporary ceiling" : ""}`);
}
}
} catch (error) {
if (error.code !== "ENOENT") throw error;
failures.push("dist/assets is missing; run pnpm build before the architecture audit");
}
if (failures.length) {
console.error("Architecture budget failed:\n- " + failures.join("\n- "));
process.exit(1);
}
console.log(`Architecture budget passed for ${sources.length} source files; ${Object.keys(config.temporaryFileExceptions).length} frozen exceptions, no import cycles/exact duplicate modules and production bundle ceilings verified.`);