Skip to content

Latest commit

 

History

821 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

ts-archunit

npm version CI License: MIT Node.js >= 24

Architecture guardrails for AI coding agents. Executable rules that catch structural violations in CI — before they reach your codebase.

Inspired by Java's ArchUnit. Powered by ts-morph.

Documentation · Getting Started · What Can It Check?

The Problem

AI coding agents don't know your architecture. They generate code that compiles, passes type checks, and looks correct in isolation — but violates the structural decisions your team spent months establishing.

An agent will:

  • Call parseInt instead of the shared extractCount() helper
  • Throw new Error() instead of your typed NotFoundError
  • Import the database driver directly from a service instead of going through the repository
  • Copy-paste a parser function instead of using the shared utility
  • Skip validation in a route handler

Code review catches some of this. But at scale — with multiple agents generating PRs across a large codebase — review becomes the bottleneck. You need automated enforcement.

The Solution

ts-archunit turns your architecture decisions into executable tests. They run in CI. Violations show up inline on the PR with clear messages explaining what's wrong, why it matters, and how to fix it — exactly the context an agent needs to self-correct.

classes(p)
  .that()
  .extend('BaseRepository')
  .should()
  .notContain(newExpr('Error'))
  .rule({
    id: 'repo/typed-errors',
    because: 'Generic Error loses context and prevents consistent error handling',
    suggestion: 'Use NotFoundError, ValidationError, or DomainError instead',
  })
  .check()

When an agent violates this rule, it sees:

Architecture Violation [repo/typed-errors]

  WebhookRepository.findById contains new 'Error' at line 42
  at src/repositories/webhook.repository.ts:42

      41 |     if (!result) {
    > 42 |       throw new Error(`Webhook '${id}' not found`)
      43 |     }

  Why: Generic Error loses context and prevents consistent error handling
  Fix: Use NotFoundError, ValidationError, or DomainError instead

The because and suggestion fields give the agent everything it needs to fix the violation without human intervention.

Why Not Just Import Rules?

Every other tool (dependency-cruiser, eslint-plugin-boundaries, ts-arch) only checks which files import which. That's necessary but insufficient.

AI agents don't violate architecture by importing wrong files. They violate it by writing the wrong code in the right place — inlining logic instead of delegating, using raw APIs instead of abstractions, skipping validation, throwing generic errors.

ts-archunit checks what happens inside your functions:

// "Services must delegate to repositories, not hardcode data"
functions(p)
  .that()
  .resideInFolder('**/services/**')
  .should()
  .satisfy(mustCall(/Repository/))
  .check()

// "No eval anywhere in production code"
modules(p).that().resideInFolder('**/src/**').should().satisfy(moduleNoEval()).check()

// "Route handlers must validate input"
functions(p)
  .that()
  .resideInFolder('**/handlers/**')
  .should()
  .satisfy(mustCall(/validate|parse/))
  .check()
Capability ts-archunit dependency-cruiser eslint-plugin-boundaries Biome ts-arch
Import path rules Yes Yes Yes Yes Yes
Body analysis (what's called inside functions) Yes No No No No
Type checking (string vs typed union) Yes No No No No
JSX element rules Yes No No No No
Cross-layer rules Yes No No No Partial
Cycle detection Yes Yes No No Yes
Code smell detection (duplicate bodies) Yes No No No No
Code metrics (cyclomatic, LOC) Yes No No No No
Pattern matching (reusable rule templates) Yes No No No No
Baseline (gradual adoption) Yes No No No No
Diff-aware / PR-only checking Yes No No No No
Watch mode Yes No No Yes No
Inline exclusion comments Yes No Via ESLint Yes No
GitHub PR annotations Yes No Via ESLint No No

Why Deterministic Rules — and Why Now

AI agents don't just make mistakes; they make the same mistakes at volume, and they produce "dark code that passes tests but undermines implicit architectural constraints" (Katie Roberts, NearForm). The field's numbers agree: AI's productivity gains dissipate in ~3 months as architectural debt accrues (Carnegie Mellon, via Sonar), while commits rose 25% as review comments fell 27% and 31% more PRs now merge with no review at all (GitHub / Faros, via eBay's ReviewDebt). Review is the bottleneck — so the architecture dimension of review has to be settled mechanically.

Two kinds of tool are converging on that job. ts-archunit is the one that stays deterministic:

  • SonarQube and other scanners are computational but verify a fixed catalog — generic quality, security, complexity. They can't be taught your invariants ("repositories extend BaseRepository", "every route has a permission-matrix entry").
  • LLM review agents can judge intent, but non-deterministically — "you don't want the green/red state of your pipeline to depend on an LLM's semantic interpretation" (Birgitta Böckeler, ThoughtWorks); the same PR scores differently as the model changes (eBay).

