128 lines
3.4 KiB
JavaScript
128 lines
3.4 KiB
JavaScript
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.");
|
|
}
|
|
}
|