Skip to content

CLI

The CLI is the default way to run ts-archunit. npx ts-archunit init scaffolds a rule file (arch.rules.ts) and npm run arch runs it — no test runner required, with baseline, diff-aware checks, and CI-friendly output built in.

Prefer to run architecture rules inside your existing vitest/jest suite instead? That works too and is fully supported — see Running Rules in Tests. This page documents the CLI (the golden-path default); the two forms differ only in how a rule is terminated and run (conversion table on that page).

Commands

init — Scaffold a Project

Generate a working ts-archunit setup in one command — no hand-authoring config or rule files.

bash
# Scaffold with the recommended safety floor (default)
npx ts-archunit init

# Scaffold for an AI-agent workflow
npx ts-archunit init --preset agent-guardrails

# Preview without writing
npx ts-archunit init --dry-run

It creates:

  • ts-archunit.config.ts — the discoverable config (rules, baseline, format).
  • arch.rules.ts — a rule file that spreads your chosen preset in the returning form (export default [...recommended(p)]), with commented examples for adding custom rules.
  • arch-baseline.json — an empty baseline placeholder.
  • package.json scriptsarch (ts-archunit check) and arch:baseline (ts-archunit baseline), added only if absent.
OptionEffect
--preset <name>recommended (default), agent-guardrails, or a shape preset: layered, strict-boundaries, data-layer.
--tsconfig <path>tsconfig to wire in (default tsconfig.json).
--no-baselineSkip arch-baseline.json (and omit the baseline config field).
--forceOverwrite existing files. Without it, init refuses and lists conflicts.
--dry-runPrint what would be created; write nothing.

init is non-destructive by default: if any target file exists it refuses and tells you to re-run with --force or --dry-run. Adopting on an existing codebase: the empty baseline does not protect the first CI run — the recommended floor includes error rules (no-eval, no-function-constructor) that fail on legacy code. Run npm run arch:baseline to snapshot current violations, commit it, then gate CI on arch. Warnings never fail the build; only errors do. One exception: a configuration finding — a rule that cannot enforce anything, such as a dead glob (since 0.34.0) or one that examined zero units (since 0.59.0) — is error regardless of severity and fails the build. 'warn' grades violations of a rule that works; it does not grade a rule that does not.

A shape preset scaffolds arch.rules.ts with the recommended floor plus the chosen architecture preset, pre-filled with example folder globs. Pick by your architecture:

  • layered — classic N-tier: dependencies flow downward, layers stay cycle-free.
  • strict-boundaries — feature modules: each folder imports only from itself and shared.
  • data-layer — repository pattern: repositories extend a base class and throw typed errors.

The scaffolded globs are examples — edit them to your real folders before you trust a green run (a glob that matches no files enforces nothing). Then npm run arch.

check — Run Rules

bash
# Run rules from a file
npx ts-archunit check arch.rules.ts

# Multiple rule files
npx ts-archunit check layers.rules.ts naming.rules.ts body.rules.ts

# With baseline (only new violations fail)
npx ts-archunit check arch.rules.ts --baseline arch-baseline.json

# Diff-aware (only report violations in changed files)
#   Prints how many findings it suppressed — a green --changed run is not a green run
npx ts-archunit check arch.rules.ts --changed --base main

# Watch mode — re-run on file changes
npx ts-archunit check arch.rules.ts --watch

# Output format
npx ts-archunit check arch.rules.ts --format github

baseline — Generate Baseline

bash
# Generate baseline from current violations
npx ts-archunit baseline arch.rules.ts --output arch-baseline.json

Records all existing violations so that check --baseline only fails on new ones. See Gradual Adoption for details.

It prints the delta it applied, because refreshing a baseline is how findings get accepted permanently and the number that matters is how many were newly accepted:

Baseline updated: 41 → 78 entries (+37, −0). The +37 are findings this file now
accepts that it did not before.
Written to: arch-baseline.json

A first run says Baseline created: 78 entries accepted (no previous baseline) — not a delta, because there was nothing to compare against. If no prior identity survived, it says so and names the identity format as the likely cause, since (+78, −78) otherwise reads as "you accepted 78 new findings" when the truth is "nothing could be compared".

Refresh before upgrading, never after — see Upgrading.

explain — Dump Rule Metadata

bash
# JSON output (default)
npx ts-archunit explain arch.rules.ts

# Markdown table
npx ts-archunit explain arch.rules.ts --markdown

# Agent format — imperative markdown for a system prompt / project instructions
npx ts-archunit explain arch.rules.ts --format agent >> CLAUDE.md

Outputs a structured description of every rule — id, description, because, suggestion — without executing them. --format agent emits an imperative "Do NOT … / MUST …" block (with a check-in-loop preamble and sentinel markers) for AI coding agents — see AI Agents. See Explain Command for use cases.

doctor — Report Rules That Enforce Nothing

bash
ts-archunit doctor arch.rules.ts

