#!/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.`);