From 192a6514cf9fb43d2ece9f7119a2fd4a65440679 Mon Sep 17 00:00:00 2001 From: ekklesia Date: Sun, 12 Jul 2026 20:28:19 -0500 Subject: [PATCH] chore: establish Ekklesia platform foundation --- .editorconfig | 12 + .env.example | 14 + .gitattributes | 8 + .github/workflows/ci.yml | 38 + .gitignore | 15 + .node-version | 1 + .npmrc | 5 + .nvmrc | 1 + CONTRIBUTING.md | 32 + README.md | 41 + biome.json | 25 + .../0001-four-layer-platform-boundary.md | 34 + .../0002-pnpm-workspace-and-typescript.md | 36 + .../0003-development-infrastructure.md | 36 + .../0004-replace-unmaintained-minio.md | 40 + docs/architecture/decisions/README.md | 15 + docs/architecture/platform-architecture.md | 66 + docs/architecture/repository-boundaries.md | 40 + docs/development/repository-workflow.md | 53 + infrastructure/compose.yaml | 46 + package.json | 36 + packages/typescript-config/base.json | 19 + packages/typescript-config/browser.json | 11 + packages/typescript-config/node.json | 11 + packages/typescript-config/package.json | 20 + packages/typescript-config/react-native.json | 11 + pnpm-lock.yaml | 1155 +++++++++++++++++ pnpm-workspace.yaml | 10 + scripts/check-boundaries.ts | 498 +++++++ tests/boundaries.test.ts | 32 + .../core-imports-module/core/src/index.ts | 3 + .../modules/calendar/src/index.ts | 1 + .../modules/calendar/src/index.ts | 3 + .../modules/media/src/index.ts | 1 + tests/fixtures/boundaries/cycle/core/src/a.ts | 3 + tests/fixtures/boundaries/cycle/core/src/b.ts | 3 + .../boundaries/legal/core/src/index.ts | 3 + .../legal/packages/contracts/package.json | 11 + .../legal/packages/contracts/src/index.ts | 3 + .../modules/calendar/src/calendar.css | 3 + .../modules/calendar/src/calendar.tsx | 3 + .../modules/calendar/src/index.ts | 3 + .../surfaces/dashboard/src/calendar-view.ts | 1 + tests/infrastructure.test.ts | 61 + tsconfig.json | 8 + turbo.json | 22 + vitest.config.ts | 12 + 47 files changed, 2505 insertions(+) create mode 100644 .editorconfig create mode 100644 .env.example create mode 100644 .gitattributes create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 .node-version create mode 100644 .npmrc create mode 100644 .nvmrc create mode 100644 CONTRIBUTING.md create mode 100644 README.md create mode 100644 biome.json create mode 100644 docs/architecture/decisions/0001-four-layer-platform-boundary.md create mode 100644 docs/architecture/decisions/0002-pnpm-workspace-and-typescript.md create mode 100644 docs/architecture/decisions/0003-development-infrastructure.md create mode 100644 docs/architecture/decisions/0004-replace-unmaintained-minio.md create mode 100644 docs/architecture/decisions/README.md create mode 100644 docs/architecture/platform-architecture.md create mode 100644 docs/architecture/repository-boundaries.md create mode 100644 docs/development/repository-workflow.md create mode 100644 infrastructure/compose.yaml create mode 100644 package.json create mode 100644 packages/typescript-config/base.json create mode 100644 packages/typescript-config/browser.json create mode 100644 packages/typescript-config/node.json create mode 100644 packages/typescript-config/package.json create mode 100644 packages/typescript-config/react-native.json create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 scripts/check-boundaries.ts create mode 100644 tests/boundaries.test.ts create mode 100644 tests/fixtures/boundaries/core-imports-module/core/src/index.ts create mode 100644 tests/fixtures/boundaries/core-imports-module/modules/calendar/src/index.ts create mode 100644 tests/fixtures/boundaries/cross-module-import/modules/calendar/src/index.ts create mode 100644 tests/fixtures/boundaries/cross-module-import/modules/media/src/index.ts create mode 100644 tests/fixtures/boundaries/cycle/core/src/a.ts create mode 100644 tests/fixtures/boundaries/cycle/core/src/b.ts create mode 100644 tests/fixtures/boundaries/legal/core/src/index.ts create mode 100644 tests/fixtures/boundaries/legal/packages/contracts/package.json create mode 100644 tests/fixtures/boundaries/legal/packages/contracts/src/index.ts create mode 100644 tests/fixtures/boundaries/module-has-ui/modules/calendar/src/calendar.css create mode 100644 tests/fixtures/boundaries/module-has-ui/modules/calendar/src/calendar.tsx create mode 100644 tests/fixtures/boundaries/module-imports-surface/modules/calendar/src/index.ts create mode 100644 tests/fixtures/boundaries/module-imports-surface/surfaces/dashboard/src/calendar-view.ts create mode 100644 tests/infrastructure.test.ts create mode 100644 tsconfig.json create mode 100644 turbo.json create mode 100644 vitest.config.ts diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..1014ba7 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 2 +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..4880e03 --- /dev/null +++ b/.env.example @@ -0,0 +1,14 @@ +# Local development only. Replace every credential outside a developer workstation. +POSTGRES_DB=ekklesia +POSTGRES_USER=ekklesia +POSTGRES_PASSWORD=ekklesia-development-only +POSTGRES_PORT=5432 +DATABASE_URL=postgresql://ekklesia:ekklesia-development-only@127.0.0.1:5432/ekklesia + +S3_ACCESS_KEY=ekklesia-development +S3_SECRET_KEY=ekklesia-development-secret +S3_BUCKET=ekklesia-development +S3_ENDPOINT=http://127.0.0.1:8333 +S3_PORT=8333 +S3_REGION=us-east-1 +S3_FORCE_PATH_STYLE=true diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..0790517 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,8 @@ +* text=auto eol=lf + +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.webp binary +*.woff2 binary diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c15ce74 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,38 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + verify: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Check out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Set up Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version-file: .node-version + + - name: Activate pinned pnpm + run: | + corepack enable + corepack prepare pnpm@11.12.0 --activate + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Verify repository + run: pnpm ci diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a7bb05f --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +node_modules/ +.turbo/ +coverage/ +dist/ +.next/ + +.env +.env.* +!.env.example + +generated/* +!generated/.gitkeep + +*.log +.DS_Store diff --git a/.node-version b/.node-version new file mode 100644 index 0000000..ca5c350 --- /dev/null +++ b/.node-version @@ -0,0 +1 @@ +24.18.0 diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..7cb6e17 --- /dev/null +++ b/.npmrc @@ -0,0 +1,5 @@ +engine-strict=true +save-exact=true +save-workspace-protocol=rolling +shared-workspace-lockfile=true +strict-peer-dependencies=true diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..ca5c350 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +24.18.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..7e4f905 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,32 @@ +# Contributing + +Ekklesia is being built in verified, reviewable phases. Changes should preserve the dependency +boundaries described in [repository-boundaries.md](docs/architecture/repository-boundaries.md). + +Before opening a change, run: + +```bash +pnpm verify +``` + +If Docker is available, also validate the development infrastructure: + +```bash +pnpm infrastructure:config +``` + +## Package rules + +- Use strict TypeScript for production packages. +- Expose supported entry points through package `exports`; do not import another package's + private source paths. +- Use `workspace:` ranges for internal package dependencies. +- Keep modules backend-only. Module packages cannot contain React, mobile, stylesheet, or other + UI source files. +- Put rendering, layout, navigation, accessibility, and all other presentation concerns in a + surface. +- Add or update tests when changing a contract or architectural rule. +- Record major architectural changes in an ADR rather than burying them in an implementation. + +Generated registries and schemas will be produced by later phases. Do not add a manually +maintained feature-module registry. diff --git a/README.md b/README.md new file mode 100644 index 0000000..d6b54e4 --- /dev/null +++ b/README.md @@ -0,0 +1,41 @@ +# Ekklesia + +Ekklesia is an open, modular church technology platform for composing backend capabilities +into independently developed dashboards, websites, mobile apps, kiosks, displays, and other +surfaces. + +## Current status + +Phase 1 establishes the repository foundation only. It includes reproducible workspace +tooling, strict TypeScript defaults, executable architectural boundary checks, tests, CI, and +local PostgreSQL/S3-compatible infrastructure. Core runtime, module discovery, identity, +Module SDK, surfaces, and feature modules intentionally begin in later phases. + +## Quick start + +Prerequisites: Node.js 24 LTS, pnpm 11.12, and Docker with Compose. + +```bash +pnpm install --frozen-lockfile +cp .env.example .env +pnpm infrastructure:up +pnpm verify +``` + +The development services bind only to loopback: + +- PostgreSQL: `127.0.0.1:5432` +- S3-compatible object storage: `127.0.0.1:8333` + +The values in `.env.example` are development-only and must never be reused in a deployed +environment. + +## Architecture + +- [Platform architecture](docs/architecture/platform-architecture.md) +- [Repository boundaries](docs/architecture/repository-boundaries.md) +- [Repository workflow](docs/development/repository-workflow.md) +- [Architecture decisions](docs/architecture/decisions/README.md) + +No open-source license has been selected yet. A license should be added only after the project +owner makes that decision. diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..a37e905 --- /dev/null +++ b/biome.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.5.3/schema.json", + "files": { + "includes": ["**", "!tests/fixtures", "!pnpm-lock.yaml"] + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 100 + }, + "linter": { + "enabled": true, + "rules": { + "preset": "recommended" + } + }, + "javascript": { + "formatter": { + "quoteStyle": "double", + "semicolons": "always", + "trailingCommas": "all" + } + } +} diff --git a/docs/architecture/decisions/0001-four-layer-platform-boundary.md b/docs/architecture/decisions/0001-four-layer-platform-boundary.md new file mode 100644 index 0000000..3ec35ce --- /dev/null +++ b/docs/architecture/decisions/0001-four-layer-platform-boundary.md @@ -0,0 +1,34 @@ +# 0001: Four-layer platform boundary + +- Status: Accepted +- Date: 2026-07-12 + +## Context + +Ekklesia must remain understandable and independently extensible after many backend capabilities +and user experiences have been added. A conventional feature monolith or UI-bearing plugin model +would couple business behavior to a particular presentation framework. + +## Decision + +Adopt four explicit ownership layers: Core orchestration, backend Modules, presentation Surfaces, +and declarative Surface Builder configuration. Begin as a modular monolith. Modules expose data +and behavior only; every renderer and UI concern belongs to a surface. + +## Alternatives + +- A conventional feature monolith was rejected because boundaries degrade as features grow. +- UI-bearing plugins were rejected because they bind capabilities to frameworks and visual design. +- Initial microservices were rejected because distribution adds failure modes before contracts + have been proven. + +## Consequences + +Stable contracts, SDK ports, generated composition, and automated boundary checks are required. +Some integrations take more deliberate service or event design, but modules and surfaces remain +independently replaceable. + +## Migration implications + +Future boundary violations must move behind public contracts before the affected package can be +treated as independently installable or upgradeable. diff --git a/docs/architecture/decisions/0002-pnpm-workspace-and-typescript.md b/docs/architecture/decisions/0002-pnpm-workspace-and-typescript.md new file mode 100644 index 0000000..371b24d --- /dev/null +++ b/docs/architecture/decisions/0002-pnpm-workspace-and-typescript.md @@ -0,0 +1,36 @@ +# 0002: pnpm workspace and strict TypeScript + +- Status: Accepted +- Date: 2026-07-12 + +## Context + +The platform needs atomic cross-package changes, reproducible installs, independent package +builds, and guardrails that do not force every surface onto one framework. + +## Decision + +Use a pnpm workspace with one committed lockfile and `workspace:` ranges for internal packages. +Use Turborepo for task ordering and local caching. Standardize production packages on strict +TypeScript and native ESM, with separate Node, browser, and React Native base configurations. Use +Biome and Vitest for the initial repository quality gates. + +Node 24 LTS and pnpm 11.12 are pinned for Phase 1. Package exports are the supported cross-package +API; aliases may not expose another package's private `src` tree. + +## Alternatives + +Polyrepos would slow coordinated contract changes. npm and Yarn workspaces are viable but do not +match the selected pnpm workflow. A universal browser-oriented TypeScript configuration was +rejected because server and native packages have different resolution and library requirements. + +## Consequences + +Repository-wide changes are straightforward, while package manifests and automated checks must +prevent monorepo convenience from becoming hidden coupling. Remote cache and independent-release +automation are deferred until they solve an observed need. + +## Migration implications + +Packages can move to separate repositories later because consumers use package names and public +exports instead of filesystem aliases. diff --git a/docs/architecture/decisions/0003-development-infrastructure.md b/docs/architecture/decisions/0003-development-infrastructure.md new file mode 100644 index 0000000..35cbe0b --- /dev/null +++ b/docs/architecture/decisions/0003-development-infrastructure.md @@ -0,0 +1,36 @@ +# 0003: Development infrastructure + +- Status: Accepted +- Date: 2026-07-12 + +## Context + +Developers need reproducible PostgreSQL and object-storage services without requiring host-level +database installation. Application source does not yet exist, so containerizing the whole +toolchain would add friction without value. + +## Decision + +Run PostgreSQL 18.4 and an S3-compatible object store in Docker Compose. Pin reviewed image +versions, enable health checks, use persistent named volumes, bind host ports to loopback, and use +explicitly development-only credentials. Run Node applications on the host during normal +development. + +This Compose file is a development baseline, not the production deployment topology. + +## Alternatives + +Locally installed services make onboarding inconsistent. Containerizing editors and the complete +Node toolchain is unnecessary at this phase. Kubernetes is premature for a modular monolith +foundation. + +## Consequences + +Docker is required for infrastructure testing but not for code-only validation. Production +backups, high availability, secret injection, image digests, and deployment topology require a +later decision. + +## Migration implications + +The future Core storage service targets S3 behavior rather than a provider-specific client, so +development and production providers can change after contract testing. diff --git a/docs/architecture/decisions/0004-replace-unmaintained-minio.md b/docs/architecture/decisions/0004-replace-unmaintained-minio.md new file mode 100644 index 0000000..cac87a5 --- /dev/null +++ b/docs/architecture/decisions/0004-replace-unmaintained-minio.md @@ -0,0 +1,40 @@ +# 0004: Replace unmaintained MinIO baseline + +- Status: Accepted +- Date: 2026-07-12 + +## Context + +The original Ekklesia brief selected MinIO as the default self-hosted S3-compatible service. The +upstream `minio/minio` repository was archived on April 25, 2026 and states that historical +community binary releases are no longer maintained. New church deployments may store highly +sensitive content, so beginning on an unmaintained security-sensitive service is unacceptable. + +## Decision + +Keep Ekklesia's future storage contract provider-neutral and use SeaweedFS 4.28 in the Phase 1 +development Compose environment. SeaweedFS is actively maintained and offers an S3-compatible +endpoint and a small single-node development mode. + +Before treating any provider as production-ready, add storage contract tests covering put, get, +head, delete, ranges, multipart uploads, metadata/content type, signed URLs, bucket and prefix +isolation, and denied access. A production provider decision remains separate. + +## Alternatives + +- Retaining the final MinIO community image was rejected because it no longer receives upstream + maintenance. +- Provider-specific Core APIs were rejected because they would couple every module to this choice. +- Deferring all object storage was rejected because a reproducible S3 development endpoint is part + of the repository foundation. + +## Consequences + +The implementation intentionally uses `S3_*` configuration rather than `MINIO_*` names. Some +SeaweedFS behavior may differ from AWS S3 or historical MinIO and must be captured by contract +tests before the storage abstraction is implemented. + +## Migration implications + +No product data exists in Phase 1. Later provider changes must follow an object-copy and integrity +verification plan while preserving stable Core storage contracts. diff --git a/docs/architecture/decisions/README.md b/docs/architecture/decisions/README.md new file mode 100644 index 0000000..57332bd --- /dev/null +++ b/docs/architecture/decisions/README.md @@ -0,0 +1,15 @@ +# Architecture decision records + +ADRs capture durable choices that affect more than one package or phase. + +- Filenames use a four-digit sequence followed by a short kebab-case title. +- Status is `Proposed`, `Accepted`, `Superseded`, or `Rejected`. +- Accepted ADRs are immutable except for status and cross-reference corrections. +- A changed decision receives a new ADR that links to and supersedes the old one. + +## Index + +- [0001: Four-layer platform boundary](0001-four-layer-platform-boundary.md) — Accepted +- [0002: pnpm workspace and strict TypeScript](0002-pnpm-workspace-and-typescript.md) — Accepted +- [0003: Development infrastructure](0003-development-infrastructure.md) — Accepted +- [0004: Replace unmaintained MinIO baseline](0004-replace-unmaintained-minio.md) — Accepted diff --git a/docs/architecture/platform-architecture.md b/docs/architecture/platform-architecture.md new file mode 100644 index 0000000..6c2d1d6 --- /dev/null +++ b/docs/architecture/platform-architecture.md @@ -0,0 +1,66 @@ +# Platform architecture + +Ekklesia is a long-lived platform from which churches can compose many applications and user +experiences. It begins as a modular monolith so contracts can stabilize before distribution is +introduced. + +## Dependency model + +The arrows below mean "depends on." Presentation and composition depend on stable lower-level +contracts; infrastructure never learns individual feature names. + +```text +Surface Builder configuration -> Surfaces -> public capability contracts + ^ + | + HTTP/API and client contracts + | +Feature Modules -> Module SDK / platform contracts <- Core implementations +``` + +The four ownership layers are: + +1. **Core** orchestrates generic platform infrastructure: startup, identity, permissions, + configuration, database connectivity, storage, events, jobs, module discovery, and lifecycle. +2. **Modules** own backend domain behavior, data schemas, resources, actions, routes, events, + permissions, and jobs. +3. **Surfaces** own every presentation concern: components, renderers, layout, navigation, + accessibility, interaction, branding, and client state. +4. **Surface Builder** stores validated declarative composition. It selects capabilities and + surface-owned renderers but contains neither executable code nor module business logic. + +Modules do not own UI. For example, Calendar data, actions, and permissions will live in +`modules/calendar`; Calendar renderers for a dashboard or public website will live in those +surfaces. + +## Stable seams + +- Modules use public SDK ports and injected Core services, never undocumented Core internals. +- Modules do not import another module's schema or private code. Integrations use public service + contracts, capabilities, events, or APIs. +- Surfaces consume API/client and capability contracts, never feature-module packages. +- Core discovers feature modules generically. It contains no feature-specific imports or + conditional branches. +- Deployable apps are thin composition roots and do not become domain-logic owners. +- Generated bootstrap artifacts may connect discovered entry points at the application edge; + they do not become manually maintained Core registries. + +## Security invariants + +- Authorization is enforced server-side. +- Secrets are isolated from public configuration and logs. +- Authentication tokens, private form content, prayer requests, and pastoral information are not + logged. +- Sensitive actions are designed for auditability without copying sensitive content into audit + records. +- Private objects use signed access and namespaced storage paths. +- Every network service starts from least-privilege and local-only development defaults. + +## Phase 1 status + +Implemented now: monorepo/tooling, strict TypeScript policy, repository-boundary validation, +tests, base CI, PostgreSQL, and a development S3-compatible object store. + +Target architecture, not yet implemented: Core runtime, module discovery and lifecycle, Drizzle +schema aggregation/migrations, Better Auth, permissions, storage service, Module SDK, Surface +SDK, capability/resource/action registries, API server, dashboard, Builder, and reference modules. diff --git a/docs/architecture/repository-boundaries.md b/docs/architecture/repository-boundaries.md new file mode 100644 index 0000000..7759f50 --- /dev/null +++ b/docs/architecture/repository-boundaries.md @@ -0,0 +1,40 @@ +# Repository boundaries + +These rules map platform ownership to filesystem and package boundaries. The executable check is +`pnpm boundaries`; CI must reject violations. + +| Area | Owns | May depend on | +| --- | --- | --- | +| `apps/*` | Thin deployable composition roots | Public Core APIs, generated bootstrap, or a surface as required by the app | +| `core` | Feature-agnostic platform implementations | Public contracts and external infrastructure libraries | +| `modules/*` | Backend domain capability and data | Module SDK, public contracts, and external backend libraries | +| `surfaces/*` | UI, renderers, navigation, and interaction | Surface SDK, API/auth clients, public contracts, and design packages | +| `builders/*` | Declarative surface composition tooling | Builder/surface SDKs and public contracts | +| `packages/*` | Explicitly scoped SDKs, contracts, clients, design, testing, or tooling | Only dependencies appropriate to the package's declared level | +| `templates/module` | A small example backend module | The same dependencies allowed for any module | + +## Mandatory rules + +- 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 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. +- 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. + +## 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. diff --git a/docs/development/repository-workflow.md b/docs/development/repository-workflow.md new file mode 100644 index 0000000..a14c215 --- /dev/null +++ b/docs/development/repository-workflow.md @@ -0,0 +1,53 @@ +# Repository workflow + +## Prerequisites + +- Node.js 24 LTS (`.node-version` and `.nvmrc` pin the reviewed patch) +- pnpm 11.12.0 +- Docker with the Compose plugin for local infrastructure + +Install dependencies from the committed lockfile: + +```bash +pnpm install --frozen-lockfile +``` + +## Quality gates + +```bash +pnpm format:check # formatting without mutation +pnpm lint # Biome recommended rules +pnpm boundaries # architectural directions, UI ownership, and cycles +pnpm typecheck # strict TypeScript +pnpm test # Vitest +pnpm build # Turborepo package build graph +pnpm verify # every code gate above +``` + +CI runs `pnpm ci`, which adds Compose configuration validation to `pnpm verify`. + +## Development infrastructure + +Create an untracked local environment file before using Compose: + +```bash +cp .env.example .env +pnpm infrastructure:up +``` + +Stop services or follow their logs with: + +```bash +pnpm infrastructure:down +pnpm infrastructure:logs +``` + +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. + +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. diff --git a/infrastructure/compose.yaml b/infrastructure/compose.yaml new file mode 100644 index 0000000..1db6967 --- /dev/null +++ b/infrastructure/compose.yaml @@ -0,0 +1,46 @@ +name: ekklesia-development + +services: + postgres: + image: postgres:18.4-alpine3.24 + restart: unless-stopped + environment: + POSTGRES_DB: ${POSTGRES_DB:?POSTGRES_DB is required} + POSTGRES_INITDB_ARGS: --data-checksums + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required} + POSTGRES_USER: ${POSTGRES_USER:?POSTGRES_USER is required} + TZ: UTC + ports: + - "127.0.0.1:${POSTGRES_PORT:-5432}:5432" + volumes: + - postgres-data:/var/lib/postgresql + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] + interval: 5s + timeout: 5s + retries: 12 + start_period: 10s + + object-storage: + image: chrislusf/seaweedfs:4.28 + restart: unless-stopped + command: ["mini", "-dir=/data"] + environment: + AWS_ACCESS_KEY_ID: ${S3_ACCESS_KEY:?S3_ACCESS_KEY is required} + AWS_SECRET_ACCESS_KEY: ${S3_SECRET_KEY:?S3_SECRET_KEY is required} + S3_BUCKET: ${S3_BUCKET:?S3_BUCKET is required} + TZ: UTC + ports: + - "127.0.0.1:${S3_PORT:-8333}:8333" + volumes: + - object-storage-data:/data + healthcheck: + test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:9333/cluster/status"] + interval: 5s + timeout: 5s + retries: 18 + start_period: 15s + +volumes: + object-storage-data: + postgres-data: diff --git a/package.json b/package.json new file mode 100644 index 0000000..8c7d57d --- /dev/null +++ b/package.json @@ -0,0 +1,36 @@ +{ + "name": "ekklesia", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Open, modular church technology platform", + "engines": { + "node": ">=24.11.0 <25", + "pnpm": ">=11.12.0 <12" + }, + "packageManager": "pnpm@11.12.0", + "scripts": { + "boundaries": "node scripts/check-boundaries.ts", + "build": "turbo run build", + "ci": "pnpm verify && pnpm infrastructure:config", + "format": "biome format --write .", + "format:check": "biome format .", + "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:up": "docker compose --env-file .env -f infrastructure/compose.yaml up --detach --wait", + "lint": "biome lint .", + "test": "vitest run", + "test:watch": "vitest", + "typecheck": "tsc --noEmit --project tsconfig.json", + "verify": "pnpm format:check && pnpm lint && pnpm boundaries && pnpm typecheck && pnpm test && pnpm build" + }, + "devDependencies": { + "@biomejs/biome": "2.5.3", + "@types/node": "24.13.3", + "turbo": "2.10.4", + "typescript": "7.0.2", + "vitest": "4.1.10", + "yaml": "2.9.0" + } +} diff --git a/packages/typescript-config/base.json b/packages/typescript-config/base.json new file mode 100644 index 0000000..db932a9 --- /dev/null +++ b/packages/typescript-config/base.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + "allowUnreachableCode": false, + "allowUnusedLabels": false, + "exactOptionalPropertyTypes": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "noFallthroughCasesInSwitch": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noUncheckedIndexedAccess": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "skipLibCheck": true, + "strict": true, + "useUnknownInCatchVariables": true + } +} diff --git a/packages/typescript-config/browser.json b/packages/typescript-config/browser.json new file mode 100644 index 0000000..eca0175 --- /dev/null +++ b/packages/typescript-config/browser.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "./base.json", + "compilerOptions": { + "jsx": "preserve", + "lib": ["DOM", "DOM.Iterable", "ES2024"], + "module": "ESNext", + "moduleResolution": "Bundler", + "target": "ES2022" + } +} diff --git a/packages/typescript-config/node.json b/packages/typescript-config/node.json new file mode 100644 index 0000000..03e3db2 --- /dev/null +++ b/packages/typescript-config/node.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "./base.json", + "compilerOptions": { + "lib": ["ES2024"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "target": "ES2024", + "types": ["node"] + } +} diff --git a/packages/typescript-config/package.json b/packages/typescript-config/package.json new file mode 100644 index 0000000..b220925 --- /dev/null +++ b/packages/typescript-config/package.json @@ -0,0 +1,20 @@ +{ + "name": "@ekklesia/typescript-config", + "version": "0.1.0", + "private": true, + "files": [ + "base.json", + "browser.json", + "node.json", + "react-native.json" + ], + "exports": { + "./base.json": "./base.json", + "./browser.json": "./browser.json", + "./node.json": "./node.json", + "./react-native.json": "./react-native.json" + }, + "ekklesia": { + "layer": "tooling" + } +} diff --git a/packages/typescript-config/react-native.json b/packages/typescript-config/react-native.json new file mode 100644 index 0000000..adef306 --- /dev/null +++ b/packages/typescript-config/react-native.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "./base.json", + "compilerOptions": { + "jsx": "react-jsx", + "lib": ["ES2024"], + "module": "ESNext", + "moduleResolution": "Bundler", + "target": "ES2022" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..bd56876 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,1155 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@biomejs/biome': + specifier: 2.5.3 + version: 2.5.3 + '@types/node': + specifier: 24.13.3 + version: 24.13.3 + turbo: + specifier: 2.10.4 + version: 2.10.4 + typescript: + specifier: 7.0.2 + version: 7.0.2 + vitest: + specifier: 4.1.10 + version: 4.1.10(@types/node@24.13.3)(vite@8.1.4(@types/node@24.13.3)(yaml@2.9.0)) + yaml: + specifier: 2.9.0 + version: 2.9.0 + + packages/typescript-config: {} + +packages: + + '@biomejs/biome@2.5.3': + resolution: {integrity: sha512-MrJswFdei9EfDwwUy2tQrPDpK0AO+RmMFvBoaaJ6ayBc3sUbHdCE+XG5N8vp+5So41ZupZJQm0roHFFhMGVD7A==} + engines: {node: '>=14.21.3'} + hasBin: true + + '@biomejs/cli-darwin-arm64@2.5.3': + resolution: {integrity: sha512-QhYP9muVQ0nUO5zztFuPbEwi4+94sJWVjaZds9aMi1l/KNZBiUjdiSUrGHsTaMGDXrYl+r4AS2sUKfgH3w+V3g==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [darwin] + + '@biomejs/cli-darwin-x64@2.5.3': + resolution: {integrity: sha512-NC1Ss13UaW7QZX+y8j44bF7AP0jSJdBl6iRhe0MAkvaSqZy+mWg3GaXsrb+eSoHoGDBtaXWEbMVV0iVN2cZ7cQ==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [darwin] + + '@biomejs/cli-linux-arm64-musl@2.5.3': + resolution: {integrity: sha512-fccix0w6xp6csCXgxeC0dU/3ecgRQal0y+cv2SP9ajNlhe7Yrk2Ug7UDe2j9AT9ZDYitkXpvUKgZjjuoYeP4Vg==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@biomejs/cli-linux-arm64@2.5.3': + resolution: {integrity: sha512-ksx1KWeyYW18ILL04msF/J4ZBtBDN33znYK8Z/aNv/vlBVxL9/g3mGP+omgHJKy4+KWbK87vcmmpmurfNjSgiA==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@biomejs/cli-linux-x64-musl@2.5.3': + resolution: {integrity: sha512-O/yU9YKRUiHhmcjF2f38PSjseVk3G4VLWYc0G2HWpzdBVREV6G8IGWIVEFf7MFPfWIzNUIvPsEjeAZQIOgnLcQ==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@biomejs/cli-linux-x64@2.5.3': + resolution: {integrity: sha512-yMkJtilsgvILDcVkh187aVLTb64xYsrxYajx5kym+r1ULkO5HUOfu9AYKLGQbOVLwJtT2utNw7hhFNg+17mUYA==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@biomejs/cli-win32-arm64@2.5.3': + resolution: {integrity: sha512-cX5z+GYwRcqEok0AH3KSfQGgqYd0Nomfp6Fbe1uiTtELE38hdH2k842wQ9wLNaF/JJ7r4rjJQ4VR+ce+fRmQbw==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [win32] + + '@biomejs/cli-win32-x64@2.5.3': + resolution: {integrity: sha512-ExSaJWi4/u6+GXCszlSKpWSjKNbDseAYqqkCznsCsZ/4uidZ/BEqsCc5/3ctlq6dfIubdIIRSVLC/PG9xPl70Q==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [win32] + + '@emnapi/core@1.11.1': + resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} + + '@emnapi/runtime@1.11.1': + resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} + + '@emnapi/wasi-threads@1.2.2': + resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@napi-rs/wasm-runtime@1.1.6': + resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + + '@oxc-project/types@0.139.0': + resolution: {integrity: sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==} + + '@rolldown/binding-android-arm64@1.1.5': + resolution: {integrity: sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.1.5': + resolution: {integrity: sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.1.5': + resolution: {integrity: sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.1.5': + resolution: {integrity: sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.1.5': + resolution: {integrity: sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.1.5': + resolution: {integrity: sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.1.5': + resolution: {integrity: sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.1.5': + resolution: {integrity: sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.1.5': + resolution: {integrity: sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.1.5': + resolution: {integrity: sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.1.5': + resolution: {integrity: sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.1.5': + resolution: {integrity: sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.1.5': + resolution: {integrity: sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.1.5': + resolution: {integrity: sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.1.5': + resolution: {integrity: sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@turbo/darwin-64@2.10.4': + resolution: {integrity: sha512-m1MUEI4MJ69r5CwfMYxmHi0H0rrgiYCBOp0tgBZ9x/YVvOb5uu/lRIDyDwdtH054R2yWeQaIigUGu6aCX9f8cA==} + cpu: [x64] + os: [darwin] + + '@turbo/darwin-arm64@2.10.4': + resolution: {integrity: sha512-VQ1Yxs5zkPT+2z7t1P4mvn6JmcKLkOCAsPuK9XbOvuVj0DlTlETfIXNisX0771v/vTWHOQqiwoGi+TtAUq8efw==} + cpu: [arm64] + os: [darwin] + + '@turbo/linux-64@2.10.4': + resolution: {integrity: sha512-IzV1QovmwX7mfGnVinmE++2IB8tbeo38weltiuH5zNqwCTBjLs/DytyRKx+bmnhHdXIq9SheR8p0Nip/LBUPHg==} + cpu: [x64] + os: [linux] + + '@turbo/linux-arm64@2.10.4': + resolution: {integrity: sha512-rfujSQkP5aYiRn0PgTM7F00WkJCP/bKDVZbOx3WmrZwa/vHA0bplhCl328kpX7VI9HH2vI90ISGwuSVgJgoqTw==} + cpu: [arm64] + os: [linux] + + '@turbo/windows-64@2.10.4': + resolution: {integrity: sha512-NnspP7Wd5fa3Wwnqv9bKfhegqZzuHBgbPxdZU/idTLQcazx/vgKu95JlCx2YHY0hdvKCnPcARrDwM+KEUmaO7A==} + cpu: [x64] + os: [win32] + + '@turbo/windows-arm64@2.10.4': + resolution: {integrity: sha512-Iv02YgOpaEShc2OkG7mgCJ2pEw1RUKiKbs0h8W5wAf4jZ5vpmraTEjuGTgHRuOORQnC1GN3KHo5WB+hu1abRMA==} + cpu: [arm64] + os: [win32] + + '@tybys/wasm-util@0.10.3': + resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/node@24.13.3': + resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==} + + '@typescript/typescript-aix-ppc64@7.0.2': + resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [aix] + + '@typescript/typescript-darwin-arm64@7.0.2': + resolution: {integrity: sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [darwin] + + '@typescript/typescript-darwin-x64@7.0.2': + resolution: {integrity: sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [darwin] + + '@typescript/typescript-freebsd-arm64@7.0.2': + resolution: {integrity: sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [freebsd] + + '@typescript/typescript-freebsd-x64@7.0.2': + resolution: {integrity: sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [freebsd] + + '@typescript/typescript-linux-arm64@7.0.2': + resolution: {integrity: sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [linux] + + '@typescript/typescript-linux-arm@7.0.2': + resolution: {integrity: sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==} + engines: {node: '>=16.20.0'} + cpu: [arm] + os: [linux] + + '@typescript/typescript-linux-loong64@7.0.2': + resolution: {integrity: sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==} + engines: {node: '>=16.20.0'} + cpu: [loong64] + os: [linux] + + '@typescript/typescript-linux-mips64el@7.0.2': + resolution: {integrity: sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==} + engines: {node: '>=16.20.0'} + cpu: [mips64el] + os: [linux] + + '@typescript/typescript-linux-ppc64@7.0.2': + resolution: {integrity: sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [linux] + + '@typescript/typescript-linux-riscv64@7.0.2': + resolution: {integrity: sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==} + engines: {node: '>=16.20.0'} + cpu: [riscv64] + os: [linux] + + '@typescript/typescript-linux-s390x@7.0.2': + resolution: {integrity: sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==} + engines: {node: '>=16.20.0'} + cpu: [s390x] + os: [linux] + + '@typescript/typescript-linux-x64@7.0.2': + resolution: {integrity: sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [linux] + + '@typescript/typescript-netbsd-arm64@7.0.2': + resolution: {integrity: sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [netbsd] + + '@typescript/typescript-netbsd-x64@7.0.2': + resolution: {integrity: sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [netbsd] + + '@typescript/typescript-openbsd-arm64@7.0.2': + resolution: {integrity: sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [openbsd] + + '@typescript/typescript-openbsd-x64@7.0.2': + resolution: {integrity: sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [openbsd] + + '@typescript/typescript-sunos-x64@7.0.2': + resolution: {integrity: sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [sunos] + + '@typescript/typescript-win32-arm64@7.0.2': + resolution: {integrity: sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [win32] + + '@typescript/typescript-win32-x64@7.0.2': + resolution: {integrity: sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [win32] + + '@vitest/expect@4.1.10': + resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} + + '@vitest/mocker@4.1.10': + resolution: {integrity: sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.10': + resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==} + + '@vitest/runner@4.1.10': + resolution: {integrity: sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==} + + '@vitest/snapshot@4.1.10': + resolution: {integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==} + + '@vitest/spy@4.1.10': + resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==} + + '@vitest/utils@4.1.10': + resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + es-module-lexer@2.3.0: + resolution: {integrity: sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + nanoid@3.3.15: + resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + obug@2.1.3: + resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==} + engines: {node: '>=12.20.0'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + postcss@8.5.17: + resolution: {integrity: sha512-J7EF+8X+CzRPaJPOv9Ck2wNWJvGnnl3PcNPAdGg6GTLjyVpyQ0yATMSXRFRV01BviT/9Gwuc3rjEyJbDJG9a4w==} + engines: {node: ^10 || ^12 || >=14} + + rolldown@1.1.5: + resolution: {integrity: sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.2.4: + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + turbo@2.10.4: + resolution: {integrity: sha512-GQpduILaKjoaGljw097ScsSyKTtZSY7cZ3bJktzfTkPMyCf3ShKLuXK2IaOEN2Plziml+ArR7WJ1m+V4VbnaKQ==} + hasBin: true + + typescript@7.0.2: + resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} + engines: {node: '>=16.20.0'} + hasBin: true + + undici-types@7.18.2: + resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + + vite@8.1.4: + resolution: {integrity: sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.3.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.10: + resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.10 + '@vitest/browser-preview': 4.1.10 + '@vitest/browser-webdriverio': 4.1.10 + '@vitest/coverage-istanbul': 4.1.10 + '@vitest/coverage-v8': 4.1.10 + '@vitest/ui': 4.1.10 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + +snapshots: + + '@biomejs/biome@2.5.3': + optionalDependencies: + '@biomejs/cli-darwin-arm64': 2.5.3 + '@biomejs/cli-darwin-x64': 2.5.3 + '@biomejs/cli-linux-arm64': 2.5.3 + '@biomejs/cli-linux-arm64-musl': 2.5.3 + '@biomejs/cli-linux-x64': 2.5.3 + '@biomejs/cli-linux-x64-musl': 2.5.3 + '@biomejs/cli-win32-arm64': 2.5.3 + '@biomejs/cli-win32-x64': 2.5.3 + + '@biomejs/cli-darwin-arm64@2.5.3': + optional: true + + '@biomejs/cli-darwin-x64@2.5.3': + optional: true + + '@biomejs/cli-linux-arm64-musl@2.5.3': + optional: true + + '@biomejs/cli-linux-arm64@2.5.3': + optional: true + + '@biomejs/cli-linux-x64-musl@2.5.3': + optional: true + + '@biomejs/cli-linux-x64@2.5.3': + optional: true + + '@biomejs/cli-win32-arm64@2.5.3': + optional: true + + '@biomejs/cli-win32-x64@2.5.3': + optional: true + + '@emnapi/core@1.11.1': + dependencies: + '@emnapi/wasi-threads': 1.2.2 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.11.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@tybys/wasm-util': 0.10.3 + optional: true + + '@oxc-project/types@0.139.0': {} + + '@rolldown/binding-android-arm64@1.1.5': + optional: true + + '@rolldown/binding-darwin-arm64@1.1.5': + optional: true + + '@rolldown/binding-darwin-x64@1.1.5': + optional: true + + '@rolldown/binding-freebsd-x64@1.1.5': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.1.5': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.1.5': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-x64-musl@1.1.5': + optional: true + + '@rolldown/binding-openharmony-arm64@1.1.5': + optional: true + + '@rolldown/binding-wasm32-wasi@1.1.5': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.1.5': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.1.5': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@standard-schema/spec@1.1.0': {} + + '@turbo/darwin-64@2.10.4': + optional: true + + '@turbo/darwin-arm64@2.10.4': + optional: true + + '@turbo/linux-64@2.10.4': + optional: true + + '@turbo/linux-arm64@2.10.4': + optional: true + + '@turbo/windows-64@2.10.4': + optional: true + + '@turbo/windows-arm64@2.10.4': + optional: true + + '@tybys/wasm-util@0.10.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + + '@types/node@24.13.3': + dependencies: + undici-types: 7.18.2 + + '@typescript/typescript-aix-ppc64@7.0.2': + optional: true + + '@typescript/typescript-darwin-arm64@7.0.2': + optional: true + + '@typescript/typescript-darwin-x64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-x64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm@7.0.2': + optional: true + + '@typescript/typescript-linux-loong64@7.0.2': + optional: true + + '@typescript/typescript-linux-mips64el@7.0.2': + optional: true + + '@typescript/typescript-linux-ppc64@7.0.2': + optional: true + + '@typescript/typescript-linux-riscv64@7.0.2': + optional: true + + '@typescript/typescript-linux-s390x@7.0.2': + optional: true + + '@typescript/typescript-linux-x64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-sunos-x64@7.0.2': + optional: true + + '@typescript/typescript-win32-arm64@7.0.2': + optional: true + + '@typescript/typescript-win32-x64@7.0.2': + optional: true + + '@vitest/expect@4.1.10': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.10(vite@8.1.4(@types/node@24.13.3)(yaml@2.9.0))': + dependencies: + '@vitest/spy': 4.1.10 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.1.4(@types/node@24.13.3)(yaml@2.9.0) + + '@vitest/pretty-format@4.1.10': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.10': + dependencies: + '@vitest/utils': 4.1.10 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + '@vitest/utils': 4.1.10 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.10': {} + + '@vitest/utils@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + + assertion-error@2.0.1: {} + + chai@6.2.2: {} + + convert-source-map@2.0.0: {} + + detect-libc@2.1.2: {} + + es-module-lexer@2.3.0: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + expect-type@1.4.0: {} + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + fsevents@2.3.3: + optional: true + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + nanoid@3.3.15: {} + + obug@2.1.3: {} + + pathe@2.0.3: {} + + picocolors@1.1.1: {} + + picomatch@4.0.5: {} + + postcss@8.5.17: + dependencies: + nanoid: 3.3.15 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + rolldown@1.1.5: + dependencies: + '@oxc-project/types': 0.139.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.1.5 + '@rolldown/binding-darwin-arm64': 1.1.5 + '@rolldown/binding-darwin-x64': 1.1.5 + '@rolldown/binding-freebsd-x64': 1.1.5 + '@rolldown/binding-linux-arm-gnueabihf': 1.1.5 + '@rolldown/binding-linux-arm64-gnu': 1.1.5 + '@rolldown/binding-linux-arm64-musl': 1.1.5 + '@rolldown/binding-linux-ppc64-gnu': 1.1.5 + '@rolldown/binding-linux-s390x-gnu': 1.1.5 + '@rolldown/binding-linux-x64-gnu': 1.1.5 + '@rolldown/binding-linux-x64-musl': 1.1.5 + '@rolldown/binding-openharmony-arm64': 1.1.5 + '@rolldown/binding-wasm32-wasi': 1.1.5 + '@rolldown/binding-win32-arm64-msvc': 1.1.5 + '@rolldown/binding-win32-x64-msvc': 1.1.5 + + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + std-env@4.2.0: {} + + tinybench@2.9.0: {} + + tinyexec@1.2.4: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + tinyrainbow@3.1.0: {} + + tslib@2.8.1: + optional: true + + turbo@2.10.4: + optionalDependencies: + '@turbo/darwin-64': 2.10.4 + '@turbo/darwin-arm64': 2.10.4 + '@turbo/linux-64': 2.10.4 + '@turbo/linux-arm64': 2.10.4 + '@turbo/windows-64': 2.10.4 + '@turbo/windows-arm64': 2.10.4 + + typescript@7.0.2: + optionalDependencies: + '@typescript/typescript-aix-ppc64': 7.0.2 + '@typescript/typescript-darwin-arm64': 7.0.2 + '@typescript/typescript-darwin-x64': 7.0.2 + '@typescript/typescript-freebsd-arm64': 7.0.2 + '@typescript/typescript-freebsd-x64': 7.0.2 + '@typescript/typescript-linux-arm': 7.0.2 + '@typescript/typescript-linux-arm64': 7.0.2 + '@typescript/typescript-linux-loong64': 7.0.2 + '@typescript/typescript-linux-mips64el': 7.0.2 + '@typescript/typescript-linux-ppc64': 7.0.2 + '@typescript/typescript-linux-riscv64': 7.0.2 + '@typescript/typescript-linux-s390x': 7.0.2 + '@typescript/typescript-linux-x64': 7.0.2 + '@typescript/typescript-netbsd-arm64': 7.0.2 + '@typescript/typescript-netbsd-x64': 7.0.2 + '@typescript/typescript-openbsd-arm64': 7.0.2 + '@typescript/typescript-openbsd-x64': 7.0.2 + '@typescript/typescript-sunos-x64': 7.0.2 + '@typescript/typescript-win32-arm64': 7.0.2 + '@typescript/typescript-win32-x64': 7.0.2 + + undici-types@7.18.2: {} + + vite@8.1.4(@types/node@24.13.3)(yaml@2.9.0): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.5 + postcss: 8.5.17 + rolldown: 1.1.5 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 24.13.3 + fsevents: 2.3.3 + yaml: 2.9.0 + + vitest@4.1.10(@types/node@24.13.3)(vite@8.1.4(@types/node@24.13.3)(yaml@2.9.0)): + dependencies: + '@vitest/expect': 4.1.10 + '@vitest/mocker': 4.1.10(vite@8.1.4(@types/node@24.13.3)(yaml@2.9.0)) + '@vitest/pretty-format': 4.1.10 + '@vitest/runner': 4.1.10 + '@vitest/snapshot': 4.1.10 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + es-module-lexer: 2.3.0 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.3 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: 8.1.4(@types/node@24.13.3)(yaml@2.9.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 24.13.3 + transitivePeerDependencies: + - msw + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + yaml@2.9.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..a1a1956 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,10 @@ +packages: + - "apps/*" + - "core" + - "modules/*" + - "packages/*" + - "surfaces/*" + - "templates/*" + +onlyBuiltDependencies: + - esbuild diff --git a/scripts/check-boundaries.ts b/scripts/check-boundaries.ts new file mode 100644 index 0000000..4231c44 --- /dev/null +++ b/scripts/check-boundaries.ts @@ -0,0 +1,498 @@ +import { readdir, readFile, stat } from "node:fs/promises"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +type Layer = "app" | "builder" | "core" | "module" | "neutral" | "shared" | "surface"; + +interface Location { + layer: Layer; + owner: string; + relativePath: string; + role?: string; +} + +interface PackageManifest { + dependencies?: Record; + devDependencies?: Record; + ekklesia?: { layer?: string }; + exports?: unknown; + name?: string; + optionalDependencies?: Record; + peerDependencies?: Record; +} + +interface WorkspacePackage { + directory: string; + location: Location; + manifest: PackageManifest; + name: string; +} + +export interface BoundaryViolation { + from: string; + message: string; + rule: string; + to?: string; +} + +export interface BoundaryResult { + filesChecked: number; + packagesChecked: number; + violations: BoundaryViolation[]; +} + +const CODE_EXTENSIONS = new Set([".cjs", ".cts", ".js", ".jsx", ".mjs", ".mts", ".ts", ".tsx"]); +const MODULE_UI_EXTENSIONS = new Set([".css", ".jsx", ".less", ".scss", ".svelte", ".tsx", ".vue"]); +const MODULE_UI_PACKAGES = new Set([ + "@angular/core", + "expo", + "next", + "react", + "react-dom", + "react-native", + "solid-js", + "svelte", + "tailwindcss", + "vue", +]); +const IGNORED_DIRECTORIES = new Set([ + ".git", + ".next", + ".turbo", + "coverage", + "dist", + "node_modules", +]); +const DEPENDENCY_FIELDS = [ + "dependencies", + "devDependencies", + "optionalDependencies", + "peerDependencies", +] as const; + +function normalizePath(value: string): string { + return value.split(path.sep).join("/").replace(/^\.\//, ""); +} + +export function describeRepositoryPath(relativePath: string): Location { + const normalized = normalizePath(relativePath); + const [area, owner = area] = normalized.split("/"); + + if (area === "core") { + return { layer: "core", owner: "core", relativePath: normalized }; + } + + if (area === "modules" && owner) { + return { layer: "module", owner, relativePath: normalized }; + } + + if (area === "surfaces" && owner) { + return { layer: "surface", owner, relativePath: normalized }; + } + + if ((area === "builders" || area === "surface-builder") && owner) { + return { layer: "builder", owner, relativePath: normalized }; + } + + if (area === "apps" && owner) { + return { layer: "app", owner, relativePath: normalized }; + } + + if (area === "packages" && owner) { + return { layer: "shared", owner, relativePath: normalized, role: owner }; + } + + if (area === "templates" && owner === "module") { + return { layer: "module", owner: "template-module", relativePath: normalized }; + } + + return { layer: "neutral", owner: area ?? "root", relativePath: normalized }; +} + +function boundaryRule(from: Location, to: Location): string | undefined { + if (from.layer === "neutral" || to.layer === "neutral") { + return undefined; + } + + if (from.layer === to.layer && from.owner === to.owner) { + return undefined; + } + + if (from.layer === "core" && ["app", "builder", "module", "surface"].includes(to.layer)) { + return "core-is-feature-agnostic"; + } + + if (from.layer === "module") { + if (["app", "builder", "core", "surface"].includes(to.layer)) { + return "modules-use-public-platform-contracts"; + } + if (to.layer === "module" && from.owner !== to.owner) { + return "modules-do-not-import-other-modules"; + } + } + + if (from.layer === "surface") { + if (["app", "builder", "core", "module"].includes(to.layer)) { + return "surfaces-use-public-contracts"; + } + if (to.layer === "surface" && from.owner !== to.owner) { + return "surfaces-are-independent"; + } + } + + if (from.layer === "builder" && ["app", "core", "module", "surface"].includes(to.layer)) { + return "builder-composes-contracts"; + } + + if ( + from.layer === "shared" && + from.role === "contracts" && + ["app", "builder", "core", "module", "surface"].includes(to.layer) + ) { + return "contracts-do-not-depend-upward"; + } + + if ( + from.layer === "shared" && + ["module-sdk", "surface-sdk"].includes(from.role ?? "") && + ["app", "builder", "core", "module", "surface"].includes(to.layer) + ) { + return "sdks-do-not-depend-on-implementations"; + } + + return undefined; +} + +function packageNameFromSpecifier(specifier: string): string { + if (specifier.startsWith("@")) { + return specifier.split("/").slice(0, 2).join("/"); + } + return specifier.split("/")[0] ?? specifier; +} + +function isModuleUiPackage(packageName: string): boolean { + return MODULE_UI_PACKAGES.has(packageName) || packageName.startsWith("@angular/"); +} + +async function isFile(filePath: string): Promise { + try { + return (await stat(filePath)).isFile(); + } catch { + return false; + } +} + +async function walkFiles(root: string, current = root): Promise { + const entries = await readdir(current, { withFileTypes: true }); + const files: string[] = []; + + for (const entry of entries) { + if (entry.isDirectory() && IGNORED_DIRECTORIES.has(entry.name)) { + continue; + } + + const absolutePath = path.join(current, entry.name); + const relativePath = normalizePath(path.relative(root, absolutePath)); + + if (entry.isDirectory()) { + if (relativePath === "tests/fixtures" || relativePath.startsWith("tests/fixtures/")) { + continue; + } + files.push(...(await walkFiles(root, absolutePath))); + continue; + } + + if (entry.isFile()) { + files.push(absolutePath); + } + } + + return files; +} + +function collectImportSpecifiers(sourceText: string): string[] { + const specifiers = new Set(); + const staticImport = + /\b(?:import|export)\s+(?:type\s+)?(?:[^"'\n;]*?\s+from\s+)?["']([^"'\n]+)["']/gu; + const dynamicImport = /\b(?:import|require)\s*\(\s*["']([^"'\n]+)["']\s*\)/gu; + + for (const pattern of [staticImport, dynamicImport]) { + for (const match of sourceText.matchAll(pattern)) { + const specifier = match[1]; + if (specifier) { + specifiers.add(specifier); + } + } + } + + return [...specifiers]; +} + +async function resolveSourceImport( + fromFile: string, + specifier: string, +): Promise { + if (!specifier.startsWith(".")) { + return undefined; + } + + const candidate = path.resolve(path.dirname(fromFile), specifier); + const extension = path.extname(candidate); + const withoutExtension = extension ? candidate.slice(0, -extension.length) : candidate; + const candidates = [candidate]; + + if ([".js", ".jsx", ".mjs", ".cjs"].includes(extension)) { + candidates.push( + `${withoutExtension}.ts`, + `${withoutExtension}.tsx`, + `${withoutExtension}.mts`, + `${withoutExtension}.cts`, + ); + } else if (!extension) { + for (const codeExtension of CODE_EXTENSIONS) { + candidates.push( + `${candidate}${codeExtension}`, + path.join(candidate, `index${codeExtension}`), + ); + } + } + + for (const possibleFile of candidates) { + if (await isFile(possibleFile)) { + return possibleFile; + } + } + + return candidate; +} + +function findCycles(graph: Map>): string[][] { + const visited = new Set(); + const active = new Set(); + const stack: string[] = []; + const cycles: string[][] = []; + const seenCycles = new Set(); + + function visit(node: string): void { + if (active.has(node)) { + const start = stack.indexOf(node); + const cycle = [...stack.slice(start), node]; + const key = [...new Set(cycle)].sort().join("|"); + if (!seenCycles.has(key)) { + seenCycles.add(key); + cycles.push(cycle); + } + return; + } + + if (visited.has(node)) { + return; + } + + visited.add(node); + active.add(node); + stack.push(node); + for (const target of graph.get(node) ?? []) { + if (graph.has(target)) { + visit(target); + } + } + stack.pop(); + active.delete(node); + } + + for (const node of graph.keys()) { + visit(node); + } + + return cycles; +} + +function exportedSubpath( + manifest: PackageManifest, + specifier: string, + packageName: string, +): boolean { + const suffix = specifier.slice(packageName.length); + if (!suffix) { + return true; + } + + if (!manifest.exports || typeof manifest.exports !== "object") { + return false; + } + + const requested = `.${suffix}`; + return Object.hasOwn(manifest.exports, requested); +} + +export async function checkBoundaries(rootDirectory: string): Promise { + const root = path.resolve(rootDirectory); + const allFiles = await walkFiles(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 manifestFile of manifestFiles) { + const manifest = JSON.parse(await readFile(manifestFile, "utf8")) as PackageManifest; + if (!manifest.name || manifestFile === path.join(root, "package.json")) { + continue; + } + const directory = path.dirname(manifestFile); + const relativeDirectory = normalizePath(path.relative(root, directory)); + const location = describeRepositoryPath(relativeDirectory); + if (manifest.ekklesia?.layer) { + location.role = manifest.ekklesia.layer; + } + const workspacePackage = { directory, location, manifest, name: manifest.name }; + workspacePackages.set(manifest.name, workspacePackage); + packageByDirectory.set(directory, workspacePackage); + } + + const packageGraph = new Map>(); + for (const workspacePackage of workspacePackages.values()) { + packageGraph.set(workspacePackage.name, new Set()); + for (const dependencyField of DEPENDENCY_FIELDS) { + for (const dependencyName of Object.keys(workspacePackage.manifest[dependencyField] ?? {})) { + if (workspacePackage.location.layer === "module" && isModuleUiPackage(dependencyName)) { + violations.push({ + from: workspacePackage.location.relativePath, + message: `Module package depends on UI framework ${dependencyName}.`, + rule: "modules-do-not-own-ui", + }); + } + + const targetPackage = workspacePackages.get(dependencyName); + if (!targetPackage) { + continue; + } + packageGraph.get(workspacePackage.name)?.add(targetPackage.name); + const rule = boundaryRule(workspacePackage.location, targetPackage.location); + if (rule) { + violations.push({ + from: workspacePackage.location.relativePath, + message: `${workspacePackage.name} may not depend on ${targetPackage.name}.`, + rule, + to: targetPackage.location.relativePath, + }); + } + } + } + } + + for (const cycle of findCycles(packageGraph)) { + violations.push({ + from: cycle[0] ?? "unknown-package", + message: `Workspace package cycle: ${cycle.join(" -> ")}`, + rule: "no-circular-package-dependencies", + }); + } + + for (const repositoryFile of allFiles) { + const relativeFile = normalizePath(path.relative(root, repositoryFile)); + const location = describeRepositoryPath(relativeFile); + if (location.layer === "module" && MODULE_UI_EXTENSIONS.has(path.extname(repositoryFile))) { + violations.push({ + from: relativeFile, + message: "UI source files belong in a surface, not a module.", + rule: "modules-do-not-own-ui", + }); + } + } + + const sourceGraph = new Map>(); + for (const codeFile of codeFiles) { + const relativeFile = normalizePath(path.relative(root, codeFile)); + const from = describeRepositoryPath(relativeFile); + sourceGraph.set(relativeFile, new Set()); + + const sourceText = await readFile(codeFile, "utf8"); + for (const specifier of collectImportSpecifiers(sourceText)) { + const dependencyName = packageNameFromSpecifier(specifier); + if (from.layer === "module" && isModuleUiPackage(dependencyName)) { + violations.push({ + from: relativeFile, + message: `Module source imports UI framework ${dependencyName}.`, + rule: "modules-do-not-own-ui", + to: specifier, + }); + } + + let targetFile: string | undefined; + let to: Location | undefined; + if (specifier.startsWith(".")) { + targetFile = await resolveSourceImport(codeFile, specifier); + if (targetFile) { + to = describeRepositoryPath(normalizePath(path.relative(root, targetFile))); + if (await isFile(targetFile)) { + sourceGraph.get(relativeFile)?.add(normalizePath(path.relative(root, targetFile))); + } + } + } else { + const targetPackage = workspacePackages.get(dependencyName); + if (targetPackage) { + to = targetPackage.location; + if (!exportedSubpath(targetPackage.manifest, specifier, dependencyName)) { + violations.push({ + from: relativeFile, + message: `${specifier} is not a public package export.`, + rule: "workspace-imports-use-public-exports", + to: targetPackage.location.relativePath, + }); + } + } + } + + if (to) { + const rule = boundaryRule(from, to); + if (rule) { + violations.push({ + from: relativeFile, + message: `${from.layer} code may not import ${to.layer} code directly.`, + rule, + to: to.relativePath, + }); + } + } + } + } + + for (const cycle of findCycles(sourceGraph)) { + violations.push({ + from: cycle[0] ?? "unknown-source", + message: `Source dependency cycle: ${cycle.join(" -> ")}`, + rule: "no-circular-source-dependencies", + }); + } + + return { + filesChecked: codeFiles.length, + packagesChecked: packageByDirectory.size, + violations, + }; +} + +async function main(): Promise { + const root = process.argv[2] ? path.resolve(process.argv[2]) : process.cwd(); + const result = await checkBoundaries(root); + if (result.violations.length > 0) { + console.error(`Boundary validation failed with ${result.violations.length} violation(s):`); + for (const violation of result.violations) { + const target = violation.to ? ` -> ${violation.to}` : ""; + console.error(`- [${violation.rule}] ${violation.from}${target}: ${violation.message}`); + } + process.exitCode = 1; + return; + } + + console.log( + `Boundary validation passed (${result.packagesChecked} packages, ${result.filesChecked} source files).`, + ); +} + +const entryPoint = process.argv[1]; +if (entryPoint && import.meta.url === pathToFileURL(path.resolve(entryPoint)).href) { + await main(); +} diff --git a/tests/boundaries.test.ts b/tests/boundaries.test.ts new file mode 100644 index 0000000..3c9d309 --- /dev/null +++ b/tests/boundaries.test.ts @@ -0,0 +1,32 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { checkBoundaries } from "../scripts/check-boundaries.js"; + +const testDirectory = path.dirname(fileURLToPath(import.meta.url)); +const fixtures = path.join(testDirectory, "fixtures", "boundaries"); + +describe("repository dependency boundaries", () => { + it("accepts public contracts below Core", async () => { + const result = await checkBoundaries(path.join(fixtures, "legal")); + expect(result.violations).toEqual([]); + }); + + it.each([ + ["core-imports-module", "core-is-feature-agnostic"], + ["module-imports-surface", "modules-use-public-platform-contracts"], + ["cross-module-import", "modules-do-not-import-other-modules"], + ["module-has-ui", "modules-do-not-own-ui"], + ["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 () => { + const result = await checkBoundaries(path.join(fixtures, "module-has-ui")); + expect( + result.violations.filter((violation) => violation.rule === "modules-do-not-own-ui"), + ).toHaveLength(2); + }); +}); diff --git a/tests/fixtures/boundaries/core-imports-module/core/src/index.ts b/tests/fixtures/boundaries/core-imports-module/core/src/index.ts new file mode 100644 index 0000000..edc08ba --- /dev/null +++ b/tests/fixtures/boundaries/core-imports-module/core/src/index.ts @@ -0,0 +1,3 @@ +import { calendar } from "../../modules/calendar/src/index.js"; + +export const invalidCoreFeature = calendar; diff --git a/tests/fixtures/boundaries/core-imports-module/modules/calendar/src/index.ts b/tests/fixtures/boundaries/core-imports-module/modules/calendar/src/index.ts new file mode 100644 index 0000000..217b229 --- /dev/null +++ b/tests/fixtures/boundaries/core-imports-module/modules/calendar/src/index.ts @@ -0,0 +1 @@ +export const calendar = "calendar"; diff --git a/tests/fixtures/boundaries/cross-module-import/modules/calendar/src/index.ts b/tests/fixtures/boundaries/cross-module-import/modules/calendar/src/index.ts new file mode 100644 index 0000000..f024687 --- /dev/null +++ b/tests/fixtures/boundaries/cross-module-import/modules/calendar/src/index.ts @@ -0,0 +1,3 @@ +import { media } from "../../../modules/media/src/index.js"; + +export const invalidModuleDependency = media; diff --git a/tests/fixtures/boundaries/cross-module-import/modules/media/src/index.ts b/tests/fixtures/boundaries/cross-module-import/modules/media/src/index.ts new file mode 100644 index 0000000..3a8db9b --- /dev/null +++ b/tests/fixtures/boundaries/cross-module-import/modules/media/src/index.ts @@ -0,0 +1 @@ +export const media = "media"; diff --git a/tests/fixtures/boundaries/cycle/core/src/a.ts b/tests/fixtures/boundaries/cycle/core/src/a.ts new file mode 100644 index 0000000..8d2a025 --- /dev/null +++ b/tests/fixtures/boundaries/cycle/core/src/a.ts @@ -0,0 +1,3 @@ +import { b } from "./b.js"; + +export const a = b; diff --git a/tests/fixtures/boundaries/cycle/core/src/b.ts b/tests/fixtures/boundaries/cycle/core/src/b.ts new file mode 100644 index 0000000..df1cf12 --- /dev/null +++ b/tests/fixtures/boundaries/cycle/core/src/b.ts @@ -0,0 +1,3 @@ +import { a } from "./a.js"; + +export const b = a; diff --git a/tests/fixtures/boundaries/legal/core/src/index.ts b/tests/fixtures/boundaries/legal/core/src/index.ts new file mode 100644 index 0000000..0f67160 --- /dev/null +++ b/tests/fixtures/boundaries/legal/core/src/index.ts @@ -0,0 +1,3 @@ +import type { HealthContract } from "@ekklesia/contracts"; + +export const health: HealthContract = { status: "ok" }; diff --git a/tests/fixtures/boundaries/legal/packages/contracts/package.json b/tests/fixtures/boundaries/legal/packages/contracts/package.json new file mode 100644 index 0000000..2890856 --- /dev/null +++ b/tests/fixtures/boundaries/legal/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/legal/packages/contracts/src/index.ts b/tests/fixtures/boundaries/legal/packages/contracts/src/index.ts new file mode 100644 index 0000000..75d4aea --- /dev/null +++ b/tests/fixtures/boundaries/legal/packages/contracts/src/index.ts @@ -0,0 +1,3 @@ +export interface HealthContract { + readonly status: "ok"; +} diff --git a/tests/fixtures/boundaries/module-has-ui/modules/calendar/src/calendar.css b/tests/fixtures/boundaries/module-has-ui/modules/calendar/src/calendar.css new file mode 100644 index 0000000..7068b9c --- /dev/null +++ b/tests/fixtures/boundaries/module-has-ui/modules/calendar/src/calendar.css @@ -0,0 +1,3 @@ +.calendar { + display: grid; +} diff --git a/tests/fixtures/boundaries/module-has-ui/modules/calendar/src/calendar.tsx b/tests/fixtures/boundaries/module-has-ui/modules/calendar/src/calendar.tsx new file mode 100644 index 0000000..92b49ef --- /dev/null +++ b/tests/fixtures/boundaries/module-has-ui/modules/calendar/src/calendar.tsx @@ -0,0 +1,3 @@ +export function Calendar(): string { + return "calendar"; +} diff --git a/tests/fixtures/boundaries/module-imports-surface/modules/calendar/src/index.ts b/tests/fixtures/boundaries/module-imports-surface/modules/calendar/src/index.ts new file mode 100644 index 0000000..4a814d5 --- /dev/null +++ b/tests/fixtures/boundaries/module-imports-surface/modules/calendar/src/index.ts @@ -0,0 +1,3 @@ +import { CalendarView } from "../../../surfaces/dashboard/src/calendar-view.js"; + +export const invalidModuleUi = CalendarView; diff --git a/tests/fixtures/boundaries/module-imports-surface/surfaces/dashboard/src/calendar-view.ts b/tests/fixtures/boundaries/module-imports-surface/surfaces/dashboard/src/calendar-view.ts new file mode 100644 index 0000000..7779c0a --- /dev/null +++ b/tests/fixtures/boundaries/module-imports-surface/surfaces/dashboard/src/calendar-view.ts @@ -0,0 +1 @@ +export const CalendarView = "calendar-view"; diff --git a/tests/infrastructure.test.ts b/tests/infrastructure.test.ts new file mode 100644 index 0000000..6fae4d9 --- /dev/null +++ b/tests/infrastructure.test.ts @@ -0,0 +1,61 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { parse } from "yaml"; + +interface ComposeService { + healthcheck?: unknown; + image?: string; + ports?: string[]; + volumes?: string[]; +} + +interface ComposeDocument { + services?: Record; + volumes?: Record; +} + +const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +describe("development infrastructure", () => { + it("has valid, local-only, version-pinned Compose services", async () => { + const source = await readFile( + path.join(repositoryRoot, "infrastructure", "compose.yaml"), + "utf8", + ); + const compose = parse(source) as ComposeDocument; + const postgres = compose.services?.postgres; + const objectStorage = compose.services?.["object-storage"]; + + expect(postgres?.image).toBe("postgres:18.4-alpine3.24"); + expect(objectStorage?.image).toBe("chrislusf/seaweedfs:4.28"); + expect(postgres?.healthcheck).toBeDefined(); + expect(objectStorage?.healthcheck).toBeDefined(); + + for (const service of [postgres, objectStorage]) { + expect(service).toBeDefined(); + expect(service?.image).not.toMatch(/:latest$/u); + expect(service?.ports?.every((port) => port.startsWith("127.0.0.1:"))).toBe(true); + expect(service?.volumes?.length).toBeGreaterThan(0); + } + + expect(compose.volumes).toHaveProperty("postgres-data"); + expect(compose.volumes).toHaveProperty("object-storage-data"); + }); + + it("has a parseable CI workflow with a single repository gate", async () => { + const source = await readFile( + path.join(repositoryRoot, ".github", "workflows", "ci.yml"), + "utf8", + ); + const workflow = parse(source) as { + jobs?: { verify?: { steps?: Array<{ run?: string }> } }; + }; + const commands = workflow.jobs?.verify?.steps?.flatMap((step) => step.run ?? []) ?? []; + + expect(workflow.jobs?.verify).toBeDefined(); + expect(commands).toContain("pnpm install --frozen-lockfile"); + expect(commands).toContain("pnpm ci"); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..a8401e6 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "./packages/typescript-config/node.json", + "compilerOptions": { + "noEmit": true + }, + "include": ["scripts/**/*.ts", "tests/**/*.ts", "vitest.config.ts"], + "exclude": ["node_modules", "tests/fixtures"] +} diff --git a/turbo.json b/turbo.json new file mode 100644 index 0000000..3258c82 --- /dev/null +++ b/turbo.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://turbo.build/schema.json", + "ui": "stream", + "tasks": { + "build": { + "dependsOn": ["^build"], + "outputs": ["dist/**", ".next/**", "!.next/cache/**"] + }, + "lint": { + "dependsOn": ["^lint"], + "outputs": [] + }, + "test": { + "dependsOn": ["build"], + "outputs": ["coverage/**"] + }, + "typecheck": { + "dependsOn": ["^typecheck"], + "outputs": [] + } + } +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..655cfcc --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + coverage: { + provider: "v8", + reporter: ["text", "json", "html"], + }, + include: ["tests/**/*.test.ts"], + passWithNoTests: false, + }, +});