Skip to content

Add ArchUnitTS architecture tests for hexagonal layering#354

Open
cteyton wants to merge 32 commits into
mainfrom
claude/sharp-goodall-pkw8nx
Open

Add ArchUnitTS architecture tests for hexagonal layering#354
cteyton wants to merge 32 commits into
mainfrom
claude/sharp-goodall-pkw8nx

Conversation

@cteyton

@cteyton cteyton commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

Explanation

Introduces executable enforcement of Packmind's hexagonal (ports & adapters) architecture using ArchUnitTS. The new architecture-tests package contains rules that validate import dependencies across the monorepo's domain packages and API layer, ensuring the documented workflow is followed in practice.

The test suite is intentionally separate from the main test suite (npm run test) and runs via a dedicated npm run test:arch command. It scans the entire monorepo's dependency graph (slower) and surfaces pre-existing violations as failures, allowing the team to track and gradually refactor architecture debt without blocking the main CI pipeline.

Key additions:

  • packages/architecture-tests/ — New package with three rule suites:
    • Layering rules (layering.arch.spec.ts) — Enforce the request flow: controller → service → adapter → use case → application service → repository interface → repository implementation
    • Domain purity rules (domain-purity.arch.spec.ts) — Prevent domain layer from importing application or infrastructure
    • Cross-domain isolation rules (cross-domain.arch.spec.ts) — Ensure domains collaborate only through @packmind/types ports, never by importing each other's source
  • scripts/build-arch-tsconfig.mjs — Generates a self-contained tsconfig.arch.json (git-ignored) with inlined path aliases, required because ArchUnitTS does not resolve extends in tsconfig
  • .github/workflows/architecture.yml — Non-blocking CI job that reports violations without failing the workflow
  • Updated eslint.config.mjs to tolerate jest.*.config.ts naming variants
  • Updated .gitignore to exclude the generated tsconfig.arch.json

Current status: 8 of 15 rules pass; 7 fail with real, documented violations (see README "Known violations" section). Failures are intentionally left visible to track architecture debt.

Type of Change

  • New feature
  • Documentation

Affected Components

  • All domain packages (accounts, spaces, standards, recipes, skills, git, deployments, coding-agent)
  • API app (NestJS controllers)
  • CI/CD (new non-blocking architecture check)

Testing

  • Architecture tests are self-contained and run via npm run test:arch
  • The test suite uses ArchUnitTS to analyze the real TypeScript dependency graph (aliases resolved via tsconfig)
  • Violations point to exact offending files with clickable paths
  • CI job is non-blocking (continue-on-error: true) so pre-existing violations don't block merges
  • No unit/integration tests needed — the architecture rules themselves are the tests

TODO List

  • CHANGELOG Updated (implicit in new feature)
  • Documentation Updated (comprehensive README in packages/architecture-tests/)

Reviewer Notes

The 7 failing rules document real architecture debt (e.g., coding-agent importing concrete repositories, git use cases importing GithubTokenResolverFactory, domain interfaces importing concrete application classes). These are intentionally left failing to make the violations visible and trackable. Each failure references the specific standard it guards (e.g., standard-use-case-architecture-patterns, standard-port-adapter-cross-domain-integration).

The generated tsconfig.arch.json is git-ignored and rebuilt by the arch Nx target before each run, ensuring path aliases always match the current edition.

https://claude.ai/code/session_01KaYguqCn23xyuPLqkj4UtQ

claude added 7 commits June 9, 2026 13:19
…arch target

Add a dedicated Nx project that runs architecture rules via ArchUnitTS,
isolated from the normal test suite:

- add archunit dev dependency
- packages/architecture-tests with project.json exposing a custom 'arch'
  target (not 'test', so nx run-many -t test never picks it up)
- jest.arch.config.ts (non-default name avoids @nx/jest test-target inference)
- scripts/build-arch-tsconfig.mjs generates a self-contained tsconfig.arch.json
  (inlined paths + include) since ArchUnitTS does not resolve tsconfig extends
- src/architecture.ts: shared tsconfig path, domain list and layer globs
- test:arch npm script; gitignore the generated config
- broaden eslint jest-config override to cover jest.*.config.ts

https://claude.ai/code/session_01KaYguqCn23xyuPLqkj4UtQ
Assert the forbidden import shortcuts/back-edges along the
controller -> adapter -> use case -> service -> repository flow:
infra/repositories must not depend on application; use cases, adapters
and services must not import repository implementations; controllers
must not reach persistence directly.

