fix(packages): declare runtime imports as production dependencies #47

Merged
Zayin_Admin merged 1 commits from fix/module-sdk-runtime-dependencies into main 2026-10-02 00:58:38 +00:00
23 changed files with 322 additions and 13 deletions
+9 -1
View File
@@ -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.
+2 -2
View File
@@ -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"
},
+2 -2
View File
@@ -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"
+5 -1
View File
@@ -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<string, string>; peerDependencies?: Record<string, string> };
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"]);
});
+6 -6
View File
@@ -201,6 +201,9 @@ importers:
'@ekklesia/contracts':
specifier: workspace:*
version: link:../contracts
drizzle-orm:
specifier: 0.45.2
version: 0.45.2(@types/[email protected])([email protected])([email protected])
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/[email protected])([email protected])([email protected])
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([email protected])
+120
View File
@@ -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<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,
@@ -906,6 +983,49 @@ export async function checkBoundaries(rootDirectory: string): Promise<BoundaryRe
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)) {
+55
View File
@@ -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
@@ -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"
}
}
@@ -0,0 +1,3 @@
import { build } from "esbuild";
await build({ entryPoints: ["src/index.ts"] });
@@ -0,0 +1,5 @@
import { describe, it } from "vitest";
describe("sdk", () => {
it("runs with development dependencies installed", () => undefined);
});
@@ -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 };
}
@@ -0,0 +1,3 @@
export function View() {
return <div />;
}
@@ -0,0 +1,3 @@
import { build } from "esbuild";
export const builder = build;
@@ -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"
@@ -2,6 +2,9 @@
"name": "@ekklesia/module-announcements",
"version": "0.0.0",
"type": "module",
"dependencies": {
"react": "19.2.8"
},
"exports": {
".": "./src/index.ts"
},
@@ -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"
}
}
@@ -0,0 +1,3 @@
import { z } from "zod";
export const identifier = z.string();
@@ -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"
}
}
@@ -0,0 +1,3 @@
export function Badge(props: { readonly label: string }) {
return <span>{props.label}</span>;
}
@@ -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"
}
}
@@ -0,0 +1,3 @@
import { pgTableCreator } from "drizzle-orm/pg-core";
export const createTable = pgTableCreator((name) => name);
@@ -0,0 +1,12 @@
{
"name": "@ekklesia/module-calendar",
"version": "0.0.0",
"type": "module",
"exports": {
".": "./src/index.ts"
},
"ekklesia": {
"layer": "module"
}
}
@@ -0,0 +1,3 @@
export async function loadSharp() {
return await import("sharp");
}