import { readdirSync, readFileSync } from "node:fs"; import { join, relative } from "node:path"; const ROOT = new URL("..", import.meta.url).pathname.replace(/\/$/, ""); const FORBIDDEN = { "packages/domain/src": [ "@initiative/application", "apps/", "react", "react-dom", "vite", ], "packages/application/src": ["apps/", "react", "react-dom", "vite"], }; /** Directories to skip when collecting files */ const SKIP_DIRS = new Set(["__tests__", "node_modules"]); /** @param {string} dir */ function collectTsFiles(dir) { /** @type {string[]} */ const results = []; for (const entry of readdirSync(dir, { withFileTypes: true })) { const full = join(dir, entry.name); if (entry.isDirectory()) { if (!SKIP_DIRS.has(entry.name)) { results.push(...collectTsFiles(full)); } } else if (entry.name.endsWith(".ts") || entry.name.endsWith(".tsx")) { results.push(full); } } return results; } /** * Check if an import path matches a forbidden module. * "vite" should match "vite" and "vite/foo" but not "vitest". * @param {string} importPath * @param {string} forbidden */ function matchesForbidden(importPath, forbidden) { if (forbidden.endsWith("/")) { return importPath.startsWith(forbidden); } return importPath === forbidden || importPath.startsWith(`${forbidden}/`); } const IMPORT_RE = /(?:import|from)\s+["']([^"']+)["']|require\s*\(\s*["']([^"']+)["']\s*\)/; /** * Check a single file for forbidden imports. * @param {string} file * @param {string[]} forbidden * @returns {{ file: string, line: number, importPath: string, forbidden: string }[]} */ function checkFile(file, forbidden) { /** @type {{ file: string, line: number, importPath: string, forbidden: string }[]} */ const violations = []; const content = readFileSync(file, "utf-8"); const lines = content.split("\n"); for (let i = 0; i < lines.length; i++) { const match = lines[i].match(IMPORT_RE); if (!match) continue; const importPath = match[1] || match[2]; for (const f of forbidden) { if (matchesForbidden(importPath, f)) { violations.push({ file: relative(ROOT, file), line: i + 1, importPath, forbidden: f, }); } } } return violations; } /** * Check all files in a layer directory for forbidden imports. * @param {string} srcDir * @param {string[]} forbidden * @returns {{ file: string, line: number, importPath: string, forbidden: string }[]} */ function checkLayer(srcDir, forbidden) { const absDir = join(ROOT, srcDir); let files; try { files = collectTsFiles(absDir); } catch { return []; } return files.flatMap((file) => checkFile(file, forbidden)); } /** @returns {{ file: string, line: number, importPath: string, forbidden: string }[]} */ export function checkLayerBoundaries() { return Object.entries(FORBIDDEN).flatMap(([srcDir, forbidden]) => checkLayer(srcDir, forbidden), ); } // Run as CLI if invoked directly if ( process.argv[1] && (process.argv[1].endsWith("check-layer-boundaries.mjs") || process.argv[1] === new URL(import.meta.url).pathname) ) { const violations = checkLayerBoundaries(); if (violations.length > 0) { console.error("Layer boundary violations found:"); for (const v of violations) { console.error( ` ${v.file}:${v.line} — imports "${v.importPath}" (forbidden: ${v.forbidden})`, ); } process.exit(1); } else { console.log("No layer boundary violations found."); } }