Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions .github/workflows/architecture.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
name: Architecture Tests

# Runs the ArchUnitTS architecture rules (packages/architecture-tests) as a
# dedicated, NON-BLOCKING check. The job is allowed to fail (continue-on-error)
# so that pre-existing architecture violations are reported and monitored over
# time without blocking merges. Open the job logs to see the offending imports.

env:
HUSKY: '0'

on:
push:
branches: ['main']
pull_request:
workflow_dispatch:

jobs:
architecture-tests:
name: Architecture rules (non-blocking)
runs-on: ${{ vars.ACTION_RUNNER_TAG || 'self-hosted' }}
timeout-minutes: ${{ github.actor == 'dependabot[bot]' && 15 || 30 }}
# Allow this job to fail without failing the workflow run or blocking PRs.
continue-on-error: true

steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1

- uses: pnpm/action-setup@v4

- name: Use Node.js from .nvmrc
uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
cache: 'pnpm'

- name: Cache pnpm supply-chain verification
uses: actions/cache@v4
with:
path: ~/.cache/pnpm
key: pnpm-verify-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}

- run: pnpm install --frozen-lockfile --prefer-offline

# tsconfig.base.effective.json is git-ignored, so generate it before Nx
# builds its project graph (the vite/vitest plugins parse every project's
# tsconfig, which `extends` this file). Mirrors the quality workflow.
- name: Generate tsconfig
env:
PACKMIND_EDITION: ${{ vars.PACKMIND_EDITION }}
run: node scripts/select-tsconfig.mjs

# Generates tsconfig.arch.json and runs the dedicated `arch` Nx target,
# which re-reads PACKMIND_EDITION to pick the edition-aware tsconfig.
- name: Run architecture tests
env:
PACKMIND_EDITION: ${{ vars.PACKMIND_EDITION }}
run: pnpm run test:arch
31 changes: 31 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,37 @@ jobs:
path: dist/packages/standards/samples/generated/
if-no-files-found: ignore

architecture-tests:
runs-on: ${{ vars.ACTION_RUNNER_TAG || 'self-hosted' }}
timeout-minutes: ${{ github.actor == 'dependabot[bot]' && 15 || 30 }}
strategy:
matrix:
node-version: ['${{ inputs.node-version }}']

steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'pnpm'
- name: Cache pnpm supply-chain verification
uses: actions/cache@v4
with:
path: ~/.cache/pnpm
key: pnpm-verify-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
- run: pnpm install --frozen-lockfile --prefer-offline
- name: Generate tsconfig
env:
PACKMIND_EDITION: ${{ vars.PACKMIND_EDITION }}
run: node scripts/select-tsconfig.mjs
- name: Run architecture tests
env:
PACKMIND_EDITION: ${{ vars.PACKMIND_EDITION }}
NODE_OPTIONS: '--max-old-space-size=16384 --no-experimental-strip-types'
run: ./node_modules/.bin/nx run architecture-tests:arch

build-cli:
runs-on: ${{ vars.ACTION_RUNNER_TAG || 'self-hosted' }}
timeout-minutes: ${{ github.actor == 'dependabot[bot]' && 15 || 30 }}
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ dockerfile/prod/secrets


tsconfig.base.effective.json
tsconfig.arch.json
packages/linter/js-playground-local

# Runtime-installed sprite sandbox compose overrides (scripts/install-michel-skills.sh)
Expand Down
4 changes: 4 additions & 0 deletions .husky/pre-push
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ export NODE_OPTIONS='--max-old-space-size=16384'
# Cached by Nx, so this is a no-op cache hit when nothing in the frontend changed.
./node_modules/.bin/nx run frontend:typecheck --tuiAutoExit

# Always run architecture tests. Cached by Nx, so this is a no-op cache hit
# when nothing relevant changed. Fails the push if any architecture rule breaks.
./node_modules/.bin/nx run architecture-tests:arch --tuiAutoExit