https://claude.ai/code/session_01KaYguqCn23xyuPLqkj4UtQ
Assert the innermost domain layer does not depend on the application or
infrastructure layers.

https://claude.ai/code/session_01KaYguqCn23xyuPLqkj4UtQ
…ules

Assert each domain package does not import another domain's source
directly (cross-domain collaboration must go through @packmind/types
ports). Add README describing every rule, how to run the suite, and the
current known violations surfaced as tech debt.

https://claude.ai/code/session_01KaYguqCn23xyuPLqkj4UtQ
Run the ArchUnitTS architecture suite (npm run test:arch) in a dedicated
workflow on push to main, pull requests and manual dispatch. The job uses
continue-on-error so pre-existing violations are surfaced and monitored
without blocking merges.

https://claude.ai/code/session_01KaYguqCn23xyuPLqkj4UtQ
tsconfig.base.effective.json is git-ignored, so on a fresh CI checkout
Nx project-graph construction fails when the vite/vitest plugins parse
project tsconfigs that extend it. Generate it via select-tsconfig.mjs as a
dedicated step before invoking the arch target, mirroring the quality
workflow.

https://claude.ai/code/session_01KaYguqCn23xyuPLqkj4UtQ
The pre-commit Prettier hook did not run on the initial commits; format
the three flagged files to satisfy the quality workflow's prettier:check.

https://claude.ai/code/session_01KaYguqCn23xyuPLqkj4UtQ
@greptile-apps

greptile-apps Bot commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR introduces executable hexagonal architecture enforcement via ArchUnitTS (packages/architecture-tests), while simultaneously fixing several real cross-domain violations it would otherwise flag — most notably removing direct ClaudePluginDeployer imports from the deployments domain and eliminating PackageRepository's cross-domain ORM reads (RecipeSchema, StandardSchema, etc.).

  • New architecture-tests package: three spec suites (layering, domain purity, cross-domain isolation) plus a boundaries suite, backed by a build-arch-tsconfig.mjs generator and a dedicated arch Nx target. 8/15 rules pass; 7 failures are intentionally left visible to track architecture debt.
  • RenderPackageAsPluginUseCase decoupled: direct import of @packmind/coding-agent's ClaudePluginDeployer replaced by ICodingAgentPort.renderPackageAsClaudePlugin; the new port method is added to @packmind/types and implemented in CodingAgentRepositories.
  • PackageRepository cleaned up: cross-domain entity hydration moved from the infra repository into PackageService via lazily-wired cross-domain ports (setArtefactPorts), called by DeploymentsAdapter.initialize; IPackageRepository gains a single findBySlugsAndSpaceIds replacing the two previous WithArtefacts variants.

Confidence Score: 4/5

Safe to merge after addressing the pre-push hook and the missing continue-on-error in build.yml; the core architecture enforcement and cross-domain refactoring are solid.

The pre-push hook runs arch tests as a hard blocking step while 7/15 rules are intentionally failing, which will abort every developer push until all violations are resolved. The reusable build.yml also adds the architecture-tests job without continue-on-error: true, meaning the entire build workflow will fail on every run while the known violations remain. Both issues directly break day-to-day developer workflow. The rest of the PR — the ArchUnitTS test suites, the ICodingAgentDeployer type migration, PackageRepository cleanup, and RenderPackageAsPluginUseCase decoupling — is well-executed.

.husky/pre-push and .github/workflows/build.yml — both introduce blocking behavior for currently-failing architecture tests.

Important Files Changed

