This commit is contained in:
@@ -0,0 +1,498 @@
|
||||
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<string, string>;
|
||||
devDependencies?: Record<string, string>;
|
||||
ekklesia?: { layer?: string };
|
||||
exports?: unknown;
|
||||
name?: string;
|
||||
optionalDependencies?: Record<string, string>;
|
||||
peerDependencies?: Record<string, string>;
|
||||
}
|
||||
|
||||
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<boolean> {
|
||||
try {
|
||||
return (await stat(filePath)).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function walkFiles(root: string, current = root): Promise<string[]> {
|
||||
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<string>();
|
||||
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<string | undefined> {
|
||||
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, Set<string>>): string[][] {
|
||||
const visited = new Set<string>();
|
||||
const active = new Set<string>();
|
||||
const stack: string[] = [];
|
||||
const cycles: string[][] = [];
|
||||
const seenCycles = new Set<string>();
|
||||
|
||||
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<BoundaryResult> {
|
||||
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<string, WorkspacePackage>();
|
||||
const packageByDirectory = new Map<string, WorkspacePackage>();
|
||||
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<string, Set<string>>();
|
||||
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<string, Set<string>>();
|
||||
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<void> {
|
||||
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();
|
||||
}
|
||||
Reference in New Issue
Block a user