Skip to content

The config holds the names of the parts of your code and the rules about them. This page lists each top-level key. Each rule form has its own page.

Archangel looks in the current folder, and then in each folder above it. In each folder, it looks for these names, in this order:

  1. archangel.config.ts
  2. archangel.config.mts
  3. archangel.config.js
  4. archangel.config.mjs
  5. archangel.config.json

It uses the first file that it finds. --config <file> replaces this search.

The folder of the config is the config folder. Archangel reads the source files below it. Each pattern in the config starts at the config folder, and each path in a report is relative to it. Archangel does not add **/ before a pattern.

When archangel finds no config, it stops with config-missing and exit code 2.

archangel.config.ts (and .mts, .js, .mjs) archangel.config.json
Needs Node Yes, 22.18 or later. Node runs it without a build step. No.
Type checks as you write Yes, with defineConfig. Completion and checks from the JSON schema.
rules A list, or a function that gets the rule builders. A list of rule objects.
Presets and plugins Yes. No, because they are code.

Archangel runs a TypeScript or JavaScript config one time for each load. It uses the default export. When rules is a function, archangel calls it one time with the rule builders: rule, mayImport, layers, isolate and entry.

defineConfig changes nothing when the config runs. It gives the types. With it, the TypeScript compiler finds some errors before archangel runs. For example, it finds a rule that names a part that does not exist, and a table cell that names nothing.

A JSON config holds the same content. Each rule is the object that you give to rule(...), and a table or a shorthand has a kind. Add $schema to get completion in your editor:

archangel.config.json
{
"$schema": "./node_modules/@enhance-eng/archangel/archangel.schema.json",
"parts": {
"feature": "src/features/{feature}/**",
"shared": "src/shared/**"
},
"rules": [
{
"kind": "mayImport",
"name": "app",
"rows": { "feature": ["shared"], "shared": [] },
"because": "Shared code never depends on a feature."
}
]
}
Key Type What it does
parts object Named areas of your code. Each file is in one part at most.
groups object Named sets of files. A file can be in many groups.
externals object Named sets of outside packages.
rules list or function The tables and the rules.
extends list Presets, such as fsd(...) and nextjs(...).
off list of names Rules and table rows to turn off.
plugins object Plugins, by the name that rules use.
ignore list of lines Files that archangel does not read.
allowUnresolved object Imports that may stay unresolved, each with a reason.
$schema string JSON only. The path of the JSON schema.

Archangel refuses a key that it does not know, with config-invalid. The message names the nearest correct key.

A config needs at least one part, one rule or one preset. A config with none of these checks nothing, so archangel refuses it.

A part is a named area of your code. Its value is a path pattern or a list of path patterns.

parts: {
feature: 'src/features/{feature}/**',
shared: 'src/shared/**',
app: ['src/app/**', 'src/main.ts'],
},
  • A name in braces, such as {feature}, is a capture. It makes each folder one instance of the part. Read Captures and instances.
  • Each file is in one of your parts at most. When two parts find one file, archangel shows part-overlap.
  • A part that finds no file is the error element-empty.
  • A part holds files, not packages. Write packages in externals.

A group is a named set of files. A file can be in many groups, so a group can cut across parts. Its value is any set of files.

groups: {
test: '**/*.test.ts',
client: { directive: 'use client' },
},

A group that finds no file is the error selector-empty.

An externals set is a named list of outside packages.

