import { readdir, readFile, stat } from "node:fs/promises"; import path from "node:path"; import { pathToFileURL } from "node:url"; import { parse } from "yaml"; 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; } interface WorkspaceConfiguration { packages?: 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"]); // Presentation formats a module may NOT contain. `.tsx` is deliberately absent: React is the // supported module UI authoring model (ADR 0022), so a module's own `.tsx` is legitimate source. // `.jsx` stays rejected because this repository is strict-TypeScript throughout, and `.css` stays // rejected because module styling and the module UI build pipeline are not designed yet — allowing // a stylesheet would imply a bundling contract that does not exist. const MODULE_UI_EXTENSIONS = new Set([ ".astro", ".css", ".htm", ".html", ".jsx", ".less", ".marko", ".mdx", ".pcss", ".postcss", ".sass", ".scss", ".styl", ".stylus", ".svelte", ".svg", ".vue", ]); // UI packages a module may NOT depend on. `react` and `react-dom` are deliberately absent: they are // the one supported module UI authoring framework (ADR 0022). Everything else stays denied, so // choosing React does not quietly open every other frontend technology. Three neighbours are worth // naming: `react-router-dom` stays denied because routing belongs to the host, `react-native` stays // denied because this pass supports React on the DOM only, and `@vitejs/plugin-react` stays denied // because the module UI build pipeline is not designed yet. const MODULE_UI_PACKAGES = new Set([ "@angular/core", "@builder.io/qwik", "@emotion/react", "@emotion/styled", "@lit/react", "@mui/material", "@preact/signals", "@storybook/react", "@tamagui/core", "alpinejs", "astro", "bootstrap", "bulma", "expo", "gatsby", "htmx.org", "lit", "marko", "nativewind", "next", "nuxt", "preact", "qwik", "react-native", "react-router-dom", "remix", "solid-js", "solid-start", "styled-components", "svelte", "tamagui", "tailwindcss", "unocss", "vue", "vue-router", "vuetify", ]); const MODULE_UI_PACKAGE_PREFIXES = [ "@angular/", "@chakra-ui/", "@emotion/", "@mui/", "@radix-ui/", "@remix-run/", "@solidjs/", "@storybook/", "@sveltejs/", "@tamagui/", "@tanstack/react-", "@unocss/", "@vitejs/plugin-react", "@vue/", ]; const IGNORED_DIRECTORIES = new Set([ ".git", ".next", ".turbo", "coverage", "dist", "node_modules", ]); const DEPENDENCY_FIELDS = [ "dependencies", "devDependencies", "optionalDependencies", "peerDependencies", ] as const; const SHARED_PACKAGE_ROLES = new Set([ "api-client", "auth-client", "builder-sdk", "contracts", "design-tokens", "module-sdk", "module-ui-host", "module-ui-sdk", "shared", "surface-sdk", "testing", "tooling", "ui", ]); 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"; } // The approved shared roles a module may reach, and nothing else. `design-tokens` and `ui` are // named individually rather than by opening "browser-safe shared packages" generally: a module // may consume Ekklesia's global semantic tokens and foundational elements (ADR 0039) precisely // so two modules' screens look like one product, but it still may not reach `api-client`, // `auth-client`, `surface-sdk`, `module-ui-host`, or any other shared role. Both approved // packages are browser-safe by construction — neither has a backend dependency — so this adds // no path from module UI to Node's globals. if ( to.layer === "shared" && ![ "contracts", "design-tokens", "module-sdk", "module-ui-sdk", "testing", "tooling", "ui", ].includes(to.role ?? "") ) { return "modules-use-public-platform-contracts"; } } 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"; } // A surface is browser code, so the roles it may reach are the browser-safe ones plus dev-time // tooling. `contracts` is deliberately absent even though it is a "public contract" package: // its barrel pulls Drizzle's types, and with them Node's global declarations, into any program // that imports it — measured in ADR 0024, which removed exactly that import from // `@ekklesia/module-ui-sdk` to close the browser boundary. Allowing it here would reopen the // same hole one layer up. `module-sdk` re-exports that barrel and is excluded for the same // reason. A surface talks to the server over HTTP and validates what arrives; it does not share // the server's type graph. if ( to.layer === "shared" && ![ "api-client", "auth-client", "design-tokens", "module-ui-host", "module-ui-sdk", "surface-sdk", "testing", "tooling", "ui", ].includes(to.role ?? "") ) { return "surfaces-use-public-contracts"; } } if (from.layer === "builder" && ["app", "core", "module", "surface"].includes(to.layer)) { return "builder-composes-contracts"; } if (from.layer === "app" && ["app", "builder", "module"].includes(to.layer)) { return "apps-are-composition-roots"; } 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" && ["builder-sdk", "module-sdk", "module-ui-host", "module-ui-sdk", "surface-sdk"].includes( from.role ?? "", ) && ["app", "builder", "core", "module", "surface"].includes(to.layer) ) { return "sdks-do-not-depend-on-implementations"; } if ( from.layer === "shared" && ["design-tokens", "ui"].includes(from.role ?? "") && ["app", "builder", "core", "module", "surface"].includes(to.layer) ) { return "design-packages-depend-downward-only"; } if (from.layer === "shared" && to.layer === "shared") { if (from.role === "contracts" && to.role !== "contracts") { return "contracts-do-not-depend-upward"; } if ( ["module-sdk", "module-ui-sdk", "surface-sdk"].includes(from.role ?? "") && to.role !== "contracts" ) { return "sdks-do-not-depend-on-implementations"; } // The host loader consumes the module-author contract — that is its whole job — plus dev-time // `tooling` (it builds a real artifact in its own tests through the canonical pipeline rather // than reimplementing one). It may not reach further: never a module, an app, a surface, or the // backend contracts barrel, which is what keeps it browser-safe (ADR 0024). if (from.role === "module-ui-host" && !["module-ui-sdk", "tooling"].includes(to.role ?? "")) { return "sdks-do-not-depend-on-implementations"; } if (from.role === "builder-sdk" && !["contracts", "surface-sdk"].includes(to.role ?? "")) { return "sdks-do-not-depend-on-implementations"; } // The design contract is the bottom of the presentation stack: it depends on nothing, exactly // like `module-ui-sdk`, so importing it can never drag another package's type graph into a // browser program. The shared elements sit one layer above it and may reach it and nothing // else — a shared element package that could reach an API client, an auth client, or the // backend contracts barrel would put all three inside every module artifact that renders a // button (ADR 0039). if (from.role === "design-tokens" && to.role !== "tooling") { return "design-packages-depend-downward-only"; } if (from.role === "ui" && !["design-tokens", "tooling"].includes(to.role ?? "")) { return "design-packages-depend-downward-only"; } } 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 { const normalizedName = packageName.toLowerCase(); return ( MODULE_UI_PACKAGES.has(normalizedName) || MODULE_UI_PACKAGE_PREFIXES.some((prefix) => normalizedName.startsWith(prefix)) ); } function npmAliasTarget(range: string): string | undefined { if (!range.startsWith("npm:")) { return undefined; } const alias = range.slice(4); if (alias.startsWith("@")) { const versionMarker = alias.lastIndexOf("@"); return versionMarker > 0 ? alias.slice(0, versionMarker) : alias; } return alias.split("@")[0]; } /** * The forbidden UI framework a module dependency actually resolves to, or `undefined` when it * resolves to none. Returns the *alias target* when the alias is what matched, so a dependency * declared as `"react": "npm:vue@3"` is reported as Vue rather than as the allowed name it hides * behind. Now that React is permitted (ADR 0022), that distinction is what keeps an allowed name * from becoming a way to smuggle a denied framework past the guard. */ function moduleUiDependencyTarget(packageName: string, range: string): string | undefined { if (isModuleUiPackage(packageName)) { return packageName; } const aliasTarget = npmAliasTarget(range); return aliasTarget && isModuleUiPackage(aliasTarget) ? aliasTarget : undefined; } 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 workspacePatternMatches(relativeDirectory: string, pattern: string): boolean { let expression = "^"; for (let index = 0; index < pattern.length; index += 1) { const character = pattern[index]; if (character === "*" && pattern[index + 1] === "*") { expression += ".*"; index += 1; } else if (character === "*") { expression += "[^/]*"; } else if (character && /[\\^$.*+?()[\]{}|]/u.test(character)) { expression += `\\${character}`; } else { expression += character; } } expression += "$"; return new RegExp(expression, "u").test(relativeDirectory); } function workspaceDirectoryIsIncluded(relativeDirectory: string, patterns: string[]): boolean { const included = patterns .filter((pattern) => !pattern.startsWith("!")) .some((pattern) => workspacePatternMatches(relativeDirectory, pattern)); const excluded = patterns .filter((pattern) => pattern.startsWith("!")) .some((pattern) => workspacePatternMatches(relativeDirectory, pattern.slice(1))); return included && !excluded; } function workspacePatternIsSupported(pattern: string): boolean { const normalizedPattern = pattern.startsWith("!") ? pattern.slice(1) : pattern; return !/[?[\]{}]/u.test(normalizedPattern); } async function readWorkspacePatterns(root: string): Promise { const workspaceFile = path.join(root, "pnpm-workspace.yaml"); if (!(await isFile(workspaceFile))) { return undefined; } const workspace = parse(await readFile(workspaceFile, "utf8")) as WorkspaceConfiguration; return workspace.packages; } function collectImportSpecifiers(sourceText: string): string[] { const specifiers = new Set(); const staticImport = /\b(?:import|export)\s+(?:type\s+)?[^;]*?\bfrom\s*["']([^"'\n]+)["']/gu; const sideEffectImport = /\bimport\s*["']([^"'\n]+)["']/gu; const dynamicImport = /\b(?:import|require)\s*\(\s*["']([^"'\n]+)["']\s*\)/gu; for (const pattern of [staticImport, sideEffectImport, 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 { function hasAvailableTarget(target: unknown): boolean { if (typeof target === "string") { return true; } if (Array.isArray(target)) { return target.some(hasAvailableTarget); } if (target && typeof target === "object") { return Object.values(target).some(hasAvailableTarget); } return false; } const suffix = specifier.slice(packageName.length); const packageExports = manifest.exports; if (packageExports === undefined || packageExports === null) { return false; } if (!suffix) { if (typeof packageExports === "string") { return true; } if (Array.isArray(packageExports)) { return hasAvailableTarget(packageExports); } if (typeof packageExports !== "object") { return false; } const exportMap = packageExports as Record; const exportKeys = Object.keys(exportMap); if (Object.hasOwn(exportMap, ".")) { return hasAvailableTarget(exportMap["."]); } return exportKeys.some((key) => !key.startsWith(".") && hasAvailableTarget(exportMap[key])); } if (typeof packageExports !== "object" || Array.isArray(packageExports)) { return false; } const exportMap = packageExports as Record; const requested = `.${suffix}`; if (Object.hasOwn(exportMap, requested)) { return hasAvailableTarget(exportMap[requested]); } function comparePatternKeys(left: string, right: string): number { const leftBaseLength = left.indexOf("*") + 1; const rightBaseLength = right.indexOf("*") + 1; if (leftBaseLength > rightBaseLength) { return -1; } if (rightBaseLength > leftBaseLength) { return 1; } if (left.length > right.length) { return -1; } if (right.length > left.length) { return 1; } return 0; } const matchingPatterns = Object.keys(exportMap) .filter((exportKey) => { const wildcard = exportKey.indexOf("*"); if (wildcard === -1) { return false; } return ( requested.startsWith(exportKey.slice(0, wildcard)) && requested.endsWith(exportKey.slice(wildcard + 1)) ); }) .sort(comparePatternKeys); const matchingPattern = matchingPatterns[0]; return matchingPattern ? hasAvailableTarget(exportMap[matchingPattern]) : false; } function findContainingPackage( filePath: string, packagesByDepth: WorkspacePackage[], ): WorkspacePackage | undefined { return packagesByDepth.find( (workspacePackage) => filePath === workspacePackage.directory || filePath.startsWith(`${workspacePackage.directory}${path.sep}`), ); } function dependencyRange(manifest: PackageManifest, dependencyName: string): string | undefined { for (const dependencyField of DEPENDENCY_FIELDS) { const range = manifest[dependencyField]?.[dependencyName]; if (range !== undefined) { return range; } } return undefined; } function workspaceAliasPackageName(range: string): string | undefined { if (!range.startsWith("workspace:")) { return undefined; } const target = range.slice("workspace:".length); if (target.startsWith("@")) { const versionMarker = target.lastIndexOf("@"); return versionMarker > 0 ? target.slice(0, versionMarker) : target; } const versionMarker = target.lastIndexOf("@"); return versionMarker > 0 ? target.slice(0, versionMarker) : undefined; } function resolveWorkspaceDependency( dependencyName: string, range: string, sourcePackage: WorkspacePackage, workspacePackages: Map, packageByDirectory: Map, ): { aliased: boolean; target: WorkspacePackage | undefined } { if (range.startsWith("workspace:.")) { const relativeTarget = range.slice("workspace:".length); return { aliased: true, target: packageByDirectory.get(path.resolve(sourcePackage.directory, relativeTarget)), }; } const aliasName = workspaceAliasPackageName(range); if (aliasName) { return { aliased: true, target: workspacePackages.get(aliasName) }; } const directTarget = workspacePackages.get(dependencyName); if (directTarget) { return { aliased: false, target: directTarget }; } return { aliased: false, target: undefined }; } export async function checkBoundaries(rootDirectory: string): Promise { const root = path.resolve(rootDirectory); const allFiles = await walkFiles(root); const workspacePatterns = await readWorkspacePatterns(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 pattern of workspacePatterns ?? []) { if (!workspacePatternIsSupported(pattern)) { violations.push({ from: "pnpm-workspace.yaml", message: `Workspace pattern ${pattern} uses unsupported glob syntax.`, rule: "workspace-patterns-use-supported-syntax", }); } } for (const manifestFile of manifestFiles) { const manifest = JSON.parse(await readFile(manifestFile, "utf8")) as PackageManifest; if (!manifest.name) { continue; } const directory = path.dirname(manifestFile); const relativeDirectory = normalizePath(path.relative(root, directory)) || "."; if ( directory !== root && workspacePatterns && !workspaceDirectoryIsIncluded(relativeDirectory, workspacePatterns) ) { continue; } const location = describeRepositoryPath(relativeDirectory); if (manifest.ekklesia?.layer) { location.role = manifest.ekklesia.layer; } if ( location.layer === "shared" && (!manifest.ekklesia?.layer || !SHARED_PACKAGE_ROLES.has(manifest.ekklesia.layer)) ) { violations.push({ from: relativeDirectory, message: `Shared package ${manifest.name} must declare a recognized ekklesia.layer role.`, rule: "shared-packages-declare-known-roles", }); } const workspacePackage = { directory, location, manifest, name: manifest.name }; const existingPackage = workspacePackages.get(manifest.name); if (existingPackage) { violations.push({ from: relativeDirectory, message: `Workspace package name ${manifest.name} is already used by ${existingPackage.location.relativePath}.`, rule: "workspace-package-names-are-unique", to: existingPackage.location.relativePath, }); } else { 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, range] of Object.entries( workspacePackage.manifest[dependencyField] ?? {}, )) { const uiDependencyTarget = workspacePackage.location.layer === "module" ? moduleUiDependencyTarget(dependencyName, range) : undefined; if (uiDependencyTarget) { violations.push({ from: workspacePackage.location.relativePath, message: uiDependencyTarget === dependencyName ? `Module package depends on UI framework ${dependencyName}.` : `Module package depends on UI framework ${uiDependencyTarget} aliased as ${dependencyName}.`, rule: "modules-do-not-own-ui", }); } const resolvedDependency = resolveWorkspaceDependency( dependencyName, range, workspacePackage, workspacePackages, packageByDirectory, ); const targetPackage = resolvedDependency.target; if (!targetPackage) { continue; } if (resolvedDependency.aliased) { violations.push({ from: workspacePackage.location.relativePath, message: `Internal dependency alias ${dependencyName} obscures ${targetPackage.name}; use the real package name.`, rule: "workspace-dependency-aliases-are-forbidden", to: targetPackage.location.relativePath, }); } if (!range.startsWith("workspace:")) { violations.push({ from: workspacePackage.location.relativePath, message: `Internal dependency ${dependencyName} must use the workspace: protocol, not ${range}.`, rule: "internal-dependencies-use-workspace-protocol", to: targetPackage.location.relativePath, }); } 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, }); } } } } const packagesByDepth = [...packageByDirectory.values()].sort( (left, right) => right.directory.length - left.directory.length, ); 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).toLowerCase()) ) { 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))); const sourcePackage = findContainingPackage(codeFile, packagesByDepth); const targetPackage = findContainingPackage(targetFile, packagesByDepth); if ( sourcePackage && targetPackage && sourcePackage.directory !== targetPackage.directory ) { violations.push({ from: relativeFile, message: `Cross-package import ${specifier} must use ${targetPackage.name}'s public exports.`, rule: "workspace-imports-use-public-exports", to: targetPackage.location.relativePath, }); } } } } else { const sourcePackage = findContainingPackage(codeFile, packagesByDepth); const declaredRange = sourcePackage ? dependencyRange(sourcePackage.manifest, dependencyName) : undefined; const targetPackage = sourcePackage && declaredRange ? resolveWorkspaceDependency( dependencyName, declaredRange, sourcePackage, workspacePackages, packageByDirectory, ).target : workspacePackages.get(dependencyName); if (targetPackage) { to = targetPackage.location; if (sourcePackage && sourcePackage.directory !== targetPackage.directory) { if (declaredRange === undefined) { violations.push({ from: relativeFile, message: `${sourcePackage.name} imports ${dependencyName} without declaring it.`, rule: "workspace-dependencies-are-declared", to: targetPackage.location.relativePath, }); } } const publicSpecifier = dependencyName === targetPackage.name ? specifier : targetPackage.name; if (!exportedSubpath(targetPackage.manifest, publicSpecifier, targetPackage.name)) { 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(); }