# passWithNoTests is already set in jest.preset.ts, so no need to pass it on the CLI
# (it would otherwise be forwarded to vite/eslint/tsc which don't know it).
./node_modules/.bin/nx affected \
Expand Down
5 changes: 4 additions & 1 deletion eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,10 @@ export default [
rules: {},
},
{
files: ['**/jest.config.ts'],
// `jest.config.ts` plus variants like `jest.arch.config.ts` (the
// architecture-tests project uses a non-default name so the @nx/jest plugin
// doesn't infer a `test` target for it).
files: ['**/jest.config.ts', '**/jest.*.config.ts'],
rules: {
'@typescript-eslint/no-require-imports': 'off',
'@nx/enforce-module-boundaries': 'off',
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"withEnv": "bash -c 'set -a; [ -f .env ] && source .env; set +a; eval \"$@\"' --",
"test": "PACKMIND_LOG_LEVEL=silent pnpm withEnv \"NODE_OPTIONS='--max-old-space-size=16384 --no-experimental-strip-types' nx run-many -t test --exclude=cli-e2e-tests --parallel=\\${NX_PARALLEL_TESTS:-3}\"",
"test:staged": "PACKMIND_LOG_LEVEL=silent pnpm withEnv \"NODE_OPTIONS='--max-old-space-size=16384 --no-experimental-strip-types' nx affected:test --parallel=\\${NX_PARALLEL_TESTS:-3}\"",
"test:arch": "nx run architecture-tests:arch",
"test:memory-profile": "./scripts/test-memory-profile.sh",
"typecheck:frontend": "nx run frontend:typecheck"
},
Expand Down Expand Up @@ -73,6 +74,7 @@
"@types/uuid": "^10.0.0",
"@types/validator": "^13.15.3",
"@vitejs/plugin-react": "^5.2.0",
"archunit": "^2.3.0",
"copy-webpack-plugin": "^14.0.0",
"esbuild-loader": "^4.4.2",
"eslint": "^9.8.0",
Expand Down
128 changes: 128 additions & 0 deletions packages/architecture-tests/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
# architecture-tests

Executable enforcement of Packmind's hexagonal (ports & adapters) architecture,
powered by [ArchUnitTS](https://github.com/LukasNiessen/ArchUnitTS).

Each test is a rule about which layer may import which. Rules are expressed as
import-dependency constraints over the real TypeScript source graph (aliases
resolved via `tsconfig`), so a failure points at the exact offending file with a
clickable path.

## Why a separate command

These tests are **not** part of `nx run-many -t test` / `npm run test:staged`.
They scan the whole monorepo's dependency graph (slower) and they intentionally
surface pre-existing violations as failures. Keeping them on a dedicated target
avoids breaking the main suite while still giving the team a runnable
architecture report.

```bash
npm run test:arch
# = PACKMIND_EDITION=oss nx run architecture-tests:arch
```

The `arch` target first runs `scripts/build-arch-tsconfig.mjs`, which generates a
self-contained `tsconfig.arch.json` (git-ignored) from the edition-aware
`tsconfig.base.effective.json`. ArchUnitTS reads tsconfig with
`ts.readConfigFile` (which does not follow `extends`), so it needs an inlined,
include-bearing config — that is what the generator produces.

## The workflow being enforced

```
NestJS controller → NestJS service → domain Adapter → UseCase
→ application Service → repository interface (port) → repository implementation (infra)
```

Dependencies only ever point downward/inward. The rules below assert the
forbidden shortcuts and back-edges.

## Rules

Rules live in `src/*.arch.spec.ts`. Layer selection globs are centralised in
`src/architecture.ts` (tolerant of folder-naming variants such as
`useCases`/`usecases` and `adapter`/`adapters`).

### Core workflow layering — `src/layering.arch.spec.ts`

| Rule | Guards |
| ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `infra/repositories` ↛ `application/**` | Persistence must not depend upward on use cases/adapters/services. |
| `application/useCases` ↛ `infra/repositories` | Use cases reach data via services/ports, never concrete repositories. (`standard-use-case-architecture-patterns`) |
| `application/adapter(s)` ↛ `infra/repositories` | "Never directly call Repositories in Adapter classes." (`standard-use-case-architecture-patterns`) |
| `application/services` ↛ `infra/repositories` | Services depend on repository **interfaces** (`domain/repositories`), not implementations. |
| `application/services` ↛ `application/useCases` | Use cases orchestrate services, never the reverse — a service reaching back up inverts the layer. |
| `application/services` ↛ `application/adapter(s)` | The adapter is the port entry point at the top of the layer; services below it must not depend back up. |
| `apps/api` ↛ `infra/repositories` | No API file (controller, module, NestJS service) may reach concrete persistence — only ports. (`standard-nestjs-module-hierarchy`) |
| `apps/api` ↛ `application/**` | The API reaches each domain only through its `@packmind/types` port (injected by port-name via the HexaRegistry), never use cases/services/adapter directly. |
| `infra/schemas` ↛ `application/**` | EntitySchema files are pure ORM mapping. (`infra/jobs` legitimately wires application jobs; schemas do not.) |

### Domain purity — `src/domain-purity.arch.spec.ts`

| Rule | Guards |
| ------------------------------ | -------------------------------------------------------------------- |
| `domain/**` ↛ `application/**` | The innermost ring must not know about the layer that depends on it. |
| `domain/**` ↛ `infra/**` | Domain stays free of infrastructure. |

### Cross-domain isolation — `src/cross-domain.arch.spec.ts`

| Rule | Guards |
| ----------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `packages/<domain>/src` ↛ `packages/<other-domain>/src` (one rule per domain) | Domains collaborate only through `@packmind/types` ports wired by the HexaRegistry — never by importing each other's source. Complements the Nx `env:*` tag boundaries. (`standard-port-adapter-cross-domain-integration`) |

Domains are **discovered**, not hardcoded: every package under `packages/` that
owns a `src/domain/` layer is a domain (today: `accounts`, `spaces`, `standards`,
`recipes`, `skills`, `git`, `deployments`, `coding-agent`, `llm`). Shared/leaf
packages (`types`, `logger`, `node-utils`, `test-utils`, `migrations`, `editions`,
`frontend`, `assets`, `linter-*`) have no `domain/` layer, so importing them stays
legal for everyone. The only hardcoded list is `EXCLUDED_PACKAGES` in
`src/architecture.ts`, which contains just `ui` (the Chakra frontend library — not
a backend hexagon). A new domain package is therefore covered automatically.

### Shared-package purity & reverse dependencies — `src/boundaries.arch.spec.ts`

Between packages the graph is layered too:
`apps/api → domain packages → @packmind/types (+ node-utils, logger)`.

| Rule | Guards |
| ------------------------------------ | --------------------------------------------------------------------------------------------------------------- |
| `@packmind/types` ↛ any domain | The contract package is a leaf — depending on a domain is a cycle and breaks "ports live in `@packmind/types`". |
| `node-utils` / `logger` ↛ any domain | Base packages everything builds on must not depend back on a domain. |
| any domain ↛ `apps/api` | Reverse dependency: the API consumes domains, never the other way round. |

## Known violations (tech debt)

At their introduction, **16 of 23 rules passed** and **7 failed** — the
violations listed below. Failures are real and left visible on purpose: each is either
fixed by refactoring the offending import or accepted as tracked debt. (As these
imports are refactored, the corresponding entries below stop failing.)

**Core layering**

- `infra/repositories ↛ application` — `coding-agent` `PackmindDeployer` imports
`CommandsIndexService` and `StandardsIndexService`.
- `useCases ↛ infra/repositories` — three `git` use cases import
`GithubTokenResolverFactory` (`addGitProvider`, `updateGitProvider`,
`shared/validateProviderCredentials`).
- `adapter ↛ infra/repositories` — `git` `GitAdapter` → `GithubTokenResolverFactory`;
`llm` `LlmAdapter` → `AIProviderRepository`.
- `services ↛ infra/repositories` — `recipes` `RecipeService` and
`RecipeVersionService` import their concrete repositories directly.

**Domain purity**

- `domain ↛ application` — `domain/jobs/*DelayedJobs` interfaces import concrete
`application/jobs/*DelayedJob` classes in `deployments`, `git`, `recipes` (×2)
and `standards`.
- `domain ↛ infra` — `coding-agent` `ICodingAgentDeployer` imports
`DefaultSkillsDeployer`.

**Cross-domain**

- `deployments` imports `coding-agent`, `recipes`, `skills`, `spaces` and
`standards` directly (use cases / `DefaultSkillsMetadataEnricher` →
`coding-agent`; `PackageRepository` → the four others). The other seven domains
are cleanly isolated.

Standards referenced above live under
`packages/.claude/rules/packmind/` and `apps/api/.claude/rules/packmind/`.
28 changes: 28 additions & 0 deletions packages/architecture-tests/jest.arch.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
const { compilerOptions } = require('../../tsconfig.base.effective.json');

const {
pathsToModuleNameMapper,
swcTransform,
standardTransformIgnorePatterns,
standardModuleFileExtensions,
} = require('../../jest-utils.ts');

// Named `jest.arch.config.ts` (not `jest.config.ts`) on purpose: it keeps the
// @nx/jest plugin from inferring a `test` target for this project, so the
// architecture suite stays OFF `nx run-many -t test` and is only ever run via
// the dedicated `arch` target (`npm run test:arch`).
module.exports = {
displayName: 'architecture-tests',
preset: '../../jest.preset.ts',
rootDir: '.',
testEnvironment: 'node',
testMatch: ['<rootDir>/src/**/*.arch.spec.ts'],
transform: swcTransform,
transformIgnorePatterns: standardTransformIgnorePatterns,
moduleFileExtensions: standardModuleFileExtensions,
coverageDirectory: '../../coverage/packages/architecture-tests',
moduleNameMapper: pathsToModuleNameMapper(
compilerOptions.paths,
'<rootDir>/../../',
),
};
36 changes: 36 additions & 0 deletions packages/architecture-tests/project.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
{
"name": "architecture-tests",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "packages/architecture-tests/src",
"projectType": "library",
"tags": ["env:node"],
"targets": {
"arch": {
"executor": "nx:run-commands",
"cache": true,
"inputs": [
"{workspaceRoot}/packages/*/src/**/*.ts",
"{workspaceRoot}/apps/api/src/**/*.ts",
"{workspaceRoot}/packages/architecture-tests/src/**/*.ts",
"{workspaceRoot}/packages/architecture-tests/jest.arch.config.ts",
"{workspaceRoot}/scripts/build-arch-tsconfig.mjs",
"{workspaceRoot}/scripts/select-tsconfig.mjs",
"{workspaceRoot}/tsconfig.base.json",
"{workspaceRoot}/tsconfig.paths.oss.json",
"{workspaceRoot}/tsconfig.paths.proprietary.json",
"{workspaceRoot}/jest.preset.ts",
"{workspaceRoot}/jest-utils.ts",
{ "env": "PACKMIND_EDITION" },
{ "externalDependencies": ["archunit", "jest"] }
Comment thread
greptile-apps[bot] marked this conversation as resolved.
],
"outputs": [],
"options": {
"parallel": false,
"commands": [
"node scripts/build-arch-tsconfig.mjs",
"NODE_OPTIONS='--max-old-space-size=8192' jest --config packages/architecture-tests/jest.arch.config.ts --runInBand"
]
}
}
}
}
Loading
Loading