ts-archunit is the deterministic, computational gate for your team's own architecture — the cell neither of the above fills. A rule is defensible in a review, and unlike a prompt or a CLAUDE.md convention (half-life: weeks), it doesn't rot when the model changes.

→ Full argument and sources: Why ts-archunit

Quick Start with Presets

One function call enforces an entire architecture pattern — layer ordering, cycles, import direction, package restrictions:

import { project } from '@nielspeter/ts-archunit'
import { layeredArchitecture } from '@nielspeter/ts-archunit/presets'

const p = project('tsconfig.json')

layeredArchitecture(p, {
  layers: {
    routes: '**/src/routes/**',
    services: '**/src/services/**',
    repositories: '**/src/repositories/**',
  },
  shared: ['**/src/shared/**'],
  strict: true,
})

This generates 5 coordinated rules. Override individual rules without disabling the preset:

layeredArchitecture(p, {
  layers: { ... },
  overrides: {
    'preset/layered/type-imports-only': 'off',
  },
})

Three presets available: layeredArchitecture, dataLayerIsolation, strictBoundaries.

Feed Your Architecture to the Agent

The explain command dumps all active rules as structured JSON — pipe it into your agent's system prompt so it knows the constraints before writing code:

npx ts-archunit explain arch.rules.ts
{
  "rules": [
    {
      "id": "repo/typed-errors",
      "rule": "that extend 'BaseRepository' should not contain new 'Error'",
      "because": "Generic Error loses context and prevents consistent error handling",
      "suggestion": "Use NotFoundError, ValidationError, or DomainError instead"
    }
  ]
}

The agent reads the rules, understands the constraints, and generates compliant code from the start. When it doesn't, CI catches it with actionable violation messages.

Custom Rules

The fluent API reads like English:

// Select → Filter → Assert → Execute
classes(p).that().extend('BaseRepository').should().notContain(call('parseInt')).check()

Body Analysis

Inspect what happens inside functions — the differentiator:

// Ban inline parseInt — use the shared helper
classes(p)
  .that()
  .extend('BaseRepository')
  .should()
  .useInsteadOf(call('parseInt'), call('this.extractCount'))
  .check()

// Services must delegate to repositories
functions(p)
  .that()
  .resideInFolder('**/services/**')
  .should()
  .satisfy(mustCall(/Repository/))
  .check()

// No process.env in domain — use dependency injection
functions(p).that().resideInFolder('**/domain/**').should().satisfy(functionNoProcessEnv()).check()

Layer Enforcement

slices(p)
  .assignedFrom({
    controllers: '**/src/controllers/**',
    services: '**/src/services/**',
    repositories: '**/src/repositories/**',
  })
  .should()
  .respectLayerOrder('controllers', 'services', 'repositories')
  .check()

slices(p).matching('src/features/*/').should().beFreeOfCycles().check()

Type-Level Rules

Check property types using the TypeScript type checker:

types(p)
  .that()
  .haveProperty('orderBy')
  .should()
  .havePropertyType('orderBy', not(isString()))
  .rule({
    because: 'Bare string orderBy is a SQL injection surface',
    suggestion: "Use a union type: orderBy?: 'created_at' | 'updated_at'",
  })
  .check()

Standard Rules Library

25+ ready-to-use rules across 8 categories:

import {
  functionNoEval,
  functionNoConsole,
  functionNoJsonParse,
} from '@nielspeter/ts-archunit/rules/security'
import { functionNoGenericErrors } from '@nielspeter/ts-archunit/rules/errors'
import { mustCall } from '@nielspeter/ts-archunit/rules/architecture'
import { noDeadModules, noStubComments, noEmptyBodies } from '@nielspeter/ts-archunit/rules/hygiene'

functions(p).that().resideInFolder('**/src/**').should().satisfy(functionNoEval()).check()
functions(p).that().resideInFolder('**/src/**').should().satisfy(noEmptyBodies()).check()
functions(p).that().resideInFolder('**/src/**').should().satisfy(noStubComments()).check()

Categories: rules/typescript, rules/security, rules/errors, rules/naming, rules/dependencies, rules/code-quality, rules/metrics, rules/architecture, rules/hygiene.

Baseline Mode

Adopt rules in existing codebases without fixing every pre-existing violation:

const baseline = withBaseline('arch-baseline.json')

// Only NEW violations fail — existing ones are recorded
classes(p).should().notContain(call('parseInt')).check({ baseline })

GitHub Actions Annotations

Violations appear inline on PR diffs — automatically detected in GitHub Actions:

classes(p).should().notContain(call('eval')).check({ format: detectFormat() })