Reports which rules cannot enforce anything, without evaluating their conditions: a glob that can never match, a rule that selects elements but asserts nothing about them, a rule whose project cannot be identified, a project that loaded no source files at all, and a rule whose own narrowing left it zero subjects to examine. It does materialize each rule's selection — that last check is a fact about the selection, not about the rule text — so it is fast but no longer free.

That last one is the commonest surprise in a monorepo. A solution-style tsconfig.json"files": [] plus "references" — loads nothing itself, so every glob in every rule is dead and none of them is the reason. doctor says so once and names the config, rather than blaming each glob in turn:

  arch.rules.ts
    preset/recommended/no-eval
    project-empty: the project loaded 0 source files (/repo/tsconfig.json), so no glob
    can match. Check that this tsconfig includes your sources — and if it delegates to
    project references ("files": [] with "references"), it loads none of them itself, so
    the rules need the tsconfig that holds your sources rather than this one
demo/typo-in-glob
  reside in folder matching "**/src/reslvers/**"  [selector]
  no-match: these are anchored but matched no file. Common causes: the glob names a
  directory rather than the files inside it (append "/**"), a path segment is
  misspelled, or the directory holds no source files

demo/excluded-by-tsconfig
  reside in folder matching "**/examples/**"  [selector]
  no-match: this path exists and contains TypeScript, but your tsconfig
  include/exclude keeps it out of the project

demo/no-condition
  no-condition: this rule reached .should() but no condition follows, so it asserts
  nothing and can never fail. Add a condition after .should() — or, if this rule is
  generated from configuration, skip generating it when there is nothing to assert;
  if it comes from a preset (ruleId "preset/..."), report it to the preset's author

demo/duplicates
  zero-subjects: This rule examined 0 function bodies (the project loaded 1 file),
  so it enforces nothing as written today. Its narrowing was: minLines(5) —
  minLines defaults to 5, a default you did not write. Either close the gap —
  widen the selector, or add the code it is waiting for — or declare the empty
  state with .expectEmpty() — a declaration is an assertion, not a silencer: it
  fails the day something does match.

zero-subjects reports last, and only when nothing else already explained the emptiness — a dead glob, a missing assertion or an empty project each names its own cause with its own remedy, so reporting this beside one of them would print the derived symptom above the root cause. Note what it does not say: "your filters". The commonest trigger is a default you never wrote — duplicateBodies applies minLines(5) unless you say otherwise — and telling a reader to fix filters they did not write sends them looking for code that is not there.

It reports identities, never totals — which rule file, which rule, and for a dead glob the glob and its position. (Before 0.24.0 this promised a position for every finding, which only a dead glob has: a rule that asserts nothing has no glob and no position, and its only identity was a prose sentence you had to grep for.) It exits non-zero when it reports anything, because an agent reads exit 0 as "nothing to do".

Scope — which rule files it can read

doctor diagnoses rule files the CLI can load: the arch.rules.ts shape init scaffolds, which is the default path. If your rules live in a vitest or jest test file, the CLI cannot import it — that file needs its runner — so call diagnose(rules) in that suite instead. It reports the same findings.

Not a build gate

doctor is a diagnostic you invoke, not a gate — check is the gate. It exits non-zero when it reports anything so an agent does not read exit 0 as "nothing to do", but that is for your terminal, not your pipeline.

What nothing else catches: a dead glob. A rule whose selector can never match certifies nothing, and check does not look — measured, check exits 0 with no output on such a rule, while doctor names the site and exits 1. That is the failure this command exists for. (An unloadable rule file is not the distinguishing case: check already reports that as an error-severity finding with a remedy.)

Options

FlagShortDescription
--baseline <path>Baseline file for filtering known violations
--output <path>Output path for baseline file (default: arch-baseline.json)
--changedOnly report violations in files changed since base branch. Reports how many findings it suppressed, on stderr and in summary.reason for --format json
--base <branch>Base branch for --changed (default: main)
--format <format>Output format: terminal, json, github, auto (default: auto)
--watch-wWatch for file changes and re-run (check command only)
--config <path>Path to config file
--version-vShow version number
--help-hShow help message

Config File

Optional ts-archunit.config.ts in your project root:

typescript
import { defineConfig } from '@nielspeter/ts-archunit'

export default defineConfig({
  project: 'tsconfig.json',
  rules: ['arch.rules.ts'],
  baseline: 'arch-baseline.json',
  format: 'auto',
  watchDirs: ['src'], // directories to watch in --watch mode
})

CLI flags override config file values. Config file overrides defaults.

Rule Files

A rule file default-exports an array of rule builders. check collects each builder's violations, applies baseline/diff filtering, and reports them together:

typescript
// arch.rules.ts
import { project, classes, modules, call } from '@nielspeter/ts-archunit'

const p = project('tsconfig.json')

