import { readdir, readFile, stat } from "node:fs/promises"; import path from "node:path"; import { pathToFileURL } from "node:url"; type Layer = "app" | "builder" | "core" | "module" | "neutral" | "shared" | "surface"; interface Location { layer: Layer; owner: string; relativePath: string; role?: string; } interface PackageManifest { dependencies?: Record; devDependencies?: Record; ekklesia?: { layer?: string }; exports?: unknown; name?: string; optionalDependencies?: Record; peerDependencies?: Record; } interface WorkspacePackage { directory: string; location: Location; manifest: PackageManifest; name: string; } export interface BoundaryViolation { from: string; message: string; rule: string; to?: string; } export interface BoundaryResult { filesChecked: number; packagesChecked: number; violations: BoundaryViolation[]; } const CODE_EXTENSIONS = new Set([".cjs", ".cts", ".js", ".jsx", ".mjs", ".mts", ".ts", ".tsx"]); const MODULE_UI_EXTENSIONS = new Set([".css", ".jsx", ".less", ".scss", ".svelte", ".tsx", ".vue"]); const MODULE_UI_PACKAGES = new Set([ "@angular/core", "expo", "next", "react", "react-dom", "react-native", "solid-js", "svelte", "tailwindcss", "vue", ]); const IGNORED_DIRECTORIES = new Set([ ".git", ".next", ".turbo", "coverage", "dist", "node_modules", ]); const DEPENDENCY_FIELDS = [ "dependencies", "devDependencies", "optionalDependencies", "peerDependencies", ] as const; function normalizePath(value: string): string { return value.split(path.sep).join("/").replace(/^\.\//, ""); } export function describeRepositoryPath(relativePath: string): Location { const normalized = normalizePath(relativePath); const [area, owner = area] = normalized.split("/"); if (area === "core") { return { layer: "core", owner: "core", relativePath: normalized }; } if (area === "modules" && owner) { return { layer: "module", owner, relativePath: normalized }; } if (area === "surfaces" && owner) { return { layer: "surface", owner, relativePath: normalized }; } if ((area === "builders" || area === "surface-builder") && owner) { return { layer: "builder", owner, relativePath: normalized }; } if (area === "apps" && owner) { return { layer: "app", owner, relativePath: normalized }; } if (area === "packages" && owner) { return { layer: "shared", owner, relativePath: normalized, role: owner }; } if (area === "templates" && owner === "module") { return { layer: "module", owner: "template-module", relativePath: normalized }; } return { layer: "neutral", owner: area ?? "root", relativePath: normalized }; } function boundaryRule(from: Location, to: Location): string | undefined { if (from.layer === "neutral" || to.layer === "neutral") { return undefined; } if (from.layer === to.layer && from.owner === to.owner) { return undefined; } if (from.layer === "core" && ["app", "builder", "module", "surface"].includes(to.layer)) { return "core-is-feature-agnostic"; } if (from.layer === "module") { if (["app", "builder", "core", "surface"].includes(to.layer)) { return "modules-use-public-platform-contracts"; } if (to.layer === "module" && from.owner !== to.owner) { return "modules-do-not-import-other-modules"; } } if (from.layer === "surface") { if (["app", "builder", "core", "module"].includes(to.layer)) { return "surfaces-use-public-contracts"; } if (to.layer === "surface" && from.owner !== to.owner) { return "surfaces-are-independent"; } } if (from.layer === "builder" && ["app", "core", "module", "surface"].includes(to.layer)) { return "builder-composes-contracts"; } if ( from.layer === "shared" && from.role === "contracts" && ["app", "builder", "core", "module", "surface"].includes(to.layer) ) { return "contracts-do-not-depend-upward"; } if ( from.layer === "shared" && ["module-sdk", "surface-sdk"].includes(from.role ?? "") && ["app", "builder", "core", "module", "surface"].includes(to.layer) ) { return "sdks-do-not-depend-on-implementations"; } return undefined; } function packageNameFromSpecifier(specifier: string): string { if (specifier.startsWith("@")) { return specifier.split("/").slice(0, 2).join("/"); } return specifier.split("/")[0] ?? specifier; } function isModuleUiPackage(packageName: string): boolean { return MODULE_UI_PACKAGES.has(packageName) || packageName.startsWith("@angular/"); } async function isFile(filePath: string): Promise { try { return (await stat(filePath)).isFile(); } catch { return false; } } async function walkFiles(root: string, current = root): Promise { const entries = await readdir(current, { withFileTypes: true }); const files: string[] = []; for (const entry of entries) { if (entry.isDirectory() && IGNORED_DIRECTORIES.has(entry.name)) { continue; } const absolutePath = path.join(current, entry.name); const relativePath = normalizePath(path.relative(root, absolutePath)); if (entry.isDirectory()) { if (relativePath === "tests/fixtures" || relativePath.startsWith("tests/fixtures/")) { continue; } files.push(...(await walkFiles(root, absolutePath))); continue; } if (entry.isFile()) { files.push(absolutePath); } } return files; } function collectImportSpecifiers(sourceText: string): string[] { const specifiers = new Set(); const staticImport = /\b(?:import|export)\s+(?:type\s+)?(?:[^"'\n;]*?\s+from\s+)?["']([^"'\n]+)["']/gu; const dynamicImport = /\b(?:import|require)\s*\(\s*["']([^"'\n]+)["']\s*\)/gu; for (const pattern of [staticImport, dynamicImport]) { for (const match of sourceText.matchAll(pattern)) { const specifier = match[1]; if (specifier) { specifiers.add(specifier); } } } return [...specifiers]; } async function resolveSourceImport( fromFile: string, specifier: string, ): Promise { if (!specifier.startsWith(".")) { return undefined; } const candidate = path.resolve(path.dirname(fromFile), specifier); const extension = path.extname(candidate); const withoutExtension = extension ? candidate.slice(0, -extension.length) : candidate; const candidates = [candidate]; if ([".js", ".jsx", ".mjs", ".cjs"].includes(extension)) { candidates.push( `${withoutExtension}.ts`, `${withoutExtension}.tsx`, `${withoutExtension}.mts`, `${withoutExtension}.cts`, ); } else if (!extension) { for (const codeExtension of CODE_EXTENSIONS) { candidates.push( `${candidate}${codeExtension}`, path.join(candidate, `index${codeExtension}`), ); } } for (const possibleFile of candidates) { if (await isFile(possibleFile)) { return possibleFile; } } return candidate; } function findCycles(graph: Map>): string[][] { const visited = new Set(); const active = new Set(); const stack: string[] = []; const cycles: string[][] = []; const seenCycles = new Set(); function visit(node: string): void { if (active.has(node)) { const start = stack.indexOf(node); const cycle = [...stack.slice(start), node]; const key = [...new Set(cycle)].sort().join("|"); if (!seenCycles.has(key)) { seenCycles.add(key); cycles.push(cycle); } return; } if (visited.has(node)) { return; } visited.add(node); active.add(node); stack.push(node); for (const target of graph.get(node) ?? []) { if (graph.has(target)) { visit(target); } } stack.pop(); active.delete(node); } for (const node of graph.keys()) { visit(node); } return cycles; } function exportedSubpath( manifest: PackageManifest, specifier: string, packageName: string, ): boolean { const suffix = specifier.slice(packageName.length); if (!suffix) { return true; } if (!manifest.exports || typeof manifest.exports !== "object") { return false; } const requested = `.${suffix}`; return Object.hasOwn(manifest.exports, requested); } export async function checkBoundaries(rootDirectory: string): Promise { const root = path.resolve(rootDirectory); const allFiles = await walkFiles(root); const manifestFiles = allFiles.filter((file) => path.basename(file) === "package.json"); const codeFiles = allFiles.filter((file) => CODE_EXTENSIONS.has(path.extname(file))); const workspacePackages = new Map(); const packageByDirectory = new Map(); const violations: BoundaryViolation[] = []; for (const manifestFile of manifestFiles) { const manifest = JSON.parse(await readFile(manifestFile, "utf8")) as PackageManifest; if (!manifest.name || manifestFile === path.join(root, "package.json")) { continue; } const directory = path.dirname(manifestFile); const relativeDirectory = normalizePath(path.relative(root, directory)); const location = describeRepositoryPath(relativeDirectory); if (manifest.ekklesia?.layer) { location.role = manifest.ekklesia.layer; } const workspacePackage = { directory, location, manifest, name: manifest.name }; workspacePackages.set(manifest.name, workspacePackage); packageByDirectory.set(directory, workspacePackage); } const packageGraph = new Map>(); for (const workspacePackage of workspacePackages.values()) { packageGraph.set(workspacePackage.name, new Set()); for (const dependencyField of DEPENDENCY_FIELDS) { for (const dependencyName of Object.keys(workspacePackage.manifest[dependencyField] ?? {})) { if (workspacePackage.location.layer === "module" && isModuleUiPackage(dependencyName)) { violations.push({ from: workspacePackage.location.relativePath, message: `Module package depends on UI framework ${dependencyName}.`, rule: "modules-do-not-own-ui", }); } const targetPackage = workspacePackages.get(dependencyName); if (!targetPackage) { continue; } packageGraph.get(workspacePackage.name)?.add(targetPackage.name); const rule = boundaryRule(workspacePackage.location, targetPackage.location); if (rule) { violations.push({ from: workspacePackage.location.relativePath, message: `${workspacePackage.name} may not depend on ${targetPackage.name}.`, rule, to: targetPackage.location.relativePath, }); } } } } for (const cycle of findCycles(packageGraph)) { violations.push({ from: cycle[0] ?? "unknown-package", message: `Workspace package cycle: ${cycle.join(" -> ")}`, rule: "no-circular-package-dependencies", }); } for (const repositoryFile of allFiles) { const relativeFile = normalizePath(path.relative(root, repositoryFile)); const location = describeRepositoryPath(relativeFile); if (location.layer === "module" && MODULE_UI_EXTENSIONS.has(path.extname(repositoryFile))) { violations.push({ from: relativeFile, message: "UI source files belong in a surface, not a module.", rule: "modules-do-not-own-ui", }); } } const sourceGraph = new Map>(); for (const codeFile of codeFiles) { const relativeFile = normalizePath(path.relative(root, codeFile)); const from = describeRepositoryPath(relativeFile); sourceGraph.set(relativeFile, new Set()); const sourceText = await readFile(codeFile, "utf8"); for (const specifier of collectImportSpecifiers(sourceText)) { const dependencyName = packageNameFromSpecifier(specifier); if (from.layer === "module" && isModuleUiPackage(dependencyName)) { violations.push({ from: relativeFile, message: `Module source imports UI framework ${dependencyName}.`, rule: "modules-do-not-own-ui", to: specifier, }); } let targetFile: string | undefined; let to: Location | undefined; if (specifier.startsWith(".")) { targetFile = await resolveSourceImport(codeFile, specifier); if (targetFile) { to = describeRepositoryPath(normalizePath(path.relative(root, targetFile))); if (await isFile(targetFile)) { sourceGraph.get(relativeFile)?.add(normalizePath(path.relative(root, targetFile))); } } } else { const targetPackage = workspacePackages.get(dependencyName); if (targetPackage) { to = targetPackage.location; if (!exportedSubpath(targetPackage.manifest, specifier, dependencyName)) { violations.push({ from: relativeFile, message: `${specifier} is not a public package export.`, rule: "workspace-imports-use-public-exports", to: targetPackage.location.relativePath, }); } } } if (to) { const rule = boundaryRule(from, to); if (rule) { violations.push({ from: relativeFile, message: `${from.layer} code may not import ${to.layer} code directly.`, rule, to: to.relativePath, }); } } } } for (const cycle of findCycles(sourceGraph)) { violations.push({ from: cycle[0] ?? "unknown-source", message: `Source dependency cycle: ${cycle.join(" -> ")}`, rule: "no-circular-source-dependencies", }); } return { filesChecked: codeFiles.length, packagesChecked: packageByDirectory.size, violations, }; } async function main(): Promise { const root = process.argv[2] ? path.resolve(process.argv[2]) : process.cwd(); const result = await checkBoundaries(root); if (result.violations.length > 0) { console.error(`Boundary validation failed with ${result.violations.length} violation(s):`); for (const violation of result.violations) { const target = violation.to ? ` -> ${violation.to}` : ""; console.error(`- [${violation.rule}] ${violation.from}${target}: ${violation.message}`); } process.exitCode = 1; return; } console.log( `Boundary validation passed (${result.packagesChecked} packages, ${result.filesChecked} source files).`, ); } const entryPoint = process.argv[1]; if (entryPoint && import.meta.url === pathToFileURL(path.resolve(entryPoint)).href) { await main(); }