Files
Ekklesia/scripts/check-boundaries.ts
T
ekklesiaandClaude Opus 5 8d651c427d
CI / verify (pull_request) Successful in 30s
CI / infrastructure-smoke (pull_request) Successful in 23s
CI / migration-smoke (pull_request) Successful in 45s
feat: define the module UI authoring model and host contract
ADR 0021 established that a module may own UI and added a statically
validated, never-executed `entrypoints.ui` path, but deliberately left
five questions open: what the artifact exports, what a host provides,
how one artifact runs under both Ekklesia and a standalone host, what
the browser-side trust model is, and how module UI avoids being welded
to a Dashboard that does not exist. This answers them.

React and TypeScript are the supported module UI authoring model. The
repository already leaned this way: `packages/typescript-config` ships a
`browser.json` preset and a `react-native.json` preset with
`"jsx": "react-jsx"`, and ADR 0002 names browser and React Native
configurations. One framework is supported; every other stays rejected.

React is deliberately not part of the contract. A module's compiled
artifact default-exports `defineModuleUi({ id, version, mount })`, and
`mount(container, context)` takes a DOM element and returns an unmount
function. `id` and `version` are build-artifact identity markers a host
verifies against the manifest, mirroring `defineModuleRuntime` exactly.
A host needs an element and a context, never a matching rendering
library, so a module's framework, a host's framework, and the platform
boundary between them stay independent.

The contract lives in a new `@ekklesia/module-ui-sdk` rather than in
`@ekklesia/module-sdk`, for a concrete reason: that package re-exports
all of `@ekklesia/contracts`, so routing module UI through it would drag
Zod, Drizzle, and semver into every browser bundle. The new package has
no runtime dependencies, and its one dependency on contracts is
type-only — the emitted `host-context.js` is `export {};`, and a test
pins that so it cannot regress.

The host context is four members: `moduleId`, `api`, `principal`, and
`permissions`. Navigation, theme, module configuration, organization
context, and diagnostics are deliberately absent; each needs a design
that does not exist. Module UI reaches its backend through
`api.request(path)` with a path relative to its own API root and never
constructs `/api/v1/modules/<id>/...`, which is Ekklesia's private
deployment detail — a module that builds that URL is silently unhostable
anywhere else. One fixture artifact is mounted under two hosts with
different origins and URL shapes to prove it.

Client-side permission hints are presentation only, and both the docs
and a test say so plainly: a denied hint does not stop the module from
calling its API, because server-side `requirePermission` is the only
authority. Module UI is trusted code in the host's own browser realm —
no iframe, no sandbox — which matches the backend model rather than
overstating it, since a module's server half already holds the API's
database authority.

The boundary guard is narrowed precisely, not removed: `.tsx`, `react`,
and `react-dom` are un-denied and modules may depend on the new
`module-ui-sdk` role. `.jsx`, `.css`, every other framework,
`react-router-dom`, `react-native`, and `@vitejs/plugin-react` stay
denied, layer rules are untouched, and an unsupported framework aliased
behind an allowed name (`"react": "npm:vue@3"`) is resolved and rejected
by its real target. Fixtures prove each case, including that module UI
source still cannot import Core.

Core, apps/api, and the manifest contract are untouched. Discovery still
validates the `entrypoints.ui` path and never evaluates it; hosts
perform their own dynamic import and validate the result with
`parseModuleUiExport`. No Dashboard shell, UI runtime, navigation,
grouping, standalone host, build or styling pipeline, installer,
hot-loading, or feature module is added.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-21 20:52:47 -05:00

935 lines
29 KiB
TypeScript

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<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;
}
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-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";
}
if (
to.layer === "shared" &&
!["contracts", "module-sdk", "module-ui-sdk", "testing", "tooling"].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";
}
}
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-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" && 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";
}
if (from.role === "builder-sdk" && !["contracts", "surface-sdk"].includes(to.role ?? "")) {
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 {
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<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 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<string[] | undefined> {
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<string>();
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<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 {
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<string, unknown>;
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<string, unknown>;
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<string, WorkspacePackage>,
packageByDirectory: Map<string, WorkspacePackage>,
): { 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<BoundaryResult> {
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<string, WorkspacePackage>();
const packageByDirectory = new Map<string, WorkspacePackage>();
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<string, Set<string>>();
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<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)));
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<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();
}