export default [
  classes(p).that().extend('BaseRepository').should().notContain(call('parseInt')),
  modules(p)
    .that()
    .resideInFolder('**/src/domain/**')
    .should()
    .notImportFrom('**/src/repositories/**'),
]

Rule files use the same API as test files.

Severity

Every rule is an error by default — a violation fails the run. Mark a rule as a non-failing warning with the non-terminal .asSeverity('warn') (with one exception (configuration findings)); it is still reported, but does not fail:

typescript
export default [
  classes(p).that().extend('BaseRepository').should().notContain(call('parseInt')), // error
  modules(p)
    .that()
    .resideInFolder('**/src/**')
    .should()
    .satisfy(noEmptyBodies())
    .asSeverity('warn'),
]

A preset that returns an array of builders spreads straight in:

typescript
export default [...myPreset(p)]

check exits non-zero only when there are error-severity violations; warnings are reported but never fail the run. One exception: a configuration finding — a rule that cannot enforce anything, such as a dead glob (since 0.34.0) or one that examined zero units (since 0.59.0) — is error regardless of severity and fails the build. 'warn' grades violations of a rule that works; it does not grade a rule that does not.

Leave rule-file builders un-terminated

In a rule file, entries in the export default [...] array must be builders, not terminal calls. Do not end them with .check() / .warn() / .severity() — those execute the rule immediately and return undefined, which the CLI silently skips (the rule never runs in the aggregated report). Use the non-terminal .asSeverity('warn') to mark a rule as a warning. (Test files are the opposite: there you do call .check().)

Since 0.29.0 the CLI also tells you when it happened the other way. A terminal at module scope that throws — .warn() on a configuration finding does, since 0.23.0 — aborts the module, so every rule declared after it is never evaluated. The run used to look entirely ordinary; it now reports the truncation and names the rule file, because "fewer findings than yesterday" is the one outcome you must not read as progress.

JSON output (--format json)

check --format json emits a single JSON document for the whole run (all rule files and builders aggregated — not one blob per rule), suitable for CI dashboards, custom tooling, and AI coding agents that self-correct against the rules. Each violation carries its severity, and the summary breaks the count down into errors vs warnings:

jsonc
{
  "summary": { "total": 2, "errors": 1, "warnings": 1, "reason": null },
  // Allowlist rules that had subjects but tested no edges, so they passed
  // without exercising the allowlist. Not a failure: for the `only*` family
  // zero edges is maximal compliance, and a dependency-free module is a
  // legitimate shape. Only you can tell that from a rule certifying nothing.
  "untestedAllowlists": [
    { "rule": "…should only import from \"**/domain/**\"", "subjects": 3, "edges": 0 },
  ],
  "violations": [
    {
      "rule": "…",
      "ruleId": "domain/no-parse-int",
      "severity": "error",
      "element": "OrderService.getTotal",
      "file": "src/domain/order.ts",
      "line": 42,
      "message": "…",
      "because": "…",
      "suggestion": "use this.extractCount()",
      "docs": "…",
      // `"configuration"` when the rule enforces nothing (empty selector, dead
      // glob, no condition) rather than the code being wrong; `"violation"`
      // otherwise. Configuration findings carry
      // `"file": null, "line": null` — they have no source location — and are
      // always `error`, never suppressible. See the AI agents page.
      "kind": "violation",
    },
  ],
  // Findings removed by an inline `// ts-archunit-exclude` comment, by rule and
  // file. Always present; empty when nothing was suppressed. Every other filter
  // in the pipeline reports itself, and this one now does too — a run with every
  // finding excluded should not read the same as a clean one.
  "commentSuppressed": [{ "ruleId": "arch/no-cycles", "file": "src/legacy/gateway.ts" }],
}

because / suggestion / docs come from the rule's .because() / .rule({ … }) metadata when the condition does not set its own — so consumers always get the rule author's rationale and fix.

Watch Mode

--watch re-runs all rules when source files change:

bash
npx ts-archunit check arch.rules.ts --watch
  • Watches src/ by default (configurable via watchDirs in config)
  • Also watches the rule files themselves
  • Debounces rapid saves (250ms window)
  • Clears screen between runs, preserving scrollback
  • Only triggers on .ts / .tsx / .mts / .cts file changes
  • Queues re-runs if a change arrives during an active check

For projects under 500 files, each re-run takes under 3 seconds. For larger projects, consider using vitest --watch with rules in test files instead.

Linux users: fs.watch with recursive watching may need a higher inotify limit:

bash
sudo sysctl fs.inotify.max_user_watches=524288

CI Integration

Architecture rules are tests. If your CI already runs npm test, it already runs architecture rules.

For standalone CI steps:

yaml
# .github/workflows/ci.yml
- run: npx ts-archunit check arch.rules.ts --format github

The --format github flag emits violations as GitHub Actions annotations — they appear inline on PR diffs.

Use --format auto (the default) to auto-detect the environment.

Released under the MIT License.