97 lines
4.7 KiB
JavaScript
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.`);
|