Skip to content

How archangel works

Markdown for agents

Archangel does two things. It makes a map of your code. Then each rule asks a question about the map. This page tells you how each step works, so you can reason about what a rule does.

Archangel reads each source file below the config folder one time. For each file, the map holds:

  • its path;
  • its part, its instance and its groups;
  • its imports, and the file or package that each import resolves to;
  • the files that import it;
  • what it exports.

Archangel reads the files that Git does not ignore, with the extensions .ts, .tsx, .mts, .cts, .js, .jsx, .mjs and .cjs. It resolves each import as TypeScript does, with your tsconfig paths and the exports of each package.json.

The map has no gaps. A file that archangel cannot parse is an error. An import that it cannot resolve is an error, unless the config permits it with allowUnresolved. Archangel never skips a file or an import without a word.

An import() of a template literal, such as import(`./locales/${code}.json`), is an import of each file that it can load, as bundlers treat it: each ${…} is one file or folder name. It must start with ./ or ../ and end in a written extension, as Vite asks. Archangel cannot follow any other computed specifier, such as import(url), and the summary lists each one by file and line.

The config gives names to sets of files:

  • A part is a named area, such as src/features/{feature}/**. Each file is in one part at most.
  • A capture, such as {feature}, makes each folder one instance of the part.
  • A group is a named set of files that can cut across parts, such as the test files.
  • An externals set is a named set of outside packages.
  • In a table, a kind is a name that a pattern gives to a file, such as entity in user.entity.ts.

Read Concepts for each one.

Each rule is a question about part of the map:

Rule The question
a table row Does a file in this row import something that the row does not list?
mayNotImport Does a file in these files import the other side?
noCycles Do these files import each other in a cycle?
mustBeNamed Does the name of each file match?
a plugin condition Your own question, as a TypeScript function.

When a rule decides to permit or refuse an import, it judges the import. An import that no rule judges is not checked. archangel rules lists these imports, so you can see the gaps.

You use three commands together:

  1. archangel check asks each rule its question, and shows each problem. This is the test of your code.

    Terminal
    $ npx archangel check
    error[may-import]: entity may not import event, through "./user-created.event": entity may import only value-object, and itself
    --> src/domain/user.entity.ts:2:29
    |
    2 | import { userCreated } from './user-created.event';
    | ^^^^^^^^^^^^^^^^^^^^^^ entity may import only value-object, and itself
    |
    = help: remove the import, or move the code to a file whose row permits the import
    = note: rule: model.entity
    = note: because: An entity does not know its events. A service publishes them.
    error[may-import]: routes may not import the package `pg`, through "pg": routes may import only services, and itself
    --> src/routes/health.ts:1:22
    |
    1 | import { Pool } from 'pg';
    | ^^^^ routes may import only services, and itself
    |
    = help: remove the import, or move the code to a file whose row permits the import
    = note: rule: backend.routes
    = note: because: A route calls a service. Only db talks to the database. The domain depends on nothing.
    checked 8 files and 10 imports: 2 problems
    For what a code means, run `archangel docs may-import`.
  2. archangel test checks the examples of each rule. This is the test of your rules. A rule with a typing error in a path finds no file, so it cannot reject its example, and the test fails.

    Terminal
    $ npx archangel test
    pass no-cycles rejected #1 src/services/a.ts, src/services/b.ts
    1 examples, none failed; 0 rules without a rejected example
    7 table rows need no written example; `archangel rules` shows what each covers
  3. archangel rules shows what each rule judged, and what no rule judges. This is how you see the reach of your tests.

    Terminal
    $ npx archangel rules
    backend 4 rows archangel.config.ts:14
    because: A route calls a service. Only db talks to the database. The domain depends on nothing.
    backend.routes: routes may import only services, and itself
    2 files · 2 imports judged · 1 problem · 0 exceptions
    backend.services: services may import only domain, db, and itself
    1 file · 2 imports judged · 0 problems · 0 exceptions
    backend.db: db may import only domain, driver, and itself
    1 file · 2 imports judged · 0 problems · 0 exceptions
    backend.domain: domain may import only itself
    3 files · 3 imports judged · 0 problems · 0 exceptions
    backend.every-file-has-a-kind: a file in no row is not checked here; add `files` to check it
    7 files · 0 problems
    model 3 rows archangel.config.ts:23
    because: An entity does not know its events. A service publishes them.
    model.entity: entity may import only value-object, and itself
    1 file · 2 imports judged · 1 problem · 0 exceptions
    model.event: event may import only entity, value-object, and itself
    1 file · 1 import judged · 0 problems · 0 exceptions
    model.value-object: value-object may import only itself
    1 file · 0 imports judged · 0 problems · 0 exceptions
    model.every-file-has-a-kind: every file of model has a row
    3 files · 0 problems
    no-cycles archangel.config.ts:33
    because: Files that import each other cannot change one at a time.
    files in src/** may not import each other in a cycle
    7 files · 0 problems
    Not judged by any rule: 0 files, 0 imports between repository files.
    Outside packages that no rule names: none.

When check passes, test passes, and rules shows no gap that you care about, your architecture is tested.

Which file imports which file is a fact about the code. Which imports are permitted is a decision in the config. Archangel keeps the two separate:

  • A change to the config does not read the code again.
  • A change to one file reads only that file again.
  • An example of a rule is a small map in memory. So archangel test checks a rule with a few files, and writes no file.
  • A Rust core reads and parses each file one time, with oxc.
  • The work of a check grows with the number of files and imports. The cost of one import does not grow with the number of rules.
  • Archangel needs no type checker, because it checks only structure.
  • In the editor and in watch mode, archangel keeps the map in memory. After a change, it reads only the changed files, and checks only what the change can affect.

On a 9,800-file monorepo, a full check of 400 rules takes 1.9 seconds on a laptop. The editor shows the result of a change in about 13 milliseconds.

--stats shows the work of a check as counts, such as the files read and the imports resolved. Read Output and exit codes.

A TypeScript config, and each plugin, runs in Node 22.18 or later. Archangel runs the config one time for each load. Plugins stay in that Node process, and archangel sends them only the facts that they read. A JSON config needs no Node.