diff --git a/docs/architecture/repository-boundaries.md b/docs/architecture/repository-boundaries.md index fda4a95..a8c459d 100644 --- a/docs/architecture/repository-boundaries.md +++ b/docs/architecture/repository-boundaries.md @@ -41,7 +41,7 @@ These rules map platform ownership to filesystem and package boundaries. The exe general "browser-safe shared packages are fine" relaxation: `api-client`, `auth-client`, `surface-sdk`, and `module-ui-host` remain rejected inside a module. Both approved packages are browser-safe by construction — `design-tokens` has no dependencies at all and `ui` depends only on - it — so a module UI file that imports them still cannot typecheck `process.env`, proven by a real + it and React — so a module UI file that imports them still cannot typecheck `process.env`, proven by a real compile under the exact browser project a module uses. Both are also pinned at the bottom of the presentation stack by `design-packages-depend-downward-only`: neither may reach an implementation layer, an API/auth client, or the backend contracts barrel, because a design package that could @@ -134,6 +134,14 @@ These rules map platform ownership to filesystem and package boundaries. The exe - Workspace package consumers use declared `exports`, not private subpaths. - Every internal dependency uses a `workspace:` range and every imported workspace package is declared by the consumer. +- Code a workspace package ships (its `src/` and `bin/`, excluding tests and declaration files) + declares every runtime import in `dependencies` or `optionalDependencies`. A peer declaration may + stay beside it, but never replaces it: 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, and a production install (`pnpm install --prod`, as the production image runs) omits + development dependencies. Type-only imports are exempt; a `.tsx`/`.jsx` file counts as importing + `react`, because the automatic JSX runtime imports `react/jsx-runtime` implicitly + (`runtime-imports-are-production-dependencies`). - Workspace aliases are forbidden because they obscure package ownership in the dependency graph. - Workspace package names are unique and shared packages declare a recognized architectural role. - Circular source and workspace-package dependencies are rejected. diff --git a/packages/module-sdk/package.json b/packages/module-sdk/package.json index 7f26ab0..aeab99c 100644 --- a/packages/module-sdk/package.json +++ b/packages/module-sdk/package.json @@ -19,12 +19,12 @@ "typecheck": "tsc --noEmit --project tsconfig.json" }, "dependencies": { - "@ekklesia/contracts": "workspace:*" + "@ekklesia/contracts": "workspace:*", + "drizzle-orm": "0.45.2" }, "devDependencies": { "@biomejs/biome": "2.5.3", "@types/node": "24.13.3", - "drizzle-orm": "0.45.2", "typescript": "7.0.2", "vitest": "4.1.10" }, diff --git a/packages/ui/package.json b/packages/ui/package.json index c4e0e07..81a392c 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -19,7 +19,8 @@ "typecheck": "tsc --noEmit --project tsconfig.json && tsc --noEmit --project tsconfig.test.json" }, "dependencies": { - "@ekklesia/design-tokens": "workspace:*" + "@ekklesia/design-tokens": "workspace:*", + "react": "19.2.8" }, "peerDependencies": { "react": "19.2.8" @@ -31,7 +32,6 @@ "@types/react": "19.2.18", "@types/react-dom": "19.2.4", "happy-dom": "20.0.11", - "react": "19.2.8", "react-dom": "19.2.8", "typescript": "7.0.2", "vitest": "4.1.10" diff --git a/packages/ui/tests/tokens.test.ts b/packages/ui/tests/tokens.test.ts index e3462ef..e4aa63d 100644 --- a/packages/ui/tests/tokens.test.ts +++ b/packages/ui/tests/tokens.test.ts @@ -67,7 +67,11 @@ describe("the shared elements consume design tokens, never hardcoded theme value await readFile(path.join(packageDirectory, "package.json"), "utf8"), ) as { dependencies?: Record; peerDependencies?: Record }; - expect(Object.keys(manifest.dependencies ?? {})).toEqual(["@ekklesia/design-tokens"]); + // React is both a production dependency and a peer: the peer states that the consumer's React + // must be the same one, and the production declaration is what makes the package's own imports + // resolve, since a consumer never satisfies a linked workspace package's peer + // (`runtime-imports-are-production-dependencies`). + expect(Object.keys(manifest.dependencies ?? {})).toEqual(["@ekklesia/design-tokens", "react"]); expect(Object.keys(manifest.peerDependencies ?? {})).toEqual(["react"]); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 469eda1..ffd7375 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -201,6 +201,9 @@ importers: '@ekklesia/contracts': specifier: workspace:* version: link:../contracts + drizzle-orm: + specifier: 0.45.2 + version: 0.45.2(@types/pg@8.20.0)(kysely@0.29.4)(pg@8.22.0) devDependencies: '@biomejs/biome': specifier: 2.5.3 @@ -208,9 +211,6 @@ importers: '@types/node': specifier: 24.13.3 version: 24.13.3 - drizzle-orm: - specifier: 0.45.2 - version: 0.45.2(@types/pg@8.20.0)(kysely@0.29.4)(pg@8.22.0) typescript: specifier: 7.0.2 version: 7.0.2 @@ -287,6 +287,9 @@ importers: '@ekklesia/design-tokens': specifier: workspace:* version: link:../design-tokens + react: + specifier: 19.2.8 + version: 19.2.8 devDependencies: '@biomejs/biome': specifier: 2.5.3 @@ -306,9 +309,6 @@ importers: happy-dom: specifier: 20.0.11 version: 20.0.11 - react: - specifier: 19.2.8 - version: 19.2.8 react-dom: specifier: 19.2.8 version: 19.2.8(react@19.2.8) diff --git a/scripts/check-boundaries.ts b/scripts/check-boundaries.ts index be959e1..dd698b7 100644 --- a/scripts/check-boundaries.ts +++ b/scripts/check-boundaries.ts @@ -1,4 +1,5 @@ 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"; @@ -506,6 +507,82 @@ function collectImportSpecifiers(sourceText: string): string[] { 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(); + 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, @@ -906,6 +983,49 @@ export async function checkBoundaries(rootDirectory: string): Promise(); + 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)) { diff --git a/tests/boundaries.test.ts b/tests/boundaries.test.ts index a762e94..60f6601 100644 --- a/tests/boundaries.test.ts +++ b/tests/boundaries.test.ts @@ -56,6 +56,10 @@ describe("repository dependency boundaries", () => { ["design-package-depends-upward", "design-packages-depend-downward-only"], ["cross-package-relative", "workspace-imports-use-public-exports"], ["cycle", "no-circular-source-dependencies"], + ["runtime-peer-only-dependency", "runtime-imports-are-production-dependencies"], + ["runtime-dev-only-dependency", "runtime-imports-are-production-dependencies"], + ["runtime-jsx-implies-react", "runtime-imports-are-production-dependencies"], + ["runtime-undeclared-dependency", "runtime-imports-are-production-dependencies"], ])("rejects %s", async (fixture, expectedRule) => { const result = await checkBoundaries(path.join(fixtures, fixture)); expect(result.violations.map((violation) => violation.rule)).toContain(expectedRule); @@ -106,6 +110,57 @@ describe("repository dependency boundaries", () => { expect(uiViolations.map((violation) => violation.message).join(" ")).not.toContain("compat"); }); + it("accepts runtime imports declared as production dependencies, and everything that is not shipped runtime code", async () => { + // Production and optional dependencies, a peer kept beside its production declaration, Node + // built-ins, the package's own exports, type-only imports of development dependencies, and + // tests or build scripts that run with development dependencies installed. + const result = await checkBoundaries(path.join(fixtures, "legal-runtime-dependencies")); + + expect(result.violations).toEqual([]); + }); + + it.each([ + [ + "runtime-peer-only-dependency", + "packages/sdk/src/index.ts", + "drizzle-orm", + "only as a peer dependency", + ], + [ + "runtime-dev-only-dependency", + "packages/sdk/src/index.ts", + "zod", + "only as a development dependency", + ], + // The automatic JSX runtime imports `react/jsx-runtime` without that import appearing in source. + [ + "runtime-jsx-implies-react", + "packages/ui/src/index.tsx", + "react", + "only as a peer dependency", + ], + [ + "runtime-undeclared-dependency", + "modules/calendar/src/server/index.ts", + "sharp", + "not at all", + ], + ])("reports %s once, naming the file, the package, and how it is declared", async (fixture, from, to, declaredAs) => { + // A workspace package's imports resolve from its own node_modules: a peer is never satisfied + // there by the consumer, and a production install omits development dependencies, so each of + // these imports fails at run time in the production image even though development works. + const result = await checkBoundaries(path.join(fixtures, fixture)); + + expect(result.violations).toEqual([ + { + from, + message: expect.stringContaining(`declares it ${declaredAs}`), + rule: "runtime-imports-are-production-dependencies", + to, + }, + ]); + }); + it("still rejects module UI source that reaches into Core internals", async () => { // Allowing `.tsx` inside a module changed which *formats* a module may contain. It did not // change which layers a module may import: module UI is subject to the identical layer rules diff --git a/tests/fixtures/boundaries/legal-runtime-dependencies/packages/sdk/package.json b/tests/fixtures/boundaries/legal-runtime-dependencies/packages/sdk/package.json new file mode 100644 index 0000000..431a815 --- /dev/null +++ b/tests/fixtures/boundaries/legal-runtime-dependencies/packages/sdk/package.json @@ -0,0 +1,29 @@ +{ + "name": "@ekklesia/sdk", + "version": "0.0.0", + "type": "module", + "dependencies": { + "drizzle-orm": "0.45.2", + "react": "19.2.8", + "polyfill": "1.0.0" + }, + "peerDependencies": { + "drizzle-orm": "0.45.2", + "react": "19.2.8" + }, + "optionalDependencies": { + "fsevents": "2.3.3" + }, + "devDependencies": { + "esbuild": "0.28.1", + "type-only": "1.0.0", + "vitest": "4.1.10" + }, + "exports": { + ".": "./src/index.ts", + "./schema": "./src/index.ts" + }, + "ekklesia": { + "layer": "module-sdk" + } +} diff --git a/tests/fixtures/boundaries/legal-runtime-dependencies/packages/sdk/scripts/build.mjs b/tests/fixtures/boundaries/legal-runtime-dependencies/packages/sdk/scripts/build.mjs new file mode 100644 index 0000000..0099779 --- /dev/null +++ b/tests/fixtures/boundaries/legal-runtime-dependencies/packages/sdk/scripts/build.mjs @@ -0,0 +1,3 @@ +import { build } from "esbuild"; + +await build({ entryPoints: ["src/index.ts"] }); diff --git a/tests/fixtures/boundaries/legal-runtime-dependencies/packages/sdk/src/index.test.ts b/tests/fixtures/boundaries/legal-runtime-dependencies/packages/sdk/src/index.test.ts new file mode 100644 index 0000000..4564e86 --- /dev/null +++ b/tests/fixtures/boundaries/legal-runtime-dependencies/packages/sdk/src/index.test.ts @@ -0,0 +1,5 @@ +import { describe, it } from "vitest"; + +describe("sdk", () => { + it("runs with development dependencies installed", () => undefined); +}); diff --git a/tests/fixtures/boundaries/legal-runtime-dependencies/packages/sdk/src/index.ts b/tests/fixtures/boundaries/legal-runtime-dependencies/packages/sdk/src/index.ts new file mode 100644 index 0000000..0f72329 --- /dev/null +++ b/tests/fixtures/boundaries/legal-runtime-dependencies/packages/sdk/src/index.ts @@ -0,0 +1,13 @@ +import { readFile } from "fs/promises"; +import path from "node:path"; +import "polyfill"; +import { pgTableCreator } from "drizzle-orm/pg-core"; +import type { Config } from "type-only"; +import { type Options, type Settings } from "type-only/settings"; +export type { Shape } from "type-only/shape"; +export { createSchema } from "@ekklesia/sdk/schema"; + +export async function load(config: Config, options: Options, settings: Settings) { + const watcher = await import("fsevents"); + return { config, options, path, readFile, settings, table: pgTableCreator, watcher }; +} diff --git a/tests/fixtures/boundaries/legal-runtime-dependencies/packages/sdk/src/view.tsx b/tests/fixtures/boundaries/legal-runtime-dependencies/packages/sdk/src/view.tsx new file mode 100644 index 0000000..2e16e72 --- /dev/null +++ b/tests/fixtures/boundaries/legal-runtime-dependencies/packages/sdk/src/view.tsx @@ -0,0 +1,3 @@ +export function View() { + return
; +} diff --git a/tests/fixtures/boundaries/legal-runtime-dependencies/packages/sdk/tests/fixture.ts b/tests/fixtures/boundaries/legal-runtime-dependencies/packages/sdk/tests/fixture.ts new file mode 100644 index 0000000..4fd9cab --- /dev/null +++ b/tests/fixtures/boundaries/legal-runtime-dependencies/packages/sdk/tests/fixture.ts @@ -0,0 +1,3 @@ +import { build } from "esbuild"; + +export const builder = build; diff --git a/tests/fixtures/boundaries/module-imports-ui-host/modules/calendar/package.json b/tests/fixtures/boundaries/module-imports-ui-host/modules/calendar/package.json index cbfac02..7ed3057 100644 --- a/tests/fixtures/boundaries/module-imports-ui-host/modules/calendar/package.json +++ b/tests/fixtures/boundaries/module-imports-ui-host/modules/calendar/package.json @@ -3,7 +3,8 @@ "version": "0.0.0", "type": "module", "dependencies": { - "@ekklesia/module-ui-host": "workspace:*" + "@ekklesia/module-ui-host": "workspace:*", + "react": "19.2.8" }, "exports": { ".": "./src/index.ts" diff --git a/tests/fixtures/boundaries/module-ui-imports-core/modules/announcements/package.json b/tests/fixtures/boundaries/module-ui-imports-core/modules/announcements/package.json index dd9096a..e222e24 100644 --- a/tests/fixtures/boundaries/module-ui-imports-core/modules/announcements/package.json +++ b/tests/fixtures/boundaries/module-ui-imports-core/modules/announcements/package.json @@ -2,6 +2,9 @@ "name": "@ekklesia/module-announcements", "version": "0.0.0", "type": "module", + "dependencies": { + "react": "19.2.8" + }, "exports": { ".": "./src/index.ts" }, diff --git a/tests/fixtures/boundaries/runtime-dev-only-dependency/packages/sdk/package.json b/tests/fixtures/boundaries/runtime-dev-only-dependency/packages/sdk/package.json new file mode 100644 index 0000000..97f2e88 --- /dev/null +++ b/tests/fixtures/boundaries/runtime-dev-only-dependency/packages/sdk/package.json @@ -0,0 +1,12 @@ +{ + "name": "@ekklesia/sdk", + "version": "0.0.0", + "type": "module", + "devDependencies": { "zod": "4.0.0" }, + "exports": { + ".": "./src/index.ts" + }, + "ekklesia": { + "layer": "module-sdk" + } +} diff --git a/tests/fixtures/boundaries/runtime-dev-only-dependency/packages/sdk/src/index.ts b/tests/fixtures/boundaries/runtime-dev-only-dependency/packages/sdk/src/index.ts new file mode 100644 index 0000000..69291ab --- /dev/null +++ b/tests/fixtures/boundaries/runtime-dev-only-dependency/packages/sdk/src/index.ts @@ -0,0 +1,3 @@ +import { z } from "zod"; + +export const identifier = z.string(); diff --git a/tests/fixtures/boundaries/runtime-jsx-implies-react/packages/ui/package.json b/tests/fixtures/boundaries/runtime-jsx-implies-react/packages/ui/package.json new file mode 100644 index 0000000..54b9e7b --- /dev/null +++ b/tests/fixtures/boundaries/runtime-jsx-implies-react/packages/ui/package.json @@ -0,0 +1,13 @@ +{ + "name": "@ekklesia/ui", + "version": "0.0.0", + "type": "module", + "peerDependencies": { "react": "19.2.8" }, + "devDependencies": { "react": "19.2.8" }, + "exports": { + ".": "./src/index.ts" + }, + "ekklesia": { + "layer": "ui" + } +} diff --git a/tests/fixtures/boundaries/runtime-jsx-implies-react/packages/ui/src/index.tsx b/tests/fixtures/boundaries/runtime-jsx-implies-react/packages/ui/src/index.tsx new file mode 100644 index 0000000..1fea126 --- /dev/null +++ b/tests/fixtures/boundaries/runtime-jsx-implies-react/packages/ui/src/index.tsx @@ -0,0 +1,3 @@ +export function Badge(props: { readonly label: string }) { + return {props.label}; +} diff --git a/tests/fixtures/boundaries/runtime-peer-only-dependency/packages/sdk/package.json b/tests/fixtures/boundaries/runtime-peer-only-dependency/packages/sdk/package.json new file mode 100644 index 0000000..9d64412 --- /dev/null +++ b/tests/fixtures/boundaries/runtime-peer-only-dependency/packages/sdk/package.json @@ -0,0 +1,13 @@ +{ + "name": "@ekklesia/sdk", + "version": "0.0.0", + "type": "module", + "peerDependencies": { "drizzle-orm": "0.45.2" }, + "devDependencies": { "drizzle-orm": "0.45.2" }, + "exports": { + ".": "./src/index.ts" + }, + "ekklesia": { + "layer": "module-sdk" + } +} diff --git a/tests/fixtures/boundaries/runtime-peer-only-dependency/packages/sdk/src/index.ts b/tests/fixtures/boundaries/runtime-peer-only-dependency/packages/sdk/src/index.ts new file mode 100644 index 0000000..3e2e0f5 --- /dev/null +++ b/tests/fixtures/boundaries/runtime-peer-only-dependency/packages/sdk/src/index.ts @@ -0,0 +1,3 @@ +import { pgTableCreator } from "drizzle-orm/pg-core"; + +export const createTable = pgTableCreator((name) => name); diff --git a/tests/fixtures/boundaries/runtime-undeclared-dependency/modules/calendar/package.json b/tests/fixtures/boundaries/runtime-undeclared-dependency/modules/calendar/package.json new file mode 100644 index 0000000..6e5e91d --- /dev/null +++ b/tests/fixtures/boundaries/runtime-undeclared-dependency/modules/calendar/package.json @@ -0,0 +1,12 @@ +{ + "name": "@ekklesia/module-calendar", + "version": "0.0.0", + "type": "module", + + "exports": { + ".": "./src/index.ts" + }, + "ekklesia": { + "layer": "module" + } +} diff --git a/tests/fixtures/boundaries/runtime-undeclared-dependency/modules/calendar/src/server/index.ts b/tests/fixtures/boundaries/runtime-undeclared-dependency/modules/calendar/src/server/index.ts new file mode 100644 index 0000000..16d2dd5 --- /dev/null +++ b/tests/fixtures/boundaries/runtime-undeclared-dependency/modules/calendar/src/server/index.ts @@ -0,0 +1,3 @@ +export async function loadSharp() { + return await import("sharp"); +}