externals: {
driver: ['pg', '@aws-sdk/*', 'node:fs'],
},
You write It matches
pg the package pg and each of its subpaths, such as pg/lib/client
@aws-sdk/* each package in the scope @aws-sdk
* each package
node:fs the Node built-in module fs

Archangel refuses these names, because they can never match:

  • a Node built-in module without node:, such as fs. Write node:fs.
  • a name with a subpath, such as pg/lib.
  • a name that starts with @types/.
  • the name of a package in your repository. An import of a workspace package resolves to its files, so use a part.

An import counts as the package that it resolves to. Use an externals set in a table cell, or on the other side of a rule. On the other side of a rule, you can also write the packages directly: { packages: ['react'] }.

The tables and the rules. In TypeScript, write a function that gets the rule builders:

rules: ({ mayImport, rule, layers, isolate, entry }) => [
isolate('feature'),
rule('no-react-in-shared', {
files: 'shared',
mayNotImport: { packages: ['react'] },
examples: { rejected: ['src/shared/a.ts imports react'] },
}),
],

You can also write a list of rule objects, as in JSON. Each rule has a name, and two rules cannot have one name (rule-name-duplicate).

Form Page
mayImport(name, rows, options) Tables
rule(name, { files, <condition> }) Conditions
rule(name, { folders, <condition> }) Conditions
rule(name, { sentences: [...] }) Conditions
rule(name, { files, plugin, options }) Plugin API
layers, isolate, entry Shorthands

A list of presets. Each preset adds parts, groups, rules and plugins, with its name as a prefix.

extends: [fsd({ root: 'src' }), nextjs()],

Read Presets.

A list of rule names and table row names to turn off. Archangel applies off after it merges the presets.

off: ['fsd/insignificant-slice', 'app.every-file-has-a-kind'],

A name in off must be the name of a rule. When it is not, archangel stops and shows the nearest correct name. archangel rules lists the rules that off removes.

The plugins that the config loads, by the name that rules use:

plugins: { team },

A rule then uses a condition of the plugin as plugin: 'team/max-imports'. Read Plugin API.

Lines in .gitignore syntax. Archangel does not read a file that a line matches.

ignore: ['src/generated/', '**/*.stories.tsx'],

A line with no / except at its end matches at any depth, as in .gitignore: 'public/' leaves out every folder named public. Write '/public/' for the folder at the top only. When a part or a group matches no file because of a line, its error names the line.

Archangel already skips each file that a .gitignore file in the repository ignores. It uses only the .gitignore files of the repository. It does not use your global Git ignore file, .git/info/exclude or .ignore files, so each machine gets the same files. A line that is not valid is ignore-line-invalid.

Archangel reads each file below the config folder with the extension .ts, .tsx, .mts, .cts, .js, .jsx, .mjs or .cjs, in hidden folders too. Declaration files (.d.ts) are source files.

Specifiers that may stay unresolved, each with a reason. A specifier can end in *.

allowUnresolved: {
'virtual:*': 'Vite makes these modules when it builds.',
'./generated/routes': 'The build writes this file before it compiles.',
},

An import that archangel cannot resolve is the error import-unresolved, because archangel cannot judge it. A specifier in allowUnresolved gives no error, and the summary counts these imports.

A type import, such as import type { Point } from 'geojson', of a package that only @types installs resolves to that package, as TypeScript resolves it, so it needs no entry here.

An entry that names a package also permits a tsconfig whose extends names that package when it is not installed, such as a sub-project that installs its own packages. '@tsconfig/node20': 'the worker installs its own packages' stops the resolution-file-invalid error for it. The files below that tsconfig are still checked.

Archangel resolves each import as TypeScript does:

  • It uses the nearest tsconfig.json of the importing file, with its extends, baseUrl and paths.
  • It uses the exports and imports fields of package.json.
  • For a specifier that ends in .js, .jsx, .mjs or .cjs, it looks for the TypeScript file, then the declaration file, then the JavaScript file.
  • It follows a symbolic link to the real file. So an import of a workspace package resolves to the files of that package.

Archangel finds import statements, export ... from, dynamic import() and require() with a literal specifier, import x = require(), import types such as import('./x').T, and triple-slash references.

An import type is a type import. With verbatimModuleSyntax in your tsconfig, import { type A } from './a' stays in the compiled code, so it is a runtime import.

A config from archangel 0.1 still loads. A config uses one spelling of each key. Archangel refuses two spellings in one config. These spellings stop at 1.0.

Spelling Older spelling
parts elements
groups selectors
because reason
rule('<name>', { ... }) rule({ name: '<name>', ... })

In 0.1, elements could hold a package set, { packages: [...] }. Write it as an entry in externals now. On the other side of a rule, { packages: [...] } is not an older spelling, and it stays.

The words in some messages still come from 0.1. For example, a message can say “element” for a part, and “selector” for a group.