@ekklesia/module-sdk imports drizzle-orm at run time but declared it only as a peer and a development dependency. A workspace package is linked into its consumers as a directory, so its imports resolve from its own node_modules, where a consumer never satisfies a peer; a production install omits development dependencies. In the production image this made every module with a schema fail: the API exited at startup and Doctor reported NOT READY. - Move drizzle-orm to module-sdk's dependencies, keeping the peer. - Move react to @ekklesia/ui's dependencies, keeping the peer. The new guard flags it the same way; it is bundled at build time today, so it was not a live failure. Both lockfile entries keep the identical resolution, so nothing new is installed and the shell bundles still contain one React. - Add the boundary rule runtime-imports-are-production-dependencies: code a package ships (src/, bin/, excluding tests and .d.ts) must declare every runtime import in dependencies or optionalDependencies. Type-only imports are exempt; .tsx/.jsx implies react (automatic JSX runtime). Fixtures cover peer-only, dev-only, JSX, undeclared, and the accepted cases. Co-Authored-By: Claude Opus 5.5 <[email protected]>
1155 lines
39 KiB
TypeScript
1155 lines
39 KiB
TypeScript
import { readdir, readFile, stat } from "node:fs/promises";
|
|
import { builtinModules } from "node:module";
|
|
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. `.css` is handled
|
|
// separately below rather than listed here: since ADR 0040 a module may author a plain stylesheet,
|
|
// but only inside the browser-artifact source roots the canonical builder actually compiles. Every
|
|
// other stylesheet dialect stays rejected outright — adding one is a toolchain decision needing its
|
|
// own ADR, not a detail of a build pass.
|
|
const MODULE_UI_EXTENSIONS = new Set([
|
|
".astro",
|
|
".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",
|
|
]);
|
|
|
|
/**
|
|
* The module-package-relative directories whose contents `@ekklesia/module-ui-build` compiles into a
|
|
* declared browser artifact (ADR 0023, ADR 0033, ADR 0040).
|
|
*
|
|
* A stylesheet is only meaningful inside one of these: anywhere else in a module it would be a file
|
|
* no build reads, no manifest declares, and no host ever loads — the undeclared-sidecar failure this
|
|
* repository has rejected since ADR 0023, just relocated. Restricting the location is also what
|
|
* keeps the relaxation honest: a module gains stylesheets for its browser artifacts, not the ability
|
|
* to scatter presentation files through its backend.
|
|
*/
|
|
const MODULE_BROWSER_SOURCE_ROOTS = ["src/ui/", "src/display/", "src/contributions/"];
|
|
|
|
/** Whether a repository-relative path inside a module package sits in one of those roots. Module
|
|
* packages live at `modules/<name>/…` and `templates/module/…`, both two segments deep. */
|
|
function isModuleBrowserSource(relativePath: string): boolean {
|
|
const withinPackage = relativePath.split("/").slice(2).join("/");
|
|
return MODULE_BROWSER_SOURCE_ROOTS.some((sourceRoot) => withinPackage.startsWith(sourceRoot));
|
|
}
|
|
|
|
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<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];
|
|
}
|
|
|
|
/**
|
|
* Whether a file is code a workspace package ships and executes: anything under its `src/` or
|
|
* `bin/`, except tests and declaration files. Build scripts, configuration files, and tests run with
|
|
* development dependencies installed, so they are outside this rule.
|
|
*/
|
|
function isRuntimeSourceFile(packageRelativePath: string): boolean {
|
|
const normalized = normalizePath(packageRelativePath);
|
|
if (!normalized.startsWith("src/") && !normalized.startsWith("bin/")) {
|
|
return false;
|
|
}
|
|
const segments = normalized.split("/");
|
|
if (segments.some((segment) => ["__tests__", "test", "tests"].includes(segment))) {
|
|
return false;
|
|
}
|
|
const fileName = segments.at(-1) ?? "";
|
|
return !/\.(?:test|spec)\.[cm]?[jt]sx?$/u.test(fileName) && !/\.d\.[cm]?ts$/u.test(fileName);
|
|
}
|
|
|
|
/** `import { type A, type B } from "x"` has no runtime binding: without `verbatimModuleSyntax`, the
|
|
* compiler erases it exactly like `import type`. A default or namespace binding, or any specifier
|
|
* without `type`, is a runtime import. */
|
|
function importClauseIsTypeOnly(clause: string): boolean {
|
|
const trimmed = clause.trim();
|
|
if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) {
|
|
return false;
|
|
}
|
|
const specifiers = trimmed
|
|
.slice(1, -1)
|
|
.split(",")
|
|
.map((specifier) => specifier.trim())
|
|
.filter((specifier) => specifier.length > 0);
|
|
return specifiers.length > 0 && specifiers.every((specifier) => /^type\s/u.test(specifier));
|
|
}
|
|
|
|
/**
|
|
* Bare specifiers a source file needs at run time: every static, side-effect, and dynamic import
|
|
* except type-only ones. A `.tsx`/`.jsx` file also needs `react`, because the platform compiles JSX
|
|
* with the automatic runtime (`"jsx": "react-jsx"`), which imports `react/jsx-runtime` without that
|
|
* import appearing in the source.
|
|
*/
|
|
function collectRuntimeImportSpecifiers(sourceText: string, extension: string): string[] {
|
|
const specifiers = new Set<string>();
|
|
const staticImport = /\b(?:import|export)\s+(type\s+)?([^;]*?)\bfrom\s*["']([^"'\n]+)["']/gu;
|
|
for (const match of sourceText.matchAll(staticImport)) {
|
|
const [, typeKeyword, clause = "", specifier] = match;
|
|
if (specifier && !typeKeyword && !importClauseIsTypeOnly(clause)) {
|
|
specifiers.add(specifier);
|
|
}
|
|
}
|
|
const sideEffectImport = /\bimport\s*["']([^"'\n]+)["']/gu;
|
|
const dynamicImport = /\b(?:import|require)\s*\(\s*["']([^"'\n]+)["']\s*\)/gu;
|
|
for (const pattern of [sideEffectImport, dynamicImport]) {
|
|
for (const match of sourceText.matchAll(pattern)) {
|
|
if (match[1]) {
|
|
specifiers.add(match[1]);
|
|
}
|
|
}
|
|
}
|
|
if (extension === ".tsx" || extension === ".jsx") {
|
|
specifiers.add("react/jsx-runtime");
|
|
}
|
|
return [...specifiers];
|
|
}
|
|
|
|
const NODE_BUILTIN_MODULES = new Set(builtinModules);
|
|
|
|
function isBareSpecifier(specifier: string): boolean {
|
|
return !(
|
|
specifier.startsWith(".") ||
|
|
specifier.startsWith("/") ||
|
|
specifier.startsWith("#") ||
|
|
specifier.startsWith("node:") ||
|
|
/^[a-z][a-z0-9+.-]*:/u.test(specifier)
|
|
);
|
|
}
|
|
|
|
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") {
|
|
continue;
|
|
}
|
|
const extension = path.extname(repositoryFile).toLowerCase();
|
|
if (extension === ".css") {
|
|
if (!isModuleBrowserSource(location.relativePath)) {
|
|
violations.push({
|
|
from: relativeFile,
|
|
message: `A module stylesheet must live inside a compiled browser source root (${MODULE_BROWSER_SOURCE_ROOTS.join(", ")}).`,
|
|
rule: "modules-do-not-own-ui",
|
|
});
|
|
}
|
|
continue;
|
|
}
|
|
if (MODULE_UI_EXTENSIONS.has(extension)) {
|
|
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");
|
|
|
|
// A workspace package is linked into its consumers as a directory, so Node resolves the
|
|
// package's own imports from the package's own `node_modules`. A peer dependency is never
|
|
// satisfied there by the consumer, and a production install (`pnpm install --prod`, as the
|
|
// production image runs) omits development dependencies. Code a package ships must therefore
|
|
// declare every runtime import as a production dependency. A peer declaration may stay beside it.
|
|
const runtimePackage = findContainingPackage(codeFile, packagesByDepth);
|
|
if (runtimePackage && isRuntimeSourceFile(path.relative(runtimePackage.directory, codeFile))) {
|
|
const reported = new Set<string>();
|
|
for (const specifier of collectRuntimeImportSpecifiers(
|
|
sourceText,
|
|
path.extname(codeFile).toLowerCase(),
|
|
)) {
|
|
if (!isBareSpecifier(specifier)) {
|
|
continue;
|
|
}
|
|
const dependencyName = packageNameFromSpecifier(specifier);
|
|
const manifest = runtimePackage.manifest;
|
|
if (
|
|
reported.has(dependencyName) ||
|
|
dependencyName === runtimePackage.name ||
|
|
NODE_BUILTIN_MODULES.has(dependencyName) ||
|
|
manifest.dependencies?.[dependencyName] !== undefined ||
|
|
manifest.optionalDependencies?.[dependencyName] !== undefined
|
|
) {
|
|
continue;
|
|
}
|
|
reported.add(dependencyName);
|
|
const declaredAs =
|
|
manifest.peerDependencies?.[dependencyName] !== undefined
|
|
? "only as a peer dependency"
|
|
: manifest.devDependencies?.[dependencyName] !== undefined
|
|
? "only as a development dependency"
|
|
: "not at all";
|
|
violations.push({
|
|
from: relativeFile,
|
|
message: `${runtimePackage.name} imports ${dependencyName} at run time but declares it ${declaredAs}; a runtime import must be in dependencies.`,
|
|
rule: "runtime-imports-are-production-dependencies",
|
|
to: dependencyName,
|
|
});
|
|
}
|
|
}
|
|
|
|
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();
|
|
}
|