Filename Overview
.husky/pre-push Architecture tests added as a blocking pre-push step; will abort every push while 7/15 rules are intentionally failing, preventing all developer pushes.
.github/workflows/build.yml New architecture-tests job added to the reusable build workflow without continue-on-error: true; will make the workflow fail while 7 known violations remain.
.github/workflows/architecture.yml New workflow: correctly uses continue-on-error: true at the job level to remain non-blocking while surfacing current violations on every PR.
packages/architecture-tests/src/architecture.ts New file: defines shared constants (ARCH_TSCONFIG, DOMAIN_PACKAGES, LAYER_GLOBS, etc.) for ArchUnitTS test suites; filesystem-driven domain discovery is clean and avoids manual lists.
packages/architecture-tests/src/layering.arch.spec.ts New file: 11 layering rules enforcing the hexagonal stack; rules are correctly scoped and commented with the standards they guard.
packages/architecture-tests/src/cross-domain.arch.spec.ts New file: per-domain cross-import isolation rules generated via it.each; correctly excludes the subject domain from its own target regex.
packages/architecture-tests/src/domain-purity.arch.spec.ts New file: two rules preventing domain/ from reaching up into application/ or infra/; known failures are acknowledged inline.
packages/architecture-tests/src/boundaries.arch.spec.ts New file: three reverse-dependency rules ensuring @packmind/types and base packages never import domain packages and no domain imports the API app.
packages/architecture-tests/project.json New file: Nx project with a dedicated arch target; comprehensively-specified inputs ensure cache invalidation when any source file changes.
scripts/build-arch-tsconfig.mjs New file: generates self-contained tsconfig.arch.json with inlined path aliases; fails loudly if the effective config has no paths (preventing false-green runs).
packages/deployments/src/application/services/PackageService.ts Cross-domain artefact hydration moved from the infra repository into the application service via injected ports; lazy setArtefactPorts() wiring is called by DeploymentsAdapter.initialize() before use.
packages/deployments/src/infra/repositories/PackageRepository.ts Removed cross-domain ORM reads (RecipeSchema, StandardSchema, SkillSchema, SpaceSchema); repository now only reads its own tables and returns Package[] with artefact ID lists.
packages/deployments/src/application/useCases/renderPackageAsPlugin/RenderPackageAsPluginUseCase.ts Removed direct import of ClaudePluginDeployer from @packmind/coding-agent; now delegates to ICodingAgentPort.renderPackageAsClaudePlugin, properly isolating the domains.
packages/types/src/coding-agent/ICodingAgentDeployer.ts New file: ICodingAgentDeployer interface and related types migrated from coding-agent's domain layer to @packmind/types, making the port accessible without importing coding-agent source.
packages/coding-agent/src/infra/repositories/CodingAgentRepositories.ts Implements the new renderPackageAsClaudePlugin port method; correct placement in infra as a concrete implementation delegating to ClaudePluginDeployer.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    subgraph CI["CI Workflows"]
        AY["architecture.yml - continue-on-error true"]
        BY["build.yml arch job - missing continue-on-error"]
        HP[".husky/pre-push - blocking step"]
    end

    subgraph ArchTests["packages/architecture-tests"]
        AT["arch Nx target"]
        L["layering.arch.spec.ts"]
        DP["domain-purity.arch.spec.ts"]
        CD["cross-domain.arch.spec.ts"]
        B["boundaries.arch.spec.ts"]
        AT --> L
        AT --> DP
        AT --> CD
        AT --> B
    end

    subgraph CrossDomainFix["Cross-Domain Fix"]
        UC["RenderPackageAsPluginUseCase"]
        PORT["ICodingAgentPort in types"]
        IMPL["CodingAgentRepositories.renderPackageAsClaudePlugin"]
        UC -->|"delegates via port"| PORT
        PORT -.->|"implemented by"| IMPL
    end

    subgraph PackageSvcFix["PackageRepository Cleanup"]
        PS["PackageService.enrichWithArtefacts"]
        PR["PackageRepository.findBySlugsAndSpaceIds returns IDs only"]
        PS --> PR
        PS --> RP["IRecipesPort / IStandardsPort / ISkillsPort / ISpacesPort"]
    end

    CI --> AT
Loading

Reviews (11): Last reviewed commit: "👷 ci(arch): use pnpm in architecture-te..." | Re-trigger Greptile

Comment thread packages/architecture-tests/project.json
Comment thread .github/workflows/architecture.yml
Comment thread scripts/build-arch-tsconfig.mjs Outdated
Comment thread scripts/build-arch-tsconfig.mjs Outdated
claude added 2 commits June 9, 2026 13:55
- project.json: mark the arch target cache:false to guarantee the
  analysis always runs against current sources (Nx never replays a stale
  result). Note: Jest itself does not cache pass/fail results, only
  transformed source, so --no-cache is unnecessary.