Smell Detection

Find code drift — duplicate function bodies and inconsistent patterns:

// .check() fails the build; .warn() reports without failing. There is no
// default — pick one. An AI agent only reads failures, so prefer .check()
// and accept an existing backlog with withBaseline() rather than warning.
smells.duplicateBodies(p).inFolder('**/src/routes/**').withMinSimilarity(0.9).check()

smells
  .inconsistentSiblings(p)
  .inFolder('**/src/repositories/**')
  .forPattern(call('this.extractCount'))
  .warn()

Assert the project's own TypeScript strictness (with strict-family resolution, so strict: true implies its sub-flags):

tsconfig(p).requires({ strict: true, noUncheckedIndexedAccess: true }).check()

More Features

Entry Points

Function Operates on Use case
modules(p) Source files Import/dependency rules
classes(p) Class declarations Inheritance, decorators, methods, body analysis
functions(p) Functions, arrow functions, class methods Naming, parameters, body analysis
types(p) Interfaces + type aliases Property types, type safety
slices(p) Groups of files Cycles, layer ordering
calls(p) Call expressions Framework-agnostic route/handler matching
within(sel) Scoped callbacks Rules inside matched call callbacks

Compared to Other Tools

Capability ts-archunit dependency-cruiser ArchUnitTS Biome ts-arch
Import path rules Yes Yes Yes Yes (noRestrictedImports) Yes
Body analysis (calls, access, constructors) Yes No No No No
Type checking (resolved types via ts-morph) Yes No No No No
Class rules (inheritance, decorators, members) Yes No No No No
Function rules (params, return types, async) Yes No No No No
JSX element rules Yes No No No No
Cross-layer rules (pair-condition per layer) Yes No No No Partial (layered slices)
Cycle detection Yes Yes Yes No Yes
Parameterized presets Yes Flat config No No No
Pattern matching (reusable rule templates) Yes No No No No
Code smell detection (duplicate bodies, siblings) Yes No No No No
Code metrics Cyclomatic, LOC No LCOM, coupling, instability No No
Baseline / gradual adoption Yes No No No No
Diff-aware / PR-only checking Yes No No No No
Watch mode Yes No No Yes No
Inline exclusion comments Yes No No Yes (biome-ignore) No
JSON output format Yes Yes Via vitest Yes Via test runner
GitHub PR annotations Yes No No No No
Violation messages with fix suggestions Yes No No Partial (autofixes) No
explain command (dump rules as JSON for agents) Yes No No No No
PlantUML diagram compliance No No Yes No No
Dependency graph visualization No Yes (dot, HTML) No No No
License checking No Yes No No No
Nx monorepo support No No Yes No No

Use ts-archunit when you need to enforce what happens inside functions — call patterns, error types, missing delegation, stub comments — and when AI agents are generating code that needs architectural guardrails. This is the only tool that catches "service calls parseInt instead of extractCount()", flags duplicate function bodies as smells, and can restrict checking to only the files changed in a PR.

Use dependency-cruiser when you only need import direction rules and want fast graph visualization, license compliance checking, or stability metrics. It's faster (no ts-morph project load) and has mature HTML/dot reporting.

Use ArchUnitTS when you need OO metrics (LCOM cohesion, coupling factor, distance from main sequence), PlantUML diagram validation, or Nx monorepo project-graph awareness.

Use Biome when you want a fast Rust-based lint + format toolchain and only need lint-level import restrictions (noRestrictedImports) — not architecture rules over class hierarchies, function bodies, or slice cycles. Pair it with ts-archunit if you want both.

Use ts-arch when you want the classic ArchUnit-style fluent API scoped to file/folder dependency rules and layered slices, and don't need body analysis, type-checker rules, or CI integrations like baselines, diff-aware runs, or GitHub annotations.

Use ts-archunit + dependency-cruiser together if you want both body-level enforcement and dependency graph visualization.

Install

npm install -D @nielspeter/ts-archunit
npx ts-archunit init   # scaffold config + rules + npm scripts

init generates a ts-archunit.config.ts, an arch.rules.ts seeded with the recommended safety floor (or --preset agent-guardrails), and arch / arch:baseline scripts — then run npm run arch.

Requires Node.js >= 24 and a tsconfig.json. Works with vitest (recommended) or jest.

Upgrading

CHANGELOG.md ships inside the package, so it is readable at node_modules/@nielspeter/ts-archunit/CHANGELOG.md without a network round trip — which matters because several releases require an action rather than merely describing one. Each entry carries an Upgrading section naming what to run before you upgrade and what changes after.

License

MIT

About

Architecture testing for TypeScript

Resources

Stars

7 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages