From d9489384c1130e043f22e4bcf1613a0ee957a98f Mon Sep 17 00:00:00 2001 From: ekklesia Date: Sun, 12 Jul 2026 21:20:58 -0500 Subject: [PATCH] chore: harden repository guardrails --- .github/workflows/ci.yml | 11 + README.md | 2 + docs/architecture/repository-boundaries.md | 24 +- docs/development/repository-workflow.md | 21 +- infrastructure/compose.yaml | 2 +- package.json | 10 +- pnpm-workspace.yaml | 1 + scripts/check-boundaries.ts | 454 +++++++++++++++++- scripts/smoke-infrastructure.sh | 227 +++++++++ tests/boundaries.test.ts | 74 ++- .../app-imports-module/apps/api/src/index.ts | 3 + .../modules/calendar/src/index.ts | 1 + .../builders/default/src/index.ts | 3 + .../modules/calendar/src/index.ts | 1 + .../contracts-import-core/core/src/index.ts | 1 + .../packages/contracts/src/index.ts | 3 + .../cross-package-relative/core/package.json | 11 + .../cross-package-relative/core/src/index.ts | 3 + .../packages/contracts/package.json | 11 + .../packages/contracts/src/index.ts | 1 + .../surfaces/dashboard/src/index.ts | 3 + .../surfaces/website/src/index.ts | 1 + .../packages/one/package.json | 11 + .../packages/one/src/index.ts | 1 + .../packages/two/package.json | 11 + .../packages/two/src/index.ts | 1 + .../apps/api/src/index.ts | 3 + .../generated/module-registry.ts | 3 + .../modules/calendar/src/index.ts | 1 + .../legal/packages/conditional/package.json | 12 + .../legal/packages/conditional/src/index.ts | 1 + .../legal/packages/consumer/package.json | 15 + .../legal/packages/consumer/src/index.ts | 9 + .../legal/packages/contracts/package.json | 3 +- .../packages/contracts/src/public/value.ts | 1 + .../modules/calendar/src/view.ASTRO | 1 + .../module-imports-core/core/src/index.ts | 1 + .../modules/calendar/src/index.ts | 3 + .../modules/calendar/package.json | 11 + .../modules/calendar/src/index.ts | 3 + .../packages/ui/package.json | 11 + .../packages/ui/src/index.ts | 1 + .../modules/calendar/package.json | 12 + .../modules/calendar/src/index.ts | 3 + .../modules/calendar/src/view.mdx | 1 + .../non-workspace-version/core/package.json | 20 + .../non-workspace-version/core/src/index.ts | 1 + .../packages/contracts/package.json | 11 + .../packages/contracts/src/index.ts | 1 + .../packages/module-sdk/package.json | 11 + .../packages/module-sdk/src/index.ts | 1 + .../packages/surface-sdk/package.json | 11 + .../packages/surface-sdk/src/index.ts | 1 + .../packages/testing/package.json | 11 + .../packages/testing/src/index.ts | 1 + .../modules/calendar/package.json | 11 + .../modules/calendar/src/index.ts | 1 + .../surfaces/dashboard/package.json | 8 + .../surfaces/dashboard/src/index.ts | 1 + .../package-cycle/packages/one/package.json | 14 + .../package-cycle/packages/one/src/index.ts | 1 + .../package-cycle/packages/two/package.json | 14 + .../package-cycle/packages/two/src/index.ts | 1 + .../package-without-exports/core/package.json | 11 + .../package-without-exports/core/src/index.ts | 3 + .../packages/contracts/package.json | 8 + .../packages/contracts/src/index.ts | 1 + .../private-package-import/core/package.json | 11 + .../private-package-import/core/src/index.ts | 3 + .../packages/contracts/package.json | 13 + .../packages/contracts/src/index.ts | 1 + .../sdk-imports-core/core/src/index.ts | 1 + .../packages/module-sdk/src/index.ts | 3 + .../packages/contracts/src/index.ts | 3 + .../packages/module-sdk/src/index.ts | 3 + .../packages/surface-sdk/src/index.ts | 1 + .../modules/calendar/src/index.ts | 1 + .../surfaces/dashboard/src/index.ts | 3 + .../packages/consumer/package.json | 11 + .../packages/consumer/src/index.ts | 3 + .../packages/contracts/package.json | 11 + .../packages/contracts/src/index.ts | 1 + .../packages/mystery/package.json | 11 + .../packages/mystery/src/index.ts | 1 + .../modules/calendar/package.json | 12 + .../modules/calendar/src/index.ts | 4 + .../packages/contracts/package.json | 11 + .../packages/contracts/src/index.ts | 1 + .../surfaces/dashboard/package.json | 8 + .../surfaces/dashboard/src/index.ts | 1 + .../packages/excluded/package.json | 5 + .../packages/excluded/src/index.ts | 1 + .../packages/included/package.json | 11 + .../packages/included/src/index.ts | 1 + .../workspace-exclusion/pnpm-workspace.yaml | 3 + .../packages/example/package.json | 11 + .../packages/example/src/index.ts | 1 + .../pnpm-workspace.yaml | 2 + tests/infrastructure.test.ts | 47 +- turbo.json | 15 +- 100 files changed, 1303 insertions(+), 42 deletions(-) create mode 100755 scripts/smoke-infrastructure.sh create mode 100644 tests/fixtures/boundaries/app-imports-module/apps/api/src/index.ts create mode 100644 tests/fixtures/boundaries/app-imports-module/modules/calendar/src/index.ts create mode 100644 tests/fixtures/boundaries/builder-imports-module/builders/default/src/index.ts create mode 100644 tests/fixtures/boundaries/builder-imports-module/modules/calendar/src/index.ts create mode 100644 tests/fixtures/boundaries/contracts-import-core/core/src/index.ts create mode 100644 tests/fixtures/boundaries/contracts-import-core/packages/contracts/src/index.ts create mode 100644 tests/fixtures/boundaries/cross-package-relative/core/package.json create mode 100644 tests/fixtures/boundaries/cross-package-relative/core/src/index.ts create mode 100644 tests/fixtures/boundaries/cross-package-relative/packages/contracts/package.json create mode 100644 tests/fixtures/boundaries/cross-package-relative/packages/contracts/src/index.ts create mode 100644 tests/fixtures/boundaries/cross-surface-import/surfaces/dashboard/src/index.ts create mode 100644 tests/fixtures/boundaries/cross-surface-import/surfaces/website/src/index.ts create mode 100644 tests/fixtures/boundaries/duplicate-package-name/packages/one/package.json create mode 100644 tests/fixtures/boundaries/duplicate-package-name/packages/one/src/index.ts create mode 100644 tests/fixtures/boundaries/duplicate-package-name/packages/two/package.json create mode 100644 tests/fixtures/boundaries/duplicate-package-name/packages/two/src/index.ts create mode 100644 tests/fixtures/boundaries/legal-generated-bootstrap/apps/api/src/index.ts create mode 100644 tests/fixtures/boundaries/legal-generated-bootstrap/generated/module-registry.ts create mode 100644 tests/fixtures/boundaries/legal-generated-bootstrap/modules/calendar/src/index.ts create mode 100644 tests/fixtures/boundaries/legal/packages/conditional/package.json create mode 100644 tests/fixtures/boundaries/legal/packages/conditional/src/index.ts create mode 100644 tests/fixtures/boundaries/legal/packages/consumer/package.json create mode 100644 tests/fixtures/boundaries/legal/packages/consumer/src/index.ts create mode 100644 tests/fixtures/boundaries/legal/packages/contracts/src/public/value.ts create mode 100644 tests/fixtures/boundaries/module-has-ui/modules/calendar/src/view.ASTRO create mode 100644 tests/fixtures/boundaries/module-imports-core/core/src/index.ts create mode 100644 tests/fixtures/boundaries/module-imports-core/modules/calendar/src/index.ts create mode 100644 tests/fixtures/boundaries/module-imports-ui-package/modules/calendar/package.json create mode 100644 tests/fixtures/boundaries/module-imports-ui-package/modules/calendar/src/index.ts create mode 100644 tests/fixtures/boundaries/module-imports-ui-package/packages/ui/package.json create mode 100644 tests/fixtures/boundaries/module-imports-ui-package/packages/ui/src/index.ts create mode 100644 tests/fixtures/boundaries/module-ui-framework/modules/calendar/package.json create mode 100644 tests/fixtures/boundaries/module-ui-framework/modules/calendar/src/index.ts create mode 100644 tests/fixtures/boundaries/module-ui-framework/modules/calendar/src/view.mdx create mode 100644 tests/fixtures/boundaries/non-workspace-version/core/package.json create mode 100644 tests/fixtures/boundaries/non-workspace-version/core/src/index.ts create mode 100644 tests/fixtures/boundaries/non-workspace-version/packages/contracts/package.json create mode 100644 tests/fixtures/boundaries/non-workspace-version/packages/contracts/src/index.ts create mode 100644 tests/fixtures/boundaries/non-workspace-version/packages/module-sdk/package.json create mode 100644 tests/fixtures/boundaries/non-workspace-version/packages/module-sdk/src/index.ts create mode 100644 tests/fixtures/boundaries/non-workspace-version/packages/surface-sdk/package.json create mode 100644 tests/fixtures/boundaries/non-workspace-version/packages/surface-sdk/src/index.ts create mode 100644 tests/fixtures/boundaries/non-workspace-version/packages/testing/package.json create mode 100644 tests/fixtures/boundaries/non-workspace-version/packages/testing/src/index.ts create mode 100644 tests/fixtures/boundaries/package-boundary-direction/modules/calendar/package.json create mode 100644 tests/fixtures/boundaries/package-boundary-direction/modules/calendar/src/index.ts create mode 100644 tests/fixtures/boundaries/package-boundary-direction/surfaces/dashboard/package.json create mode 100644 tests/fixtures/boundaries/package-boundary-direction/surfaces/dashboard/src/index.ts create mode 100644 tests/fixtures/boundaries/package-cycle/packages/one/package.json create mode 100644 tests/fixtures/boundaries/package-cycle/packages/one/src/index.ts create mode 100644 tests/fixtures/boundaries/package-cycle/packages/two/package.json create mode 100644 tests/fixtures/boundaries/package-cycle/packages/two/src/index.ts create mode 100644 tests/fixtures/boundaries/package-without-exports/core/package.json create mode 100644 tests/fixtures/boundaries/package-without-exports/core/src/index.ts create mode 100644 tests/fixtures/boundaries/package-without-exports/packages/contracts/package.json create mode 100644 tests/fixtures/boundaries/package-without-exports/packages/contracts/src/index.ts create mode 100644 tests/fixtures/boundaries/private-package-import/core/package.json create mode 100644 tests/fixtures/boundaries/private-package-import/core/src/index.ts create mode 100644 tests/fixtures/boundaries/private-package-import/packages/contracts/package.json create mode 100644 tests/fixtures/boundaries/private-package-import/packages/contracts/src/index.ts create mode 100644 tests/fixtures/boundaries/sdk-imports-core/core/src/index.ts create mode 100644 tests/fixtures/boundaries/sdk-imports-core/packages/module-sdk/src/index.ts create mode 100644 tests/fixtures/boundaries/shared-role-order/packages/contracts/src/index.ts create mode 100644 tests/fixtures/boundaries/shared-role-order/packages/module-sdk/src/index.ts create mode 100644 tests/fixtures/boundaries/shared-role-order/packages/surface-sdk/src/index.ts create mode 100644 tests/fixtures/boundaries/surface-imports-module/modules/calendar/src/index.ts create mode 100644 tests/fixtures/boundaries/surface-imports-module/surfaces/dashboard/src/index.ts create mode 100644 tests/fixtures/boundaries/undeclared-workspace-dependency/packages/consumer/package.json create mode 100644 tests/fixtures/boundaries/undeclared-workspace-dependency/packages/consumer/src/index.ts create mode 100644 tests/fixtures/boundaries/undeclared-workspace-dependency/packages/contracts/package.json create mode 100644 tests/fixtures/boundaries/undeclared-workspace-dependency/packages/contracts/src/index.ts create mode 100644 tests/fixtures/boundaries/unknown-shared-role/packages/mystery/package.json create mode 100644 tests/fixtures/boundaries/unknown-shared-role/packages/mystery/src/index.ts create mode 100644 tests/fixtures/boundaries/workspace-alias/modules/calendar/package.json create mode 100644 tests/fixtures/boundaries/workspace-alias/modules/calendar/src/index.ts create mode 100644 tests/fixtures/boundaries/workspace-alias/packages/contracts/package.json create mode 100644 tests/fixtures/boundaries/workspace-alias/packages/contracts/src/index.ts create mode 100644 tests/fixtures/boundaries/workspace-alias/surfaces/dashboard/package.json create mode 100644 tests/fixtures/boundaries/workspace-alias/surfaces/dashboard/src/index.ts create mode 100644 tests/fixtures/boundaries/workspace-exclusion/packages/excluded/package.json create mode 100644 tests/fixtures/boundaries/workspace-exclusion/packages/excluded/src/index.ts create mode 100644 tests/fixtures/boundaries/workspace-exclusion/packages/included/package.json create mode 100644 tests/fixtures/boundaries/workspace-exclusion/packages/included/src/index.ts create mode 100644 tests/fixtures/boundaries/workspace-exclusion/pnpm-workspace.yaml create mode 100644 tests/fixtures/boundaries/workspace-unsupported-pattern/packages/example/package.json create mode 100644 tests/fixtures/boundaries/workspace-unsupported-pattern/packages/example/src/index.ts create mode 100644 tests/fixtures/boundaries/workspace-unsupported-pattern/pnpm-workspace.yaml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c15ce74..7aa3426 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,3 +36,14 @@ jobs: - name: Verify repository run: pnpm ci + + infrastructure-smoke: + needs: verify + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Check out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Exercise PostgreSQL and S3 infrastructure + run: bash scripts/smoke-infrastructure.sh diff --git a/README.md b/README.md index d6b54e4..a48992a 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,8 @@ pnpm infrastructure:up pnpm verify ``` +Run `pnpm infrastructure:smoke` for an isolated end-to-end PostgreSQL and authenticated S3 check. + The development services bind only to loopback: - PostgreSQL: `127.0.0.1:5432` diff --git a/docs/architecture/repository-boundaries.md b/docs/architecture/repository-boundaries.md index 7759f50..07ed8ff 100644 --- a/docs/architecture/repository-boundaries.md +++ b/docs/architecture/repository-boundaries.md @@ -17,24 +17,36 @@ These rules map platform ownership to filesystem and package boundaries. The exe - Core never imports a feature module, surface, Builder implementation, or app. - A module never imports Core implementations, another module, a surface, Builder code, or an app. -- A module cannot contain `.tsx`, `.jsx`, `.css`, `.scss`, `.less`, `.vue`, or `.svelte` source - and cannot depend on a UI framework. Modules expose data and behavior only. +- A module cannot contain presentation source, including JSX/TSX, single-file components, + Astro/Marko/MDX/HTML views, stylesheets, or SVG presentation assets, and cannot depend on UI + frameworks or shared UI-role packages. Modules expose data and behavior only. - A surface never imports Core or a feature module. It uses public API/client contracts. - Separate surfaces do not import each other's internals. - Builder code composes contracts and surface renderer identifiers; it does not import module or surface internals. - Contract and SDK packages cannot depend upward on implementations. - 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. +- 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. - Cross-module data access uses services, events, capabilities, or opaque identifiers rather than direct table imports and foreign keys. The boundary test suite includes intentionally invalid fixture graphs. This proves that the guard -rejects forbidden dependency directions even before production packages fill every directory. +rejects every currently machine-checkable repository rule even before production packages fill +every directory. The UI guard rejects component, view, template, stylesheet, shared UI-role, and +common UI-framework dependencies inside modules. This deny list is defense in depth; package +review remains required when adopting a new frontend technology. + +Cross-module table ownership, foreign keys, and schema namespace rules require Drizzle metadata +and are intentionally deferred to the Phase 3 schema validator. ## Generated code Generated artifacts are reproducible outputs with a named generator and validation step. A -generated module bootstrap may import discovered module entry points at an application -composition edge, but Core itself remains feature-agnostic. Generated files are never an excuse -for a hand-maintained central module registry. +generated module bootstrap under the root `generated/` boundary may import discovered module entry +points, and apps may import that generated bootstrap. Handwritten app and Core code still cannot +import feature modules directly. Generated files are never an excuse for a hand-maintained central +module registry. diff --git a/docs/development/repository-workflow.md b/docs/development/repository-workflow.md index a14c215..1d5c820 100644 --- a/docs/development/repository-workflow.md +++ b/docs/development/repository-workflow.md @@ -24,7 +24,12 @@ pnpm build # Turborepo package build graph pnpm verify # every code gate above ``` -CI runs `pnpm ci`, which adds Compose configuration validation to `pnpm verify`. +The lint, typecheck, and test commands run both repository-root checks and every matching +package-level Turborepo task. New packages add their own `lint`, `typecheck`, and `test` scripts; +the root verification command discovers them automatically. + +CI runs `pnpm ci`, which adds Compose configuration validation to `pnpm verify`. A separate CI job +runs the live infrastructure smoke test so code-only local verification does not require Docker. ## Development infrastructure @@ -42,6 +47,16 @@ pnpm infrastructure:down pnpm infrastructure:logs ``` +Exercise both services with isolated, throwaway credentials and Docker-assigned host ports: + +```bash +pnpm infrastructure:smoke +``` + +The smoke test verifies PostgreSQL password authentication and authenticated S3 list, put, get, +head, and delete operations. It also proves invalid database passwords, anonymous object access, +and invalid S3 signatures are rejected. Its containers, network, and volumes are removed on exit. + PostgreSQL and the S3 endpoint bind to `127.0.0.1` only. Compose uses persistent named volumes; `infrastructure:down` preserves data. The S3 service creates the configured development bucket idempotently at startup. @@ -49,5 +64,5 @@ idempotently at startup. The checked-in credentials are safe only for local development. Deployed secrets must come from an environment-appropriate secret provider and must not be committed. -Docker is not installed in every contributor environment. `pnpm infrastructure:config` and live -service health therefore run separately from the code-only `pnpm verify` gate. +Docker is not installed in every contributor environment. Compose validation and live service +health therefore run separately from the code-only `pnpm verify` gate. diff --git a/infrastructure/compose.yaml b/infrastructure/compose.yaml index 1db6967..dc18b5c 100644 --- a/infrastructure/compose.yaml +++ b/infrastructure/compose.yaml @@ -6,7 +6,7 @@ services: restart: unless-stopped environment: POSTGRES_DB: ${POSTGRES_DB:?POSTGRES_DB is required} - POSTGRES_INITDB_ARGS: --data-checksums + POSTGRES_INITDB_ARGS: --data-checksums --auth-host=scram-sha-256 --auth-local=trust POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required} POSTGRES_USER: ${POSTGRES_USER:?POSTGRES_USER is required} TZ: UTC diff --git a/package.json b/package.json index 8c7d57d..ee85e6c 100644 --- a/package.json +++ b/package.json @@ -18,11 +18,15 @@ "infrastructure:config": "docker compose --env-file .env.example -f infrastructure/compose.yaml config --quiet", "infrastructure:down": "docker compose --env-file .env -f infrastructure/compose.yaml down", "infrastructure:logs": "docker compose --env-file .env -f infrastructure/compose.yaml logs --follow", + "infrastructure:smoke": "bash scripts/smoke-infrastructure.sh", "infrastructure:up": "docker compose --env-file .env -f infrastructure/compose.yaml up --detach --wait", - "lint": "biome lint .", - "test": "vitest run", + "lint": "turbo run lint lint:root", + "lint:root": "biome lint .", + "test": "turbo run test test:root", + "test:root": "vitest run --config vitest.config.ts", "test:watch": "vitest", - "typecheck": "tsc --noEmit --project tsconfig.json", + "typecheck": "turbo run typecheck typecheck:root", + "typecheck:root": "tsc --noEmit --project tsconfig.json", "verify": "pnpm format:check && pnpm lint && pnpm boundaries && pnpm typecheck && pnpm test && pnpm build" }, "devDependencies": { diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index a1a1956..d17bab2 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,5 +1,6 @@ packages: - "apps/*" + - "builders/*" - "core" - "modules/*" - "packages/*" diff --git a/scripts/check-boundaries.ts b/scripts/check-boundaries.ts index 4231c44..e399449 100644 --- a/scripts/check-boundaries.ts +++ b/scripts/check-boundaries.ts @@ -1,6 +1,7 @@ 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"; @@ -28,6 +29,10 @@ interface WorkspacePackage { name: string; } +interface WorkspaceConfiguration { + packages?: string[]; +} + export interface BoundaryViolation { from: string; message: string; @@ -42,19 +47,82 @@ export interface BoundaryResult { } const CODE_EXTENSIONS = new Set([".cjs", ".cts", ".js", ".jsx", ".mjs", ".mts", ".ts", ".tsx"]); -const MODULE_UI_EXTENSIONS = new Set([".css", ".jsx", ".less", ".scss", ".svelte", ".tsx", ".vue"]); +const MODULE_UI_EXTENSIONS = new Set([ + ".astro", + ".css", + ".htm", + ".html", + ".jsx", + ".less", + ".marko", + ".mdx", + ".pcss", + ".postcss", + ".sass", + ".scss", + ".styl", + ".stylus", + ".svelte", + ".svg", + ".tsx", + ".vue", +]); 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", "react-dom", "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", @@ -69,6 +137,19 @@ const DEPENDENCY_FIELDS = [ "optionalDependencies", "peerDependencies", ] as const; +const SHARED_PACKAGE_ROLES = new Set([ + "api-client", + "auth-client", + "builder-sdk", + "contracts", + "design-tokens", + "module-sdk", + "shared", + "surface-sdk", + "testing", + "tooling", + "ui", +]); function normalizePath(value: string): string { return value.split(path.sep).join("/").replace(/^\.\//, ""); @@ -129,6 +210,12 @@ function boundaryRule(from: Location, to: Location): string | undefined { if (to.layer === "module" && from.owner !== to.owner) { return "modules-do-not-import-other-modules"; } + if ( + to.layer === "shared" && + !["contracts", "module-sdk", "testing", "tooling"].includes(to.role ?? "") + ) { + return "modules-use-public-platform-contracts"; + } } if (from.layer === "surface") { @@ -144,6 +231,10 @@ function boundaryRule(from: Location, to: Location): string | undefined { 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" && @@ -154,12 +245,24 @@ function boundaryRule(from: Location, to: Location): string | undefined { if ( from.layer === "shared" && - ["module-sdk", "surface-sdk"].includes(from.role ?? "") && + ["builder-sdk", "module-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", "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; } @@ -171,7 +274,28 @@ function packageNameFromSpecifier(specifier: string): string { } function isModuleUiPackage(packageName: string): boolean { - return MODULE_UI_PACKAGES.has(packageName) || packageName.startsWith("@angular/"); + 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]; +} + +function isModuleUiDependency(packageName: string, range: string): boolean { + const aliasTarget = npmAliasTarget(range); + return isModuleUiPackage(packageName) || (aliasTarget ? isModuleUiPackage(aliasTarget) : false); } async function isFile(filePath: string): Promise { @@ -210,13 +334,56 @@ async function walkFiles(root: string, current = root): Promise { 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 { + 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(); - const staticImport = - /\b(?:import|export)\s+(?:type\s+)?(?:[^"'\n;]*?\s+from\s+)?["']([^"'\n]+)["']/gu; + 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, dynamicImport]) { + for (const pattern of [staticImport, sideEffectImport, dynamicImport]) { for (const match of sourceText.matchAll(pattern)) { const specifier = match[1]; if (specifier) { @@ -313,41 +480,210 @@ function exportedSubpath( specifier: string, packageName: string, ): boolean { - const suffix = specifier.slice(packageName.length); - if (!suffix) { - return true; - } - - if (!manifest.exports || typeof manifest.exports !== "object") { + 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; + 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; const requested = `.${suffix}`; - return Object.hasOwn(manifest.exports, requested); + 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, + packageByDirectory: Map, +): { 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 { 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(); const packageByDirectory = new Map(); 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 || manifestFile === path.join(root, "package.json")) { + if (!manifest.name) { continue; } const directory = path.dirname(manifestFile); - const relativeDirectory = normalizePath(path.relative(root, directory)); + 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 }; - workspacePackages.set(manifest.name, workspacePackage); + 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); } @@ -355,8 +691,13 @@ export async function checkBoundaries(rootDirectory: string): Promise right.directory.length - left.directory.length, + ); + for (const cycle of findCycles(packageGraph)) { violations.push({ from: cycle[0] ?? "unknown-package", @@ -393,7 +761,10 @@ export async function checkBoundaries(rootDirectory: string): Promise/dev/null || { + echo "Docker is required for the infrastructure smoke test." >&2 + exit 1 +} +command -v curl >/dev/null || { + echo "curl is required for the infrastructure smoke test." >&2 + exit 1 +} +command -v cmp >/dev/null || { + echo "cmp is required for the infrastructure smoke test." >&2 + exit 1 +} +docker compose version >/dev/null + +curl_help="$(curl --help all)" +if [[ "$curl_help" != *"--aws-sigv4"* ]]; then + echo "curl must support --aws-sigv4 for authenticated S3 checks." >&2 + exit 1 +fi + +raw_project="ekklesia-smoke-${GITHUB_RUN_ID:-local}-${GITHUB_RUN_ATTEMPT:-0}-$$" +project="${raw_project//[^a-zA-Z0-9_-]/-}" +project="${project,,}" +temporary_directory="$(mktemp -d)" +payload_file="$temporary_directory/payload.txt" +download_file="$temporary_directory/download.txt" +bucket_list_file="$temporary_directory/buckets.xml" +: >"$bucket_list_file" + +export POSTGRES_DB="ekklesia_smoke" +export POSTGRES_USER="ekklesia_smoke" +export POSTGRES_PASSWORD="ekklesia-smoke-postgres-password" +export POSTGRES_PORT="${EKKLESIA_SMOKE_POSTGRES_PORT:-0}" +export S3_ACCESS_KEY="ekklesia-smoke-access" +export S3_SECRET_KEY="ekklesia-smoke-secret-key" +export S3_BUCKET="ekklesia-smoke" +export S3_REGION="us-east-1" +export S3_PORT="${EKKLESIA_SMOKE_S3_PORT:-0}" + +compose=( + docker compose + --project-name "$project" + --env-file .env.example + --file infrastructure/compose.yaml +) + +cleanup() { + local exit_status=$? + local cleanup_output + trap - EXIT INT TERM + if ((exit_status != 0)); then + "${compose[@]}" logs --no-color >&2 || true + fi + if ! cleanup_output="$( + "${compose[@]}" down --volumes --remove-orphans --timeout 10 2>&1 + )"; then + echo "Infrastructure smoke cleanup failed:" >&2 + echo "$cleanup_output" >&2 + if ((exit_status == 0)); then + exit_status=1 + fi + fi + rm -rf -- "$temporary_directory" + exit "$exit_status" +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +"${compose[@]}" config --quiet +"${compose[@]}" up --detach --wait --wait-timeout 120 +"${compose[@]}" up --detach --wait --wait-timeout 120 + +database_result="$( + "${compose[@]}" exec -T \ + -e "PGPASSWORD=$POSTGRES_PASSWORD" \ + postgres \ + psql \ + --no-psqlrc \ + --host 127.0.0.1 \ + --username "$POSTGRES_USER" \ + --dbname "$POSTGRES_DB" \ + --tuples-only \ + --no-align \ + --set ON_ERROR_STOP=1 \ + --command "SELECT current_database(), current_user" +)" +if [[ "$database_result" != "$POSTGRES_DB|$POSTGRES_USER" ]]; then + echo "PostgreSQL returned an unexpected identity: $database_result" >&2 + exit 1 +fi + +if "${compose[@]}" exec -T \ + -e "PGPASSWORD=incorrect-password" \ + postgres \ + psql \ + --no-psqlrc \ + --host 127.0.0.1 \ + --username "$POSTGRES_USER" \ + --dbname "$POSTGRES_DB" \ + --command "SELECT 1" >/dev/null 2>&1; then + echo "PostgreSQL unexpectedly accepted an invalid password." >&2 + exit 1 +fi + +s3_binding="$("${compose[@]}" port object-storage 8333)" +s3_port="${s3_binding##*:}" +if [[ ! "$s3_port" =~ ^[0-9]+$ ]]; then + echo "Compose returned an invalid S3 port binding: $s3_binding" >&2 + exit 1 +fi +s3_endpoint="http://127.0.0.1:$s3_port" +signed_curl=( + curl + --silent + --show-error + --aws-sigv4 "aws:amz:$S3_REGION:s3" + --user "$S3_ACCESS_KEY:$S3_SECRET_KEY" +) + +s3_ready=false +for _ in {1..30}; do + status="$( + "${signed_curl[@]}" \ + --output "$bucket_list_file" \ + --write-out "%{http_code}" \ + "$s3_endpoint/" || true + )" + bucket_list="$(<"$bucket_list_file")" + if [[ "$status" == "200" && "$bucket_list" == *"$S3_BUCKET"* ]]; then + s3_ready=true + break + fi + sleep 2 +done +if [[ "$s3_ready" != "true" ]]; then + echo "The authenticated S3 endpoint and bucket did not become ready." >&2 + exit 1 +fi + +printf '%s' "ekklesia-storage-smoke" >"$payload_file" +object_key="smoke/$project/object.txt" +object_url="$s3_endpoint/$S3_BUCKET/$object_key" + +put_status="$( + "${signed_curl[@]}" \ + --upload-file "$payload_file" \ + --header "Content-Type: text/plain" \ + --output /dev/null \ + --write-out "%{http_code}" \ + "$object_url" +)" +if [[ "$put_status" != "200" ]]; then + echo "S3 PUT returned $put_status instead of 200." >&2 + exit 1 +fi + +get_status="$( + "${signed_curl[@]}" \ + --output "$download_file" \ + --write-out "%{http_code}" \ + "$object_url" +)" +if [[ "$get_status" != "200" ]] || ! cmp --silent "$payload_file" "$download_file"; then + echo "S3 GET did not return the uploaded object unchanged." >&2 + exit 1 +fi + +head_status="$( + "${signed_curl[@]}" --head --output /dev/null --write-out "%{http_code}" "$object_url" +)" +if [[ "$head_status" != "200" ]]; then + echo "S3 HEAD returned $head_status instead of 200." >&2 + exit 1 +fi + +anonymous_status="$( + curl --silent --show-error --output /dev/null --write-out "%{http_code}" "$object_url" +)" +if [[ "$anonymous_status" != "403" ]]; then + echo "S3 anonymous GET returned $anonymous_status instead of 403." >&2 + exit 1 +fi + +bad_signature_status="$( + curl \ + --silent \ + --show-error \ + --aws-sigv4 "aws:amz:$S3_REGION:s3" \ + --user "$S3_ACCESS_KEY:incorrect-secret" \ + --output /dev/null \ + --write-out "%{http_code}" \ + "$object_url" +)" +if [[ "$bad_signature_status" != "403" ]]; then + echo "S3 invalid signature returned $bad_signature_status instead of 403." >&2 + exit 1 +fi + +delete_status="$( + "${signed_curl[@]}" \ + --request DELETE \ + --output /dev/null \ + --write-out "%{http_code}" \ + "$object_url" +)" +if [[ "$delete_status" != "204" ]]; then + echo "S3 DELETE returned $delete_status instead of 204." >&2 + exit 1 +fi + +missing_status="$( + "${signed_curl[@]}" --head --output /dev/null --write-out "%{http_code}" "$object_url" +)" +if [[ "$missing_status" != "404" ]]; then + echo "Deleted S3 object returned $missing_status instead of 404." >&2 + exit 1 +fi + +echo "Infrastructure smoke test passed for PostgreSQL and authenticated S3 operations." diff --git a/tests/boundaries.test.ts b/tests/boundaries.test.ts index 3c9d309..921c4ef 100644 --- a/tests/boundaries.test.ts +++ b/tests/boundaries.test.ts @@ -12,21 +12,93 @@ describe("repository dependency boundaries", () => { expect(result.violations).toEqual([]); }); + it("accepts generated module composition only through the generated bootstrap boundary", async () => { + const result = await checkBoundaries(path.join(fixtures, "legal-generated-bootstrap")); + expect(result.violations).toEqual([]); + }); + + it("honors explicit pnpm workspace exclusions", async () => { + const result = await checkBoundaries(path.join(fixtures, "workspace-exclusion")); + expect(result.violations).toEqual([]); + }); + it.each([ ["core-imports-module", "core-is-feature-agnostic"], + ["app-imports-module", "apps-are-composition-roots"], + ["module-imports-core", "modules-use-public-platform-contracts"], ["module-imports-surface", "modules-use-public-platform-contracts"], + ["package-boundary-direction", "modules-use-public-platform-contracts"], ["cross-module-import", "modules-do-not-import-other-modules"], + ["surface-imports-module", "surfaces-use-public-contracts"], + ["cross-surface-import", "surfaces-are-independent"], + ["builder-imports-module", "builder-composes-contracts"], + ["contracts-import-core", "contracts-do-not-depend-upward"], + ["sdk-imports-core", "sdks-do-not-depend-on-implementations"], + ["shared-role-order", "sdks-do-not-depend-on-implementations"], + ["package-without-exports", "workspace-imports-use-public-exports"], + ["private-package-import", "workspace-imports-use-public-exports"], + ["non-workspace-version", "internal-dependencies-use-workspace-protocol"], + ["workspace-alias", "workspace-dependency-aliases-are-forbidden"], + ["undeclared-workspace-dependency", "workspace-dependencies-are-declared"], + ["duplicate-package-name", "workspace-package-names-are-unique"], + ["unknown-shared-role", "shared-packages-declare-known-roles"], + ["workspace-unsupported-pattern", "workspace-patterns-use-supported-syntax"], + ["package-cycle", "no-circular-package-dependencies"], ["module-has-ui", "modules-do-not-own-ui"], + ["module-ui-framework", "modules-do-not-own-ui"], + ["module-imports-ui-package", "modules-use-public-platform-contracts"], + ["cross-package-relative", "workspace-imports-use-public-exports"], ["cycle", "no-circular-source-dependencies"], ])("rejects %s", async (fixture, expectedRule) => { const result = await checkBoundaries(path.join(fixtures, fixture)); expect(result.violations.map((violation) => violation.rule)).toContain(expectedRule); }); - it("rejects both component and stylesheet files inside a module", async () => { + it("rejects component, stylesheet, and case-insensitive view files inside a module", async () => { const result = await checkBoundaries(path.join(fixtures, "module-has-ui")); expect( result.violations.filter((violation) => violation.rule === "modules-do-not-own-ui"), + ).toHaveLength(3); + }); + + it("rejects UI framework dependencies, imports, and view formats inside a module", async () => { + const result = await checkBoundaries(path.join(fixtures, "module-ui-framework")); + expect( + result.violations.filter((violation) => violation.rule === "modules-do-not-own-ui"), + ).toHaveLength(4); + }); + + it("requires workspace: ranges in every dependency field", async () => { + const result = await checkBoundaries(path.join(fixtures, "non-workspace-version")); + expect( + result.violations.filter( + (violation) => violation.rule === "internal-dependencies-use-workspace-protocol", + ), + ).toHaveLength(4); + }); + + it("resolves forbidden workspace aliases before applying layer rules", async () => { + const result = await checkBoundaries(path.join(fixtures, "workspace-alias")); + expect( + result.violations.filter( + (violation) => violation.rule === "workspace-dependency-aliases-are-forbidden", + ), ).toHaveLength(2); + expect(result.violations.map((violation) => violation.rule)).toEqual( + expect.arrayContaining([ + "workspace-dependency-aliases-are-forbidden", + "modules-use-public-platform-contracts", + ]), + ); + }); + + it("prevents contracts and SDKs from depending upward through shared packages", async () => { + const result = await checkBoundaries(path.join(fixtures, "shared-role-order")); + expect(result.violations.map((violation) => violation.rule)).toEqual( + expect.arrayContaining([ + "contracts-do-not-depend-upward", + "sdks-do-not-depend-on-implementations", + ]), + ); }); }); diff --git a/tests/fixtures/boundaries/app-imports-module/apps/api/src/index.ts b/tests/fixtures/boundaries/app-imports-module/apps/api/src/index.ts new file mode 100644 index 0000000..c480d8f --- /dev/null +++ b/tests/fixtures/boundaries/app-imports-module/apps/api/src/index.ts @@ -0,0 +1,3 @@ +import { calendar } from "../../../modules/calendar/src/index.js"; + +export const invalidAppFeature = calendar; diff --git a/tests/fixtures/boundaries/app-imports-module/modules/calendar/src/index.ts b/tests/fixtures/boundaries/app-imports-module/modules/calendar/src/index.ts new file mode 100644 index 0000000..217b229 --- /dev/null +++ b/tests/fixtures/boundaries/app-imports-module/modules/calendar/src/index.ts @@ -0,0 +1 @@ +export const calendar = "calendar"; diff --git a/tests/fixtures/boundaries/builder-imports-module/builders/default/src/index.ts b/tests/fixtures/boundaries/builder-imports-module/builders/default/src/index.ts new file mode 100644 index 0000000..8242402 --- /dev/null +++ b/tests/fixtures/boundaries/builder-imports-module/builders/default/src/index.ts @@ -0,0 +1,3 @@ +import { calendar } from "../../../modules/calendar/src/index.js"; + +export const invalidBuilderFeature = calendar; diff --git a/tests/fixtures/boundaries/builder-imports-module/modules/calendar/src/index.ts b/tests/fixtures/boundaries/builder-imports-module/modules/calendar/src/index.ts new file mode 100644 index 0000000..217b229 --- /dev/null +++ b/tests/fixtures/boundaries/builder-imports-module/modules/calendar/src/index.ts @@ -0,0 +1 @@ +export const calendar = "calendar"; diff --git a/tests/fixtures/boundaries/contracts-import-core/core/src/index.ts b/tests/fixtures/boundaries/contracts-import-core/core/src/index.ts new file mode 100644 index 0000000..c86d8ee --- /dev/null +++ b/tests/fixtures/boundaries/contracts-import-core/core/src/index.ts @@ -0,0 +1 @@ +export const core = "core"; diff --git a/tests/fixtures/boundaries/contracts-import-core/packages/contracts/src/index.ts b/tests/fixtures/boundaries/contracts-import-core/packages/contracts/src/index.ts new file mode 100644 index 0000000..fadf62a --- /dev/null +++ b/tests/fixtures/boundaries/contracts-import-core/packages/contracts/src/index.ts @@ -0,0 +1,3 @@ +import { core } from "../../../core/src/index.js"; + +export const invalidContract = core; diff --git a/tests/fixtures/boundaries/cross-package-relative/core/package.json b/tests/fixtures/boundaries/cross-package-relative/core/package.json new file mode 100644 index 0000000..302605c --- /dev/null +++ b/tests/fixtures/boundaries/cross-package-relative/core/package.json @@ -0,0 +1,11 @@ +{ + "name": "@ekklesia/core", + "version": "0.0.0", + "type": "module", + "dependencies": { + "@ekklesia/contracts": "workspace:*" + }, + "exports": { + ".": "./src/index.ts" + } +} diff --git a/tests/fixtures/boundaries/cross-package-relative/core/src/index.ts b/tests/fixtures/boundaries/cross-package-relative/core/src/index.ts new file mode 100644 index 0000000..f6cd040 --- /dev/null +++ b/tests/fixtures/boundaries/cross-package-relative/core/src/index.ts @@ -0,0 +1,3 @@ +import { contract } from "../../packages/contracts/src/index.js"; + +export const invalidPrivatePath = contract; diff --git a/tests/fixtures/boundaries/cross-package-relative/packages/contracts/package.json b/tests/fixtures/boundaries/cross-package-relative/packages/contracts/package.json new file mode 100644 index 0000000..2890856 --- /dev/null +++ b/tests/fixtures/boundaries/cross-package-relative/packages/contracts/package.json @@ -0,0 +1,11 @@ +{ + "name": "@ekklesia/contracts", + "version": "0.0.0", + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "ekklesia": { + "layer": "contracts" + } +} diff --git a/tests/fixtures/boundaries/cross-package-relative/packages/contracts/src/index.ts b/tests/fixtures/boundaries/cross-package-relative/packages/contracts/src/index.ts new file mode 100644 index 0000000..774bcf0 --- /dev/null +++ b/tests/fixtures/boundaries/cross-package-relative/packages/contracts/src/index.ts @@ -0,0 +1 @@ +export const contract = "contract"; diff --git a/tests/fixtures/boundaries/cross-surface-import/surfaces/dashboard/src/index.ts b/tests/fixtures/boundaries/cross-surface-import/surfaces/dashboard/src/index.ts new file mode 100644 index 0000000..5ad2051 --- /dev/null +++ b/tests/fixtures/boundaries/cross-surface-import/surfaces/dashboard/src/index.ts @@ -0,0 +1,3 @@ +import { website } from "../../../surfaces/website/src/index.js"; + +export const invalidSurfaceDependency = website; diff --git a/tests/fixtures/boundaries/cross-surface-import/surfaces/website/src/index.ts b/tests/fixtures/boundaries/cross-surface-import/surfaces/website/src/index.ts new file mode 100644 index 0000000..5b169a1 --- /dev/null +++ b/tests/fixtures/boundaries/cross-surface-import/surfaces/website/src/index.ts @@ -0,0 +1 @@ +export const website = "website"; diff --git a/tests/fixtures/boundaries/duplicate-package-name/packages/one/package.json b/tests/fixtures/boundaries/duplicate-package-name/packages/one/package.json new file mode 100644 index 0000000..ee32875 --- /dev/null +++ b/tests/fixtures/boundaries/duplicate-package-name/packages/one/package.json @@ -0,0 +1,11 @@ +{ + "name": "@ekklesia/duplicate", + "version": "0.0.0", + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "ekklesia": { + "layer": "tooling" + } +} diff --git a/tests/fixtures/boundaries/duplicate-package-name/packages/one/src/index.ts b/tests/fixtures/boundaries/duplicate-package-name/packages/one/src/index.ts new file mode 100644 index 0000000..d815786 --- /dev/null +++ b/tests/fixtures/boundaries/duplicate-package-name/packages/one/src/index.ts @@ -0,0 +1 @@ +export const one = 1; diff --git a/tests/fixtures/boundaries/duplicate-package-name/packages/two/package.json b/tests/fixtures/boundaries/duplicate-package-name/packages/two/package.json new file mode 100644 index 0000000..ee32875 --- /dev/null +++ b/tests/fixtures/boundaries/duplicate-package-name/packages/two/package.json @@ -0,0 +1,11 @@ +{ + "name": "@ekklesia/duplicate", + "version": "0.0.0", + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "ekklesia": { + "layer": "tooling" + } +} diff --git a/tests/fixtures/boundaries/duplicate-package-name/packages/two/src/index.ts b/tests/fixtures/boundaries/duplicate-package-name/packages/two/src/index.ts new file mode 100644 index 0000000..6a2308a --- /dev/null +++ b/tests/fixtures/boundaries/duplicate-package-name/packages/two/src/index.ts @@ -0,0 +1 @@ +export const two = 2; diff --git a/tests/fixtures/boundaries/legal-generated-bootstrap/apps/api/src/index.ts b/tests/fixtures/boundaries/legal-generated-bootstrap/apps/api/src/index.ts new file mode 100644 index 0000000..c45c9d8 --- /dev/null +++ b/tests/fixtures/boundaries/legal-generated-bootstrap/apps/api/src/index.ts @@ -0,0 +1,3 @@ +import { installedModules } from "../../../generated/module-registry.js"; + +export const bootstrap = installedModules; diff --git a/tests/fixtures/boundaries/legal-generated-bootstrap/generated/module-registry.ts b/tests/fixtures/boundaries/legal-generated-bootstrap/generated/module-registry.ts new file mode 100644 index 0000000..bc0bfc5 --- /dev/null +++ b/tests/fixtures/boundaries/legal-generated-bootstrap/generated/module-registry.ts @@ -0,0 +1,3 @@ +import { calendar } from "../modules/calendar/src/index.js"; + +export const installedModules = [calendar]; diff --git a/tests/fixtures/boundaries/legal-generated-bootstrap/modules/calendar/src/index.ts b/tests/fixtures/boundaries/legal-generated-bootstrap/modules/calendar/src/index.ts new file mode 100644 index 0000000..217b229 --- /dev/null +++ b/tests/fixtures/boundaries/legal-generated-bootstrap/modules/calendar/src/index.ts @@ -0,0 +1 @@ +export const calendar = "calendar"; diff --git a/tests/fixtures/boundaries/legal/packages/conditional/package.json b/tests/fixtures/boundaries/legal/packages/conditional/package.json new file mode 100644 index 0000000..401057d --- /dev/null +++ b/tests/fixtures/boundaries/legal/packages/conditional/package.json @@ -0,0 +1,12 @@ +{ + "name": "@ekklesia/conditional", + "version": "0.0.0", + "type": "module", + "exports": { + "types": "./src/index.ts", + "import": "./src/index.ts" + }, + "ekklesia": { + "layer": "shared" + } +} diff --git a/tests/fixtures/boundaries/legal/packages/conditional/src/index.ts b/tests/fixtures/boundaries/legal/packages/conditional/src/index.ts new file mode 100644 index 0000000..cd4a36b --- /dev/null +++ b/tests/fixtures/boundaries/legal/packages/conditional/src/index.ts @@ -0,0 +1 @@ +export const conditional = "conditional"; diff --git a/tests/fixtures/boundaries/legal/packages/consumer/package.json b/tests/fixtures/boundaries/legal/packages/consumer/package.json new file mode 100644 index 0000000..2ee51bc --- /dev/null +++ b/tests/fixtures/boundaries/legal/packages/consumer/package.json @@ -0,0 +1,15 @@ +{ + "name": "@ekklesia/consumer", + "version": "0.0.0", + "type": "module", + "dependencies": { + "@ekklesia/conditional": "workspace:*", + "@ekklesia/contracts": "workspace:*" + }, + "exports": { + ".": "./src/index.ts" + }, + "ekklesia": { + "layer": "shared" + } +} diff --git a/tests/fixtures/boundaries/legal/packages/consumer/src/index.ts b/tests/fixtures/boundaries/legal/packages/consumer/src/index.ts new file mode 100644 index 0000000..90ff8e9 --- /dev/null +++ b/tests/fixtures/boundaries/legal/packages/consumer/src/index.ts @@ -0,0 +1,9 @@ +import type { HealthContract } from "@ekklesia/contracts"; +import { conditional } from "@ekklesia/conditional"; +import { + publicValue, +} from "@ekklesia/contracts/public/value"; + +export const health: HealthContract = { status: "ok" }; +export { conditional }; +export { publicValue }; diff --git a/tests/fixtures/boundaries/legal/packages/contracts/package.json b/tests/fixtures/boundaries/legal/packages/contracts/package.json index 2890856..06bf465 100644 --- a/tests/fixtures/boundaries/legal/packages/contracts/package.json +++ b/tests/fixtures/boundaries/legal/packages/contracts/package.json @@ -3,7 +3,8 @@ "version": "0.0.0", "type": "module", "exports": { - ".": "./src/index.ts" + ".": "./src/index.ts", + "./public/*": "./src/public/*.ts" }, "ekklesia": { "layer": "contracts" diff --git a/tests/fixtures/boundaries/legal/packages/contracts/src/public/value.ts b/tests/fixtures/boundaries/legal/packages/contracts/src/public/value.ts new file mode 100644 index 0000000..60100f9 --- /dev/null +++ b/tests/fixtures/boundaries/legal/packages/contracts/src/public/value.ts @@ -0,0 +1 @@ +export const publicValue = "public"; diff --git a/tests/fixtures/boundaries/module-has-ui/modules/calendar/src/view.ASTRO b/tests/fixtures/boundaries/module-has-ui/modules/calendar/src/view.ASTRO new file mode 100644 index 0000000..c20795e --- /dev/null +++ b/tests/fixtures/boundaries/module-has-ui/modules/calendar/src/view.ASTRO @@ -0,0 +1 @@ +
Modules do not own UI.
diff --git a/tests/fixtures/boundaries/module-imports-core/core/src/index.ts b/tests/fixtures/boundaries/module-imports-core/core/src/index.ts new file mode 100644 index 0000000..c86d8ee --- /dev/null +++ b/tests/fixtures/boundaries/module-imports-core/core/src/index.ts @@ -0,0 +1 @@ +export const core = "core"; diff --git a/tests/fixtures/boundaries/module-imports-core/modules/calendar/src/index.ts b/tests/fixtures/boundaries/module-imports-core/modules/calendar/src/index.ts new file mode 100644 index 0000000..be5c8c7 --- /dev/null +++ b/tests/fixtures/boundaries/module-imports-core/modules/calendar/src/index.ts @@ -0,0 +1,3 @@ +import { core } from "../../../core/src/index.js"; + +export const invalidCoreImport = core; diff --git a/tests/fixtures/boundaries/module-imports-ui-package/modules/calendar/package.json b/tests/fixtures/boundaries/module-imports-ui-package/modules/calendar/package.json new file mode 100644 index 0000000..5259555 --- /dev/null +++ b/tests/fixtures/boundaries/module-imports-ui-package/modules/calendar/package.json @@ -0,0 +1,11 @@ +{ + "name": "@ekklesia/module-calendar", + "version": "0.0.0", + "type": "module", + "dependencies": { + "@ekklesia/ui": "workspace:*" + }, + "exports": { + ".": "./src/index.ts" + } +} diff --git a/tests/fixtures/boundaries/module-imports-ui-package/modules/calendar/src/index.ts b/tests/fixtures/boundaries/module-imports-ui-package/modules/calendar/src/index.ts new file mode 100644 index 0000000..1b5aa00 --- /dev/null +++ b/tests/fixtures/boundaries/module-imports-ui-package/modules/calendar/src/index.ts @@ -0,0 +1,3 @@ +import { button } from "@ekklesia/ui"; + +export const invalidModuleUiDependency = button; diff --git a/tests/fixtures/boundaries/module-imports-ui-package/packages/ui/package.json b/tests/fixtures/boundaries/module-imports-ui-package/packages/ui/package.json new file mode 100644 index 0000000..fa75f71 --- /dev/null +++ b/tests/fixtures/boundaries/module-imports-ui-package/packages/ui/package.json @@ -0,0 +1,11 @@ +{ + "name": "@ekklesia/ui", + "version": "0.0.0", + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "ekklesia": { + "layer": "ui" + } +} diff --git a/tests/fixtures/boundaries/module-imports-ui-package/packages/ui/src/index.ts b/tests/fixtures/boundaries/module-imports-ui-package/packages/ui/src/index.ts new file mode 100644 index 0000000..009eb5e --- /dev/null +++ b/tests/fixtures/boundaries/module-imports-ui-package/packages/ui/src/index.ts @@ -0,0 +1 @@ +export const button = "button"; diff --git a/tests/fixtures/boundaries/module-ui-framework/modules/calendar/package.json b/tests/fixtures/boundaries/module-ui-framework/modules/calendar/package.json new file mode 100644 index 0000000..4579027 --- /dev/null +++ b/tests/fixtures/boundaries/module-ui-framework/modules/calendar/package.json @@ -0,0 +1,12 @@ +{ + "name": "@ekklesia/module-calendar", + "version": "0.0.0", + "type": "module", + "dependencies": { + "compat": "npm:react@19.2.0", + "preact": "10.27.2" + }, + "exports": { + ".": "./src/index.ts" + } +} diff --git a/tests/fixtures/boundaries/module-ui-framework/modules/calendar/src/index.ts b/tests/fixtures/boundaries/module-ui-framework/modules/calendar/src/index.ts new file mode 100644 index 0000000..c406c5f --- /dev/null +++ b/tests/fixtures/boundaries/module-ui-framework/modules/calendar/src/index.ts @@ -0,0 +1,3 @@ +import { h } from "preact"; + +export const invalidUiFramework = h; diff --git a/tests/fixtures/boundaries/module-ui-framework/modules/calendar/src/view.mdx b/tests/fixtures/boundaries/module-ui-framework/modules/calendar/src/view.mdx new file mode 100644 index 0000000..04d093d --- /dev/null +++ b/tests/fixtures/boundaries/module-ui-framework/modules/calendar/src/view.mdx @@ -0,0 +1 @@ +# Modules do not own views diff --git a/tests/fixtures/boundaries/non-workspace-version/core/package.json b/tests/fixtures/boundaries/non-workspace-version/core/package.json new file mode 100644 index 0000000..9645e7e --- /dev/null +++ b/tests/fixtures/boundaries/non-workspace-version/core/package.json @@ -0,0 +1,20 @@ +{ + "name": "@ekklesia/core", + "version": "0.0.0", + "type": "module", + "dependencies": { + "@ekklesia/contracts": "^1.0.0" + }, + "devDependencies": { + "@ekklesia/testing": "1.0.0" + }, + "optionalDependencies": { + "@ekklesia/module-sdk": "~1.0.0" + }, + "peerDependencies": { + "@ekklesia/surface-sdk": ">=1.0.0" + }, + "exports": { + ".": "./src/index.ts" + } +} diff --git a/tests/fixtures/boundaries/non-workspace-version/core/src/index.ts b/tests/fixtures/boundaries/non-workspace-version/core/src/index.ts new file mode 100644 index 0000000..c86d8ee --- /dev/null +++ b/tests/fixtures/boundaries/non-workspace-version/core/src/index.ts @@ -0,0 +1 @@ +export const core = "core"; diff --git a/tests/fixtures/boundaries/non-workspace-version/packages/contracts/package.json b/tests/fixtures/boundaries/non-workspace-version/packages/contracts/package.json new file mode 100644 index 0000000..11eced2 --- /dev/null +++ b/tests/fixtures/boundaries/non-workspace-version/packages/contracts/package.json @@ -0,0 +1,11 @@ +{ + "name": "@ekklesia/contracts", + "version": "1.0.0", + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "ekklesia": { + "layer": "contracts" + } +} diff --git a/tests/fixtures/boundaries/non-workspace-version/packages/contracts/src/index.ts b/tests/fixtures/boundaries/non-workspace-version/packages/contracts/src/index.ts new file mode 100644 index 0000000..774bcf0 --- /dev/null +++ b/tests/fixtures/boundaries/non-workspace-version/packages/contracts/src/index.ts @@ -0,0 +1 @@ +export const contract = "contract"; diff --git a/tests/fixtures/boundaries/non-workspace-version/packages/module-sdk/package.json b/tests/fixtures/boundaries/non-workspace-version/packages/module-sdk/package.json new file mode 100644 index 0000000..bd526f9 --- /dev/null +++ b/tests/fixtures/boundaries/non-workspace-version/packages/module-sdk/package.json @@ -0,0 +1,11 @@ +{ + "name": "@ekklesia/module-sdk", + "version": "1.0.0", + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "ekklesia": { + "layer": "module-sdk" + } +} diff --git a/tests/fixtures/boundaries/non-workspace-version/packages/module-sdk/src/index.ts b/tests/fixtures/boundaries/non-workspace-version/packages/module-sdk/src/index.ts new file mode 100644 index 0000000..5dfb17a --- /dev/null +++ b/tests/fixtures/boundaries/non-workspace-version/packages/module-sdk/src/index.ts @@ -0,0 +1 @@ +export const moduleSdk = "module-sdk"; diff --git a/tests/fixtures/boundaries/non-workspace-version/packages/surface-sdk/package.json b/tests/fixtures/boundaries/non-workspace-version/packages/surface-sdk/package.json new file mode 100644 index 0000000..584a138 --- /dev/null +++ b/tests/fixtures/boundaries/non-workspace-version/packages/surface-sdk/package.json @@ -0,0 +1,11 @@ +{ + "name": "@ekklesia/surface-sdk", + "version": "1.0.0", + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "ekklesia": { + "layer": "surface-sdk" + } +} diff --git a/tests/fixtures/boundaries/non-workspace-version/packages/surface-sdk/src/index.ts b/tests/fixtures/boundaries/non-workspace-version/packages/surface-sdk/src/index.ts new file mode 100644 index 0000000..9be0157 --- /dev/null +++ b/tests/fixtures/boundaries/non-workspace-version/packages/surface-sdk/src/index.ts @@ -0,0 +1 @@ +export const surfaceSdk = "surface-sdk"; diff --git a/tests/fixtures/boundaries/non-workspace-version/packages/testing/package.json b/tests/fixtures/boundaries/non-workspace-version/packages/testing/package.json new file mode 100644 index 0000000..b5692b2 --- /dev/null +++ b/tests/fixtures/boundaries/non-workspace-version/packages/testing/package.json @@ -0,0 +1,11 @@ +{ + "name": "@ekklesia/testing", + "version": "1.0.0", + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "ekklesia": { + "layer": "testing" + } +} diff --git a/tests/fixtures/boundaries/non-workspace-version/packages/testing/src/index.ts b/tests/fixtures/boundaries/non-workspace-version/packages/testing/src/index.ts new file mode 100644 index 0000000..cf70295 --- /dev/null +++ b/tests/fixtures/boundaries/non-workspace-version/packages/testing/src/index.ts @@ -0,0 +1 @@ +export const testing = "testing"; diff --git a/tests/fixtures/boundaries/package-boundary-direction/modules/calendar/package.json b/tests/fixtures/boundaries/package-boundary-direction/modules/calendar/package.json new file mode 100644 index 0000000..5743cce --- /dev/null +++ b/tests/fixtures/boundaries/package-boundary-direction/modules/calendar/package.json @@ -0,0 +1,11 @@ +{ + "name": "@ekklesia/module-calendar", + "version": "0.0.0", + "type": "module", + "dependencies": { + "@ekklesia/surface-dashboard": "workspace:*" + }, + "exports": { + ".": "./src/index.ts" + } +} diff --git a/tests/fixtures/boundaries/package-boundary-direction/modules/calendar/src/index.ts b/tests/fixtures/boundaries/package-boundary-direction/modules/calendar/src/index.ts new file mode 100644 index 0000000..217b229 --- /dev/null +++ b/tests/fixtures/boundaries/package-boundary-direction/modules/calendar/src/index.ts @@ -0,0 +1 @@ +export const calendar = "calendar"; diff --git a/tests/fixtures/boundaries/package-boundary-direction/surfaces/dashboard/package.json b/tests/fixtures/boundaries/package-boundary-direction/surfaces/dashboard/package.json new file mode 100644 index 0000000..1297491 --- /dev/null +++ b/tests/fixtures/boundaries/package-boundary-direction/surfaces/dashboard/package.json @@ -0,0 +1,8 @@ +{ + "name": "@ekklesia/surface-dashboard", + "version": "0.0.0", + "type": "module", + "exports": { + ".": "./src/index.ts" + } +} diff --git a/tests/fixtures/boundaries/package-boundary-direction/surfaces/dashboard/src/index.ts b/tests/fixtures/boundaries/package-boundary-direction/surfaces/dashboard/src/index.ts new file mode 100644 index 0000000..d3c613d --- /dev/null +++ b/tests/fixtures/boundaries/package-boundary-direction/surfaces/dashboard/src/index.ts @@ -0,0 +1 @@ +export const dashboard = "dashboard"; diff --git a/tests/fixtures/boundaries/package-cycle/packages/one/package.json b/tests/fixtures/boundaries/package-cycle/packages/one/package.json new file mode 100644 index 0000000..ff25c6e --- /dev/null +++ b/tests/fixtures/boundaries/package-cycle/packages/one/package.json @@ -0,0 +1,14 @@ +{ + "name": "@ekklesia/one", + "version": "0.0.0", + "type": "module", + "dependencies": { + "@ekklesia/two": "workspace:*" + }, + "exports": { + ".": "./src/index.ts" + }, + "ekklesia": { + "layer": "shared" + } +} diff --git a/tests/fixtures/boundaries/package-cycle/packages/one/src/index.ts b/tests/fixtures/boundaries/package-cycle/packages/one/src/index.ts new file mode 100644 index 0000000..d815786 --- /dev/null +++ b/tests/fixtures/boundaries/package-cycle/packages/one/src/index.ts @@ -0,0 +1 @@ +export const one = 1; diff --git a/tests/fixtures/boundaries/package-cycle/packages/two/package.json b/tests/fixtures/boundaries/package-cycle/packages/two/package.json new file mode 100644 index 0000000..73c671c --- /dev/null +++ b/tests/fixtures/boundaries/package-cycle/packages/two/package.json @@ -0,0 +1,14 @@ +{ + "name": "@ekklesia/two", + "version": "0.0.0", + "type": "module", + "dependencies": { + "@ekklesia/one": "workspace:*" + }, + "exports": { + ".": "./src/index.ts" + }, + "ekklesia": { + "layer": "shared" + } +} diff --git a/tests/fixtures/boundaries/package-cycle/packages/two/src/index.ts b/tests/fixtures/boundaries/package-cycle/packages/two/src/index.ts new file mode 100644 index 0000000..6a2308a --- /dev/null +++ b/tests/fixtures/boundaries/package-cycle/packages/two/src/index.ts @@ -0,0 +1 @@ +export const two = 2; diff --git a/tests/fixtures/boundaries/package-without-exports/core/package.json b/tests/fixtures/boundaries/package-without-exports/core/package.json new file mode 100644 index 0000000..302605c --- /dev/null +++ b/tests/fixtures/boundaries/package-without-exports/core/package.json @@ -0,0 +1,11 @@ +{ + "name": "@ekklesia/core", + "version": "0.0.0", + "type": "module", + "dependencies": { + "@ekklesia/contracts": "workspace:*" + }, + "exports": { + ".": "./src/index.ts" + } +} diff --git a/tests/fixtures/boundaries/package-without-exports/core/src/index.ts b/tests/fixtures/boundaries/package-without-exports/core/src/index.ts new file mode 100644 index 0000000..6ca2156 --- /dev/null +++ b/tests/fixtures/boundaries/package-without-exports/core/src/index.ts @@ -0,0 +1,3 @@ +import { contract } from "@ekklesia/contracts"; + +export const invalidImport = contract; diff --git a/tests/fixtures/boundaries/package-without-exports/packages/contracts/package.json b/tests/fixtures/boundaries/package-without-exports/packages/contracts/package.json new file mode 100644 index 0000000..251b82a --- /dev/null +++ b/tests/fixtures/boundaries/package-without-exports/packages/contracts/package.json @@ -0,0 +1,8 @@ +{ + "name": "@ekklesia/contracts", + "version": "0.0.0", + "type": "module", + "ekklesia": { + "layer": "contracts" + } +} diff --git a/tests/fixtures/boundaries/package-without-exports/packages/contracts/src/index.ts b/tests/fixtures/boundaries/package-without-exports/packages/contracts/src/index.ts new file mode 100644 index 0000000..774bcf0 --- /dev/null +++ b/tests/fixtures/boundaries/package-without-exports/packages/contracts/src/index.ts @@ -0,0 +1 @@ +export const contract = "contract"; diff --git a/tests/fixtures/boundaries/private-package-import/core/package.json b/tests/fixtures/boundaries/private-package-import/core/package.json new file mode 100644 index 0000000..302605c --- /dev/null +++ b/tests/fixtures/boundaries/private-package-import/core/package.json @@ -0,0 +1,11 @@ +{ + "name": "@ekklesia/core", + "version": "0.0.0", + "type": "module", + "dependencies": { + "@ekklesia/contracts": "workspace:*" + }, + "exports": { + ".": "./src/index.ts" + } +} diff --git a/tests/fixtures/boundaries/private-package-import/core/src/index.ts b/tests/fixtures/boundaries/private-package-import/core/src/index.ts new file mode 100644 index 0000000..1440e4b --- /dev/null +++ b/tests/fixtures/boundaries/private-package-import/core/src/index.ts @@ -0,0 +1,3 @@ +import { privateValue } from "@ekklesia/contracts/abcdzz"; + +export const invalidPrivateImport = privateValue; diff --git a/tests/fixtures/boundaries/private-package-import/packages/contracts/package.json b/tests/fixtures/boundaries/private-package-import/packages/contracts/package.json new file mode 100644 index 0000000..db34700 --- /dev/null +++ b/tests/fixtures/boundaries/private-package-import/packages/contracts/package.json @@ -0,0 +1,13 @@ +{ + "name": "@ekklesia/contracts", + "version": "0.0.0", + "type": "module", + "exports": { + ".": "./src/index.ts", + "./a*cdzz": "./src/index.ts", + "./ab*zz": null + }, + "ekklesia": { + "layer": "contracts" + } +} diff --git a/tests/fixtures/boundaries/private-package-import/packages/contracts/src/index.ts b/tests/fixtures/boundaries/private-package-import/packages/contracts/src/index.ts new file mode 100644 index 0000000..b643c9c --- /dev/null +++ b/tests/fixtures/boundaries/private-package-import/packages/contracts/src/index.ts @@ -0,0 +1 @@ +export const privateValue = "private"; diff --git a/tests/fixtures/boundaries/sdk-imports-core/core/src/index.ts b/tests/fixtures/boundaries/sdk-imports-core/core/src/index.ts new file mode 100644 index 0000000..c86d8ee --- /dev/null +++ b/tests/fixtures/boundaries/sdk-imports-core/core/src/index.ts @@ -0,0 +1 @@ +export const core = "core"; diff --git a/tests/fixtures/boundaries/sdk-imports-core/packages/module-sdk/src/index.ts b/tests/fixtures/boundaries/sdk-imports-core/packages/module-sdk/src/index.ts new file mode 100644 index 0000000..4980bc1 --- /dev/null +++ b/tests/fixtures/boundaries/sdk-imports-core/packages/module-sdk/src/index.ts @@ -0,0 +1,3 @@ +import { core } from "../../../core/src/index.js"; + +export const invalidSdk = core; diff --git a/tests/fixtures/boundaries/shared-role-order/packages/contracts/src/index.ts b/tests/fixtures/boundaries/shared-role-order/packages/contracts/src/index.ts new file mode 100644 index 0000000..15606f4 --- /dev/null +++ b/tests/fixtures/boundaries/shared-role-order/packages/contracts/src/index.ts @@ -0,0 +1,3 @@ +import { moduleSdk } from "../../module-sdk/src/index.js"; + +export const invalidContractDependency = moduleSdk; diff --git a/tests/fixtures/boundaries/shared-role-order/packages/module-sdk/src/index.ts b/tests/fixtures/boundaries/shared-role-order/packages/module-sdk/src/index.ts new file mode 100644 index 0000000..03cc37a --- /dev/null +++ b/tests/fixtures/boundaries/shared-role-order/packages/module-sdk/src/index.ts @@ -0,0 +1,3 @@ +import { surfaceSdk } from "../../surface-sdk/src/index.js"; + +export const moduleSdk = surfaceSdk; diff --git a/tests/fixtures/boundaries/shared-role-order/packages/surface-sdk/src/index.ts b/tests/fixtures/boundaries/shared-role-order/packages/surface-sdk/src/index.ts new file mode 100644 index 0000000..9be0157 --- /dev/null +++ b/tests/fixtures/boundaries/shared-role-order/packages/surface-sdk/src/index.ts @@ -0,0 +1 @@ +export const surfaceSdk = "surface-sdk"; diff --git a/tests/fixtures/boundaries/surface-imports-module/modules/calendar/src/index.ts b/tests/fixtures/boundaries/surface-imports-module/modules/calendar/src/index.ts new file mode 100644 index 0000000..217b229 --- /dev/null +++ b/tests/fixtures/boundaries/surface-imports-module/modules/calendar/src/index.ts @@ -0,0 +1 @@ +export const calendar = "calendar"; diff --git a/tests/fixtures/boundaries/surface-imports-module/surfaces/dashboard/src/index.ts b/tests/fixtures/boundaries/surface-imports-module/surfaces/dashboard/src/index.ts new file mode 100644 index 0000000..979861e --- /dev/null +++ b/tests/fixtures/boundaries/surface-imports-module/surfaces/dashboard/src/index.ts @@ -0,0 +1,3 @@ +import { calendar } from "../../../modules/calendar/src/index.js"; + +export const invalidSurfaceFeature = calendar; diff --git a/tests/fixtures/boundaries/undeclared-workspace-dependency/packages/consumer/package.json b/tests/fixtures/boundaries/undeclared-workspace-dependency/packages/consumer/package.json new file mode 100644 index 0000000..777e3d9 --- /dev/null +++ b/tests/fixtures/boundaries/undeclared-workspace-dependency/packages/consumer/package.json @@ -0,0 +1,11 @@ +{ + "name": "@ekklesia/consumer", + "version": "0.0.0", + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "ekklesia": { + "layer": "shared" + } +} diff --git a/tests/fixtures/boundaries/undeclared-workspace-dependency/packages/consumer/src/index.ts b/tests/fixtures/boundaries/undeclared-workspace-dependency/packages/consumer/src/index.ts new file mode 100644 index 0000000..99f30cd --- /dev/null +++ b/tests/fixtures/boundaries/undeclared-workspace-dependency/packages/consumer/src/index.ts @@ -0,0 +1,3 @@ +import { contract } from "@ekklesia/contracts"; + +export const invalidUndeclaredDependency = contract; diff --git a/tests/fixtures/boundaries/undeclared-workspace-dependency/packages/contracts/package.json b/tests/fixtures/boundaries/undeclared-workspace-dependency/packages/contracts/package.json new file mode 100644 index 0000000..2890856 --- /dev/null +++ b/tests/fixtures/boundaries/undeclared-workspace-dependency/packages/contracts/package.json @@ -0,0 +1,11 @@ +{ + "name": "@ekklesia/contracts", + "version": "0.0.0", + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "ekklesia": { + "layer": "contracts" + } +} diff --git a/tests/fixtures/boundaries/undeclared-workspace-dependency/packages/contracts/src/index.ts b/tests/fixtures/boundaries/undeclared-workspace-dependency/packages/contracts/src/index.ts new file mode 100644 index 0000000..774bcf0 --- /dev/null +++ b/tests/fixtures/boundaries/undeclared-workspace-dependency/packages/contracts/src/index.ts @@ -0,0 +1 @@ +export const contract = "contract"; diff --git a/tests/fixtures/boundaries/unknown-shared-role/packages/mystery/package.json b/tests/fixtures/boundaries/unknown-shared-role/packages/mystery/package.json new file mode 100644 index 0000000..c72b662 --- /dev/null +++ b/tests/fixtures/boundaries/unknown-shared-role/packages/mystery/package.json @@ -0,0 +1,11 @@ +{ + "name": "@ekklesia/mystery", + "version": "0.0.0", + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "ekklesia": { + "layer": "mystery" + } +} diff --git a/tests/fixtures/boundaries/unknown-shared-role/packages/mystery/src/index.ts b/tests/fixtures/boundaries/unknown-shared-role/packages/mystery/src/index.ts new file mode 100644 index 0000000..3794bb0 --- /dev/null +++ b/tests/fixtures/boundaries/unknown-shared-role/packages/mystery/src/index.ts @@ -0,0 +1 @@ +export const mystery = "mystery"; diff --git a/tests/fixtures/boundaries/workspace-alias/modules/calendar/package.json b/tests/fixtures/boundaries/workspace-alias/modules/calendar/package.json new file mode 100644 index 0000000..3b5df18 --- /dev/null +++ b/tests/fixtures/boundaries/workspace-alias/modules/calendar/package.json @@ -0,0 +1,12 @@ +{ + "name": "@ekklesia/module-calendar", + "version": "0.0.0", + "type": "module", + "dependencies": { + "@ekklesia/contracts": "workspace:@ekklesia/surface-dashboard@*", + "dashboard-data": "workspace:@ekklesia/surface-dashboard@*" + }, + "exports": { + ".": "./src/index.ts" + } +} diff --git a/tests/fixtures/boundaries/workspace-alias/modules/calendar/src/index.ts b/tests/fixtures/boundaries/workspace-alias/modules/calendar/src/index.ts new file mode 100644 index 0000000..3d927ea --- /dev/null +++ b/tests/fixtures/boundaries/workspace-alias/modules/calendar/src/index.ts @@ -0,0 +1,4 @@ +import { dashboard } from "dashboard-data"; +import { hiddenDashboard } from "@ekklesia/contracts"; + +export const invalidAlias = [dashboard, hiddenDashboard]; diff --git a/tests/fixtures/boundaries/workspace-alias/packages/contracts/package.json b/tests/fixtures/boundaries/workspace-alias/packages/contracts/package.json new file mode 100644 index 0000000..c8887d5 --- /dev/null +++ b/tests/fixtures/boundaries/workspace-alias/packages/contracts/package.json @@ -0,0 +1,11 @@ +{ + "name": "@ekklesia/contracts", + "version": "0.0.0", + "type": "module", + "ekklesia": { + "layer": "contracts" + }, + "exports": { + ".": "./src/index.ts" + } +} diff --git a/tests/fixtures/boundaries/workspace-alias/packages/contracts/src/index.ts b/tests/fixtures/boundaries/workspace-alias/packages/contracts/src/index.ts new file mode 100644 index 0000000..7652267 --- /dev/null +++ b/tests/fixtures/boundaries/workspace-alias/packages/contracts/src/index.ts @@ -0,0 +1 @@ +export const hiddenDashboard = "contracts"; diff --git a/tests/fixtures/boundaries/workspace-alias/surfaces/dashboard/package.json b/tests/fixtures/boundaries/workspace-alias/surfaces/dashboard/package.json new file mode 100644 index 0000000..1297491 --- /dev/null +++ b/tests/fixtures/boundaries/workspace-alias/surfaces/dashboard/package.json @@ -0,0 +1,8 @@ +{ + "name": "@ekklesia/surface-dashboard", + "version": "0.0.0", + "type": "module", + "exports": { + ".": "./src/index.ts" + } +} diff --git a/tests/fixtures/boundaries/workspace-alias/surfaces/dashboard/src/index.ts b/tests/fixtures/boundaries/workspace-alias/surfaces/dashboard/src/index.ts new file mode 100644 index 0000000..d3c613d --- /dev/null +++ b/tests/fixtures/boundaries/workspace-alias/surfaces/dashboard/src/index.ts @@ -0,0 +1 @@ +export const dashboard = "dashboard"; diff --git a/tests/fixtures/boundaries/workspace-exclusion/packages/excluded/package.json b/tests/fixtures/boundaries/workspace-exclusion/packages/excluded/package.json new file mode 100644 index 0000000..8fd829b --- /dev/null +++ b/tests/fixtures/boundaries/workspace-exclusion/packages/excluded/package.json @@ -0,0 +1,5 @@ +{ + "name": "@ekklesia/included", + "version": "0.0.0", + "type": "module" +} diff --git a/tests/fixtures/boundaries/workspace-exclusion/packages/excluded/src/index.ts b/tests/fixtures/boundaries/workspace-exclusion/packages/excluded/src/index.ts new file mode 100644 index 0000000..6965610 --- /dev/null +++ b/tests/fixtures/boundaries/workspace-exclusion/packages/excluded/src/index.ts @@ -0,0 +1 @@ +export const excluded = true; diff --git a/tests/fixtures/boundaries/workspace-exclusion/packages/included/package.json b/tests/fixtures/boundaries/workspace-exclusion/packages/included/package.json new file mode 100644 index 0000000..ffd3923 --- /dev/null +++ b/tests/fixtures/boundaries/workspace-exclusion/packages/included/package.json @@ -0,0 +1,11 @@ +{ + "name": "@ekklesia/included", + "version": "0.0.0", + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "ekklesia": { + "layer": "tooling" + } +} diff --git a/tests/fixtures/boundaries/workspace-exclusion/packages/included/src/index.ts b/tests/fixtures/boundaries/workspace-exclusion/packages/included/src/index.ts new file mode 100644 index 0000000..1091730 --- /dev/null +++ b/tests/fixtures/boundaries/workspace-exclusion/packages/included/src/index.ts @@ -0,0 +1 @@ +export const included = true; diff --git a/tests/fixtures/boundaries/workspace-exclusion/pnpm-workspace.yaml b/tests/fixtures/boundaries/workspace-exclusion/pnpm-workspace.yaml new file mode 100644 index 0000000..c0d5275 --- /dev/null +++ b/tests/fixtures/boundaries/workspace-exclusion/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +packages: + - "packages/*" + - "!packages/excluded" diff --git a/tests/fixtures/boundaries/workspace-unsupported-pattern/packages/example/package.json b/tests/fixtures/boundaries/workspace-unsupported-pattern/packages/example/package.json new file mode 100644 index 0000000..c84c819 --- /dev/null +++ b/tests/fixtures/boundaries/workspace-unsupported-pattern/packages/example/package.json @@ -0,0 +1,11 @@ +{ + "name": "@ekklesia/example", + "version": "0.0.0", + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "ekklesia": { + "layer": "tooling" + } +} diff --git a/tests/fixtures/boundaries/workspace-unsupported-pattern/packages/example/src/index.ts b/tests/fixtures/boundaries/workspace-unsupported-pattern/packages/example/src/index.ts new file mode 100644 index 0000000..5ae63e0 --- /dev/null +++ b/tests/fixtures/boundaries/workspace-unsupported-pattern/packages/example/src/index.ts @@ -0,0 +1 @@ +export const example = true; diff --git a/tests/fixtures/boundaries/workspace-unsupported-pattern/pnpm-workspace.yaml b/tests/fixtures/boundaries/workspace-unsupported-pattern/pnpm-workspace.yaml new file mode 100644 index 0000000..6a0b001 --- /dev/null +++ b/tests/fixtures/boundaries/workspace-unsupported-pattern/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - "packages/{example,other}" diff --git a/tests/infrastructure.test.ts b/tests/infrastructure.test.ts index 6fae4d9..c333735 100644 --- a/tests/infrastructure.test.ts +++ b/tests/infrastructure.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it } from "vitest"; import { parse } from "yaml"; interface ComposeService { + environment?: Record; healthcheck?: unknown; image?: string; ports?: string[]; @@ -16,6 +17,18 @@ interface ComposeDocument { volumes?: Record; } +interface PackageDocument { + scripts?: Record; +} + +interface TurboDocument { + tasks?: Record; +} + +interface WorkspaceDocument { + packages?: string[]; +} + const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); describe("development infrastructure", () => { @@ -32,6 +45,7 @@ describe("development infrastructure", () => { expect(objectStorage?.image).toBe("chrislusf/seaweedfs:4.28"); expect(postgres?.healthcheck).toBeDefined(); expect(objectStorage?.healthcheck).toBeDefined(); + expect(postgres?.environment?.POSTGRES_INITDB_ARGS).toContain("--auth-host=scram-sha-256"); for (const service of [postgres, objectStorage]) { expect(service).toBeDefined(); @@ -44,18 +58,47 @@ describe("development infrastructure", () => { expect(compose.volumes).toHaveProperty("object-storage-data"); }); - it("has a parseable CI workflow with a single repository gate", async () => { + it("has a parseable CI workflow with static and live infrastructure gates", async () => { const source = await readFile( path.join(repositoryRoot, ".github", "workflows", "ci.yml"), "utf8", ); const workflow = parse(source) as { - jobs?: { verify?: { steps?: Array<{ run?: string }> } }; + jobs?: { + "infrastructure-smoke"?: { steps?: Array<{ run?: string }> }; + verify?: { steps?: Array<{ run?: string }> }; + }; }; const commands = workflow.jobs?.verify?.steps?.flatMap((step) => step.run ?? []) ?? []; + const infrastructureCommands = + workflow.jobs?.["infrastructure-smoke"]?.steps?.flatMap((step) => step.run ?? []) ?? []; expect(workflow.jobs?.verify).toBeDefined(); expect(commands).toContain("pnpm install --frozen-lockfile"); expect(commands).toContain("pnpm ci"); + expect(infrastructureCommands).toContain("bash scripts/smoke-infrastructure.sh"); + }); + + it("includes every official platform layer in the workspace", async () => { + const source = await readFile(path.join(repositoryRoot, "pnpm-workspace.yaml"), "utf8"); + const workspace = parse(source) as WorkspaceDocument; + + expect(workspace.packages).toEqual( + expect.arrayContaining(["apps/*", "builders/*", "core", "modules/*", "surfaces/*"]), + ); + }); + + it("dispatches root and future package quality tasks through Turborepo", async () => { + const packageSource = await readFile(path.join(repositoryRoot, "package.json"), "utf8"); + const turboSource = await readFile(path.join(repositoryRoot, "turbo.json"), "utf8"); + const packageDocument = JSON.parse(packageSource) as PackageDocument; + const turboDocument = JSON.parse(turboSource) as TurboDocument; + + expect(packageDocument.scripts?.lint).toBe("turbo run lint lint:root"); + expect(packageDocument.scripts?.test).toBe("turbo run test test:root"); + expect(packageDocument.scripts?.typecheck).toBe("turbo run typecheck typecheck:root"); + expect(turboDocument.tasks).toHaveProperty("//#lint:root"); + expect(turboDocument.tasks).toHaveProperty("//#test:root"); + expect(turboDocument.tasks).toHaveProperty("//#typecheck:root"); }); }); diff --git a/turbo.json b/turbo.json index 3258c82..f19b605 100644 --- a/turbo.json +++ b/turbo.json @@ -1,5 +1,6 @@ { "$schema": "https://turbo.build/schema.json", + "globalDependencies": ["biome.json", "vitest.config.ts"], "ui": "stream", "tasks": { "build": { @@ -7,16 +8,24 @@ "outputs": ["dist/**", ".next/**", "!.next/cache/**"] }, "lint": { - "dependsOn": ["^lint"], "outputs": [] }, "test": { - "dependsOn": ["build"], - "outputs": ["coverage/**"] + "dependsOn": ["^build"], + "outputs": [] }, "typecheck": { "dependsOn": ["^typecheck"], "outputs": [] + }, + "//#lint:root": { + "outputs": [] + }, + "//#test:root": { + "outputs": [] + }, + "//#typecheck:root": { + "outputs": [] } } }