- build-arch-tsconfig.mjs: exclude packages/architecture-tests/** from the
  scanned graph so the test-infra package never enters analysis; clarify
  the select-tsconfig dynamic-import comment re: per-process ESM caching.

https://claude.ai/code/session_01KaYguqCn23xyuPLqkj4UtQ
- build-arch-tsconfig.mjs: abort with a clear error if the effective
  tsconfig has no compilerOptions.paths, since unresolved @packmind/*
  aliases would make cross-domain rules pass for the wrong reason.
- layering.arch.spec.ts: scope the describe label to repository
  implementations, matching the rule (infra/repositories, not all infra).

https://claude.ai/code/session_01KaYguqCn23xyuPLqkj4UtQ
Comment thread packages/architecture-tests/src/architecture.ts Outdated
cteyton and others added 18 commits June 9, 2026 16:35
Add `schemas`, `apiAll` and `types` layer globs plus `DOMAIN_SRC_REGEX`
and `SHARED_BASE_REGEX` helpers, consumed by the new layering and
boundary rules. The regex helpers mirror the inline pattern already used
in cross-domain.arch.spec.ts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
New layering rules, all green today:
- application/services must not import use cases or the domain adapter
  (use cases orchestrate services, never the reverse).
- the whole API app (not just controllers) must not import repository
  implementations, and must not import the application layer at all
  (it reaches domains only through @packmind/types ports).
- infra/schemas must not import the application layer (schemas are pure
  ORM mapping; infra/jobs legitimately wires application jobs, schemas
  do not).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
New boundaries.arch.spec.ts asserting the inter-package layering
(apps/api -> domains -> @packmind/types + node-utils/logger):
- @packmind/types must not import any domain (keeps the contract package
  a leaf; a domain import would be a cycle).
- node-utils/logger must not import any domain.
- no domain may import the API app (reverse dependency).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add the application-flow, API-boundary, schema-purity and
shared-package/reverse-dependency rules to the Rules tables, and update
the pass count from 8/15 to 15/22 (the 7 known violations are unchanged).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The API layering rule now uses the broader apiAll glob, leaving
LAYER_GLOBS.controllers unreferenced. Drop the dead entry.

https://claude.ai/code/session_01KaYguqCn23xyuPLqkj4UtQ
llm is a full hexagon domain (domain/application/infra, ILlmPort) that was
absent from DOMAIN_PACKAGES, so cross-domain and reverse-dependency rules
never covered it. Add it (verified: 0 new violations). The list stays
hardcoded on purpose — a domain missing its hexagon layers is a problem to
surface, not to auto-skip.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the hardcoded DOMAIN_PACKAGES array with filesystem discovery:
every packages/* that owns a src/domain/ layer is a domain. The only
hardcoded list is now EXCLUDED_PACKAGES = { ui } (the Chakra frontend
library, not a backend hexagon). New domain packages are covered with no
list to maintain; shared/leaf packages (types, logger, node-utils, ...)
have no domain/ layer so importing them stays legal for everyone.

Derived domain set is identical today (9 domains incl. llm); suite stays
16/23 pass, 7 fail.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Packmind playbook regeneration of the use-case-architecture-patterns
standard: add `alwaysApply: true` frontmatter, drop two redundant rules,
and propagate across the agent rule formats (.claude, .cursor, .github,
.gitlab) plus standards-index, AGENTS.md, packmind-lock.json and
packmind.json.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…services

RecipeService and RecipeVersionService imported concrete infra
repositories solely to use them as constructor default values, which
made the application layer depend on the infra layer and broke the
layering.arch.spec rule "application/services must not depend on
infra/repositories".

All real construction sites (RecipesServices, unit tests) already
inject the repository explicitly, so the defaults were dead weight.
Removing the imports and defaults makes both services depend only on
the domain port interfaces, matching the compliant StandardService.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
GithubAppMode lived in the infra file GithubTokenResolverFactory, so the
application-layer GitAdapter and use cases imported it across the layer
boundary. ArchUnitTS counts type-only imports, so this tripped the
'application layer reaches data only through repository interfaces' rule.

Relocate the type to the shared contract package (@packmind/types/git) as
the single source of truth and repoint all consumers. Also removes the two
duplicate redefinitions in apps/api (edition.ts) and the frontend.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
LlmAdapter constructed AIProviderRepository directly in initialize(),
making the application-layer adapter depend on an infra repository
implementation and violating the layering arch rule.

Add an ILlmRepositories interface (domain) and an LlmRepositories
aggregator (infra) that owns the DataSource-to-repository wiring,
mirroring the git package's GitRepositories pattern. LlmAdapter now
receives ILlmRepositories instead of a DataSource; LlmHexa builds it.
LlmHexa's public constructor signature is unchanged, so callers are
unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Resolve every violation reported by `npm run test:arch` (now 23/23 green)
by routing cross-domain access through @packmind/types ports and fixing
intra-package layer leaks. No behavior change.

Layering (infra ↛ application):
- Move CommandsIndexService/StandardsIndexService (pure formatters) from
  coding-agent application/services to infra/repositories/packmind, beside
  their only consumer PackmindDeployer.

Domain purity (domain ↛ application/infra):
- Move the four I*DelayedJobs interfaces from domain/jobs to application/jobs
  (they hold application job-class instances).
- Move DefaultSkillsDeployResult/DefaultSkillMetadata out of infra into the
  coding-agent deployer port, breaking a domain↔infra cycle.

Cross-domain isolation (packages/<domain>/src ↛ packages/<other>/src):
- Promote the ICodingAgentDeployer port and its DTOs into
  @packmind/types/coding-agent; drop the `ICodingAgentDeployer = unknown`
  stub. coding-agent re-exports them from types for its own use; deployments
  imports the port from @packmind/types.
- Add ICodingAgentPort.renderPackageAsClaudePlugin (contract in
  @packmind/types) implemented inside coding-agent; RenderPackageAsPluginUseCase
  now calls the port instead of instantiating ClaudePluginDeployer directly.
- PackageRepository no longer imports recipes/standards/skills/spaces schemas:
  it returns packages with artefact IDs only, and PackageService hydrates the
  full artefacts through IRecipesPort/IStandardsPort/ISkillsPort/ISpacesPort
  (lazy-wired by the adapter). Standard summary parity kept via
  listStandardVersions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The arch target had cache:false, so test:arch always reran (~14s) even
with no source changes.

Set cache:true with explicit inputs covering the full scan scope — the
arch suite analyzes the whole repo (packages/*/src + apps/api/src), not
just its own package, so default projectRoot-only inputs would replay a
stale PASS after a boundary violation introduced elsewhere. Inputs also
include the tsconfig build scripts, jest preset/utils, the
PACKMIND_EDITION env (it changes path aliases), and the archunit/jest
versions. outputs:[] — cache just replays terminal output.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Symmetric to findById: a single In(ids) query that excludes
soft-deleted rows by default. Gives every repository a batch
primitive so cross-domain hydration can avoid per-id fan-out.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Exposes a batched, access-control-free read of recipes by IDs
through IRecipesPort (service -> use case -> adapter), backed by
the new AbstractRepository.findByIds. Lets cross-domain artefact
hydration fetch all recipes in one query instead of per-id.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds StandardRepository.findByIds and a lean
StandardVersionRepository.findByStandardIds, then exposes
IStandardsPort.getStandardsByIds. The adapter resolves all
standards and their versions in one query each and rebuilds the
current-version summary map, matching the previous repository
hydration behaviour without per-id fan-out.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Exposes ISkillsPort.getSkillsByIds backed by
SkillRepository.findByIds (service -> adapter), mirroring getSkill
for a set of IDs so cross-domain artefact hydration fetches all
skills in one query instead of per-id.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
cteyton and others added 5 commits June 10, 2026 13:49
enrichWithArtefacts now resolves recipes/standards/skills through
the new batched ports (getRecipesByIdsInternal / getStandardsByIds
/ getSkillsByIds) in one query apiece instead of one call per id,
removing the O(N) cross-domain fan-out on package download/deploy.
Drops loadStandardWithSummary (the summary join now lives in the
standards domain) and documents the setArtefactPorts precondition
on the public methods.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a dedicated architecture-tests job to the build workflow so any
architecture rule violation fails the build. Also wire the arch target
into the pre-push hook to catch violations before pushing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The architecture-tests jobs still used `cache: 'npm'` + `npm ci`, but the
repo migrated to pnpm (pnpm-lock.yaml, packageManager: pnpm@11.5.0). setup-node's
npm cache looks for package-lock.json/yarn.lock, which no longer exist, so the
jobs failed with "Dependencies lock file is not found".

Switch both jobs (architecture.yml and build.yml) to pnpm/action-setup, the
pnpm cache, and `pnpm install --frozen-lockfile`, mirroring the build-cli job.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@sonarqubecloud

Copy link
Copy Markdown

@cteyton cteyton self-assigned this Jun 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants