This is the full developer documentation for Archangel # Make architecture verifiable. > Architecture tests for TypeScript. Write each decision about your code as a test. Archangel checks it in milliseconds, in your editor, in CI and for your coding agents. ## Your architecture, as a test A decision such as “features do not import each other” is easy to write in a document. It is also easy to break with one line in a large change. A reviewer sees the diff, not the graph of imports. Archangel makes the decision a test. Give names to the parts of your code. Then write a table: each row is a part, and the list on the row is what that part may import. archangel.config.ts ```ts import { defineConfig } from '@enhance-eng/archangel'; export default defineConfig({ parts: { feature: 'src/features/{feature}/**', shared: 'src/shared/**', }, rules: ({ mayImport, rule }) => [ mayImport('app', { feature: ['shared'], shared: [], }, { because: 'A feature changes alone. Code that two features need goes in shared.', }), rule('no-react-in-shared', { files: 'shared', mayNotImport: { packages: ['react'] }, because: 'Shared code also runs on the server.', examples: { rejected: ['src/shared/money.ts imports react'], }, }), ], }); ``` The checkout feature imports a file inside the cart feature. Archangel shows where, why, and what to do: Terminal ```ansi $ npx archangel check error[may-import]: feature may not import another instance of feature, through "../cart/cart": feature may import only shared, and itself in the same instance --> src/features/checkout/checkout.ts:1:27 | 1 | import { cartTotal } from '../cart/cart'; | ^^^^^^^^^^^^^^ feature may import only shared, and itself in the same instance | = help: remove the import, or move the code to a file whose row permits the import = note: rule: app.feature = note: because: A feature changes alone. Code that two features need goes in shared. checked 5 files and 4 imports: 1 problem For what a code means, run `archangel docs may-import`. ``` ## What archangel checks Imports Which code may import which code, in a table. Direct imports, chains of imports, the names that an import brings in, and packages that `package.json` does not list. Cycles Files that import each other. Runtime imports count. Type imports count only when you say so. Structure Where files must be, which files a folder must contain, and which files must not exist. Names and exports The style of file and folder names, lists of permitted names, and what a file must export. ## A rule that cannot fail is a defect A rule with a wrong path finds no files. It then shows “no problems”, the same as a rule that works. Archangel does not let this occur in silence: * A part, a group or a table that finds no file is an error. * Each rule that is not a table row has an example of code that it must reject. `archangel test` fails when the rule does not reject it. * `archangel rules` shows each rule as a sentence, with the files and imports that it judged, and the files that no rule judges. ## Fast enough for each keystroke ![Seconds to check the same 400 architecture rules on a 9,800-file TypeScript monorepo: archangel 1.9 s; dependency-cruiser, ESLint and Steiger together 67.7 s](/archangel/_astro/speed-light.Dm2MVhY6.svg) ![Seconds to check the same 400 architecture rules on a 9,800-file TypeScript monorepo: archangel 1.9 s; dependency-cruiser, ESLint and Steiger together 67.7 s](/archangel/_astro/speed-dark.BDPmze9G.svg) A Rust core reads each file one time. The chart shows the time to check the same 400 rules on a 9,800-file monorepo, on a laptop. Without archangel, these rules need three tools. The editor shows the result of a change in about 13 milliseconds. ## Start here [Getting started](/archangel/start/getting-started/)Install archangel, write a first table, and see it find a problem. [Add archangel to an existing repository](/archangel/guides/existing-repository/)Start on code that already breaks your rules, and stop new breaks. [Concepts](/archangel/concepts/parts-groups-and-externals/)Parts, groups, kinds, tables and cells, in plain words. [Reference](/archangel/reference/config/)Each config key, condition, command and diagnostic code. # Captures and instances > A capture such as {feature} makes each folder one instance of a part. Rules can treat an import inside one instance differently from one between two. Many architectures have a set of folders with the same shape: features, modules, packages. Each folder must stay apart from the others. A **capture** lets one part describe all of these folders, and still tell them apart. ## A capture takes a name from the path A capture is a name in braces in a path pattern. It takes one folder name from the path of each file. | Pattern | File | Captures | | --------------------------- | ---------------------------------- | ----------------------------------------- | | `src/features/{feature}/**` | `src/features/cart/views/page.tsx` | `feature` = `cart` | | `src/{area}/{module}/**` | `src/billing/invoices/invoice.ts` | `area` = `billing`, `module` = `invoices` | Braces with a comma are not a capture. `src/{ui,lib}/**` matches `src/ui` or `src/lib`. ## An instance is the files with the same captures An **instance** is the set of files that have the same value in each capture of their part. With the pattern `src/{area}/{module}/**`, this repository has three instances of the part `module`: * src/ * billing/ * invoices/ instance `billing/invoices` * invoice.ts * number.ts * payments/ instance `billing/payments` * refund.ts * shipping/ * invoices/ instance `shipping/invoices` * label.ts `billing/invoices` and `shipping/invoices` are two instances. They have the same `module`, but a different `area`. ## Rules that use instances This config keeps the modules apart with one shorthand: archangel.config.ts ```ts import { defineConfig } from '@enhance-eng/archangel'; export default defineConfig({ parts: { module: 'src/{area}/{module}/**', }, rules: ({ isolate }) => [ isolate('module', { because: 'A module changes alone. Modules talk through events, not imports.', examples: { rejected: ['src/sales/orders/a.ts imports src/sales/quotes/b.ts'], }, }), ], }); ``` `archangel explain` shows the instance of the file, and the instance of each import: Terminal ```ansi $ npx archangel explain src/billing/invoices/invoice.ts src/billing/invoices/invoice.ts part: "module", instance "billing/invoices" groups: none rules that apply: isolate(module) isolate(module): files in module may not import module in a different instance (judges its imports and imports of it) imports: line 1 './number' -> src/billing/invoices/number.ts ("module", instance "billing/invoices") line 2 '../payments/refund' -> src/billing/payments/refund.ts ("module", instance "billing/payments") breaks isolate(module) imported by: none ``` The import on line 1 stays in the instance, so `isolate` permits it. The import on line 2 goes to another instance, so `isolate` refuses it: Terminal ```ansi $ npx archangel check error[isolate]: "billing/invoices" of "module" imports "billing/payments" of "module" through "../payments/refund", and instances of "module" may not import each other --> src/billing/invoices/invoice.ts:2:24 | 2 | import { refund } from '../payments/refund'; | ^^^^^^^^^^^^^^^^^^^^ move what both need into an element that instances of "module" may share | = note: rule: isolate(module) = note: because: A module changes alone. Modules talk through events, not imports. checked 5 files and 3 imports: 1 problem For what a code means, run `archangel docs isolate`. ``` The rule has no `name`, so archangel names it after the rule itself: `isolate(module)`. Use this name in an exception, in the baseline and in `off`. These rules use instances: | Rule | What it does with instances | | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | | A table row | A row may import files of its own part or kind in the same instance. An import of another instance of its own name needs the name in the row’s list. | | `isolate('feature')` | An instance may not import another instance of the same part. | | `entry('feature', 'index.ts')` | Code outside an instance may import it only through its entry files. | | `mayOnlyImport` | When `files` is one part with a capture, the rule permits imports in the same instance only. To permit each instance, add the part to the list. | | `instance: 'same'` or `'different'` | An import condition then applies only to imports in the same instance, or only to imports between two instances. `files` must be a part with a capture. | | `mustContain` | Each instance must have a file that matches the pattern. | So the table row `feature: ['shared']` reads: “a feature may import shared, and files of its own feature”. Write `feature: ['shared', 'feature']` to also permit imports between two features. ## Select one instance To write a rule about one instance only, select it by its captures: ```ts rule('login-no-react', { files: { element: 'feature', capture: { feature: 'login' } }, mayNotImport: { packages: ['react'] }, examples: { rejected: ['src/features/login/a.ts imports react'] }, }) ``` ## Rules that need a capture `isolate` needs a part with a capture, because without one the part has only one instance. If you name a part without a capture, archangel shows the error [`rule-needs-capture`](/archangel/reference/diagnostics/rule-needs-capture/). With `defineConfig`, the TypeScript compiler finds this error before archangel runs. # Examples prove rules > A rule can be wrong and still pass. Examples are code that a rule must accept or reject, and archangel test checks them in memory, laid over your repository. A rule that finds no problem looks the same as a rule that checks nothing. A typing error in a path makes a rule find no files, and the check then shows “no problems”. Examples prove that each rule can find what it is for. ## Accepted and rejected An example is a small set of files. A rule has two lists of them: * **Rejected:** code that the rule must refuse. A rejected example proves that the rule can find a problem. Each rule that is not a table row must have one. * **Accepted:** code that the rule must permit. An accepted example proves that the rule does not refuse correct code. Write an example on one line, or as files with their text: ```ts rule('no-react-in-shared', { files: 'shared', mayNotImport: { packages: ['react'] }, examples: { accepted: ['src/shared/price.ts imports src/shared/money.ts'], rejected: [{ 'src/shared/price.ts': "import { useState } from 'react';" }], }, }) ``` [The examples reference](/archangel/reference/examples/) tells you how to write one line, and where archangel refuses one line. ## What `archangel test` does 1. Archangel reads the repository one time, as a check does. 2. For each example, it lays the example’s files over the repository, in memory. A file of the example at the path of a repository file replaces that file, for that example only. A file of the example can import files of the repository. 3. It checks only the files of the example, and their imports. It does not read the repository again, and it does not write a file to disk. 4. It decides the result: * A rejected example passes only when **its own rule** reports a problem that involves a file of the example. A problem from another rule, or from a repository file, does not count. * An accepted example passes only when its own rule reports no problem in the example, and the rule looked at the example. A rule that never looked at it proves nothing. 5. It fails each rule that has no rejected example, with the error `rule-unproven`. ## Three mistakes that examples find This config has three mistakes. Each one looks correct when you read it: archangel.config.ts ```ts import { defineConfig } from '@enhance-eng/archangel'; export default defineConfig({ parts: { feature: 'src/features/{feature}/**', shared: 'src/shared/**', }, rules: ({ rule }) => [ rule('feature-entry', { folders: 'src/features/*', mustContain: 'index.ts', examples: { accepted: ['src/shared/format.ts'], rejected: ['src/features/cart/model.ts'], }, }), rule('no-react-in-shared', { files: 'shared', mayNotImport: { packages: ['react'] }, examples: { accepted: ['src/shared/format.ts imports src/shared/money.ts'], }, }), ], }); ``` Terminal ```ansi $ npx archangel test fail feature-entry accepted #1 src/shared/format.ts "feature-entry" never looked at a folder of this example, so accepting it proves nothing; give the example a file in a folder the rule names fail feature-entry rejected #1 src/features/cart/model.ts no rule rejected the example, and "feature-entry" should have; fix the rule or the example pass no-react-in-shared accepted #1 src/shared/format.ts, src/shared/money.ts error[rule-unproven]: the rule "no-react-in-shared" has no rejected example, so nothing shows that it can find a violation; add one under examples.rejected | = note: rule: no-react-in-shared 3 examples, 2 failed; 1 rules without a rejected example ``` 1. **The accepted example of `feature-entry` proves nothing.** Its only file is in `src/shared`, and the rule looks only at the folders in `src/features`. The rule permits it because it never looked at it. 2. **The rejected example of `feature-entry` fails.** The example puts `model.ts` in `src/features/cart`. The examples are laid over the repository, and the repository already has `src/features/cart/index.ts`. So the folder `cart` has an index file, and the rule permits it. 3. **`no-react-in-shared` has no rejected example.** Nothing shows that it can refuse an import. Here is the fixed config. Each example uses a folder that the repository does not have, and each rule has a rejected example: archangel.config.ts ```ts import { defineConfig } from '@enhance-eng/archangel'; export default defineConfig({ parts: { feature: 'src/features/{feature}/**', shared: 'src/shared/**', }, rules: ({ rule }) => [ rule('feature-entry', { folders: 'src/features/*', mustContain: 'index.ts', examples: { accepted: [{ 'src/features/search/index.ts': '', 'src/features/search/query.ts': '' }], rejected: ['src/features/search/query.ts'], }, }), rule('no-react-in-shared', { files: 'shared', mayNotImport: { packages: ['react'] }, examples: { accepted: ['src/shared/format.ts imports src/shared/money.ts'], rejected: ['src/shared/format.ts imports react'], }, }), ], }); ``` Terminal ```ansi $ npx archangel test pass feature-entry accepted #1 src/features/search/index.ts, src/features/search/query.ts pass feature-entry rejected #1 src/features/search/query.ts pass no-react-in-shared accepted #1 src/shared/format.ts, src/shared/money.ts pass no-react-in-shared rejected #1 src/shared/format.ts 4 examples, none failed; 0 rules without a rejected example ``` Use names that the repository does not have Examples are laid over your real files. Use folder names such as `search` or `example-login`, which your repository does not have. Then the result depends only on the example. ## Tables and presets * **A table row needs no written example.** `archangel rules` shows what each row judged, and a table that finds no file is the error `table-empty`. You can write examples for a table. They run against all its rows, and a rejected example passes when one row refuses one of its imports. * **A preset brings its own examples.** `archangel test` checks them in your repository, under the preset’s root, with your tsconfig. ## Run it in CI Run `archangel test` next to `archangel check`. A change to the config can break an example, and a change to the code can too, because examples are laid over the code. See [Use archangel in CI](/archangel/guides/ci/). # Kinds > A kind is a row name that one pattern gives to each file, such as entity for order.entity.ts. Kinds let one table cover many files with no declarations. Some codebases say what a file is in its name or its folder: `order.entity.ts`, `order.repository.ts`, `views/cart-page.tsx`. A **kind** turns that name into a table row. You write one pattern, and archangel gives each file its kind. You do not declare a part for each kind. ## The kinds pattern A table’s `kinds` option is a path pattern with the capture `{kind}`. The value of the capture is the file’s kind. | Pattern | File | Kind | | -------------------------- | ---------------------------------- | -------- | | `**/*.{kind}.ts` | `src/orders/order.entity.ts` | `entity` | | `src/features/*/{kind}/**` | `src/features/cart/views/page.tsx` | `views` | | `**/{kind}/**/*.{kind}.ts` | `src/entity/order.entity.ts` | `entity` | * `{kind}` can be in a pattern two times. Then the two places must give the same name. * A declaration file counts `.d.ts` as one extension. So `env.d.ts` has no kind `d`. * A file that the pattern does not match has no kind. ## A table of kinds This table uses the end of each file name as its kind. It also has one row, `shared`, that is a part. One table can mix kinds, parts and groups: archangel.config.ts ```ts import { defineConfig } from '@enhance-eng/archangel'; export default defineConfig({ parts: { shared: 'src/shared/**', }, rules: ({ mayImport }) => [ mayImport('orders', { service: ['repository', 'entity', 'shared'], repository: ['entity', 'shared'], entity: ['shared'], shared: [], }, { kinds: 'src/**/*.{kind}.ts', files: 'src/**', because: 'A service uses repositories. A repository stores entities. An entity knows only itself.', }), ], }); ``` `archangel explain` shows the kind of a file. The file is in no part, but it has the kind `service` in the table `orders`: Terminal ```ansi $ npx archangel explain src/orders/place-order.service.ts src/orders/place-order.service.ts part: no part; no file pattern matches it: "shared" src/shared/**: the path does not start with "src/shared/" kind: service in orders groups: none rules that apply: orders.service orders.service: service may import only repository, entity, shared, and itself (judges its imports) imports: line 1 './order.entity' -> src/orders/order.entity.ts line 2 './order.repository' -> src/orders/order.repository.ts line 3 './order.helpers' -> src/orders/order.helpers.ts breaks orders.service imported by: none ``` ## A new kind is not a silent gap The repository has a file that the table does not know: `order.helpers.ts`. Its kind is `helpers`, and the table has no row for it. Two rules find it: Terminal ```ansi $ npx archangel check error[every-file-has-a-kind]: src/orders/order.helpers.ts has the kind helpers, and the table "orders" has no row for it: no row for helpers. To fix this, add a row, rename the file, or leave it out of the table's files with `not` --> src/orders/order.helpers.ts = help: add a row, rename the file, or leave it out of the table's files with `not` = note: rule: orders.every-file-has-a-kind = note: because: A service uses repositories. A repository stores entities. An entity knows only itself. error[may-import]: service may not import helpers, through "./order.helpers": no row for helpers, and service may import only repository, entity, shared, and itself --> src/orders/place-order.service.ts:3:28 | 3 | import { orderTotal } from './order.helpers'; | ^^^^^^^^^^^^^^^^^ service may import only repository, entity, shared, and itself | = help: remove the import, or move the code to a file whose row permits the import = note: rule: orders.service = note: because: A service uses repositories. A repository stores entities. An entity knows only itself. checked 6 files and 7 imports: 2 problems For what a code means, run `archangel docs every-file-has-a-kind` or `archangel docs may-import`. ``` * `orders.every-file-has-a-kind` refuses the file itself. This rule checks only the files in the table’s `files`, here `src/**`. Without `files`, this rule checks nothing. * `orders.service` refuses the import of the file, because the row `service` does not list `helpers`, and the table has no row for it. To fix it, decide where helpers belong in your architecture. Add a row, and name it in the rows that may import it: archangel.config.ts ```ts import { defineConfig } from '@enhance-eng/archangel'; export default defineConfig({ parts: { shared: 'src/shared/**', }, rules: ({ mayImport }) => [ mayImport('orders', { service: ['repository', 'entity', 'helpers', 'shared'], repository: ['entity', 'shared'], entity: ['shared'], helpers: ['entity', 'shared'], shared: [], }, { kinds: 'src/**/*.{kind}.ts', files: 'src/**', because: 'A service uses repositories. A repository stores entities. An entity knows only itself.', }), ], }); ``` Terminal ```ansi $ npx archangel check checked 6 files and 7 imports: no problems ``` You can also rename the file so that it has a kind with a row, or leave it out of the table’s `files` with `not`. ## Kinds or parts | Use | When | | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | A kind | One pattern gives the names, for many folders or file endings. You add a kind when you add the first file with that ending, and the table tells you if it has no row. | | A part | The area has a name of its own, and rules other than this table use it. A rule’s `files` can name a part. | A kind has a meaning only in its table. A rule’s `files` cannot name a kind. To select the same files in a rule, write the pattern, for example `files: 'src/**/*.entity.ts'`. \`files\` does not change a kind The `files` option chooses only which importers the table judges, and which files must have a row. A file outside `files` still has its kind when another file imports it. # Parts, groups and externals > The three kinds of names that a config gives to code. A file is in one part at most, in many groups, and an externals set names outside packages. Before you write a test, you give names to sets of code. A rule then says “these files may not import those files”, and each name in the sentence is one of these sets. Archangel has three types of names: | Name | What it holds | How many per file | | ----------------- | ------------------------------------------------------- | ----------------- | | **Part** | Files, found by a path pattern | One part at most | | **Group** | Files, found by a pattern, a directive or a combination | Any number | | **Externals set** | Outside packages, by package name | Not about files | This config uses all three: archangel.config.ts ```ts import { defineConfig } from '@enhance-eng/archangel'; export default defineConfig({ parts: { feature: 'src/features/{feature}/**', shared: 'src/shared/**', }, groups: { tests: '**/*.test.ts', client: { directive: 'use client' }, }, externals: { browser: ['react', 'react-dom'], }, rules: ({ mayImport, rule }) => [ mayImport('app', { feature: ['shared', 'browser'], shared: [], }), rule('tests-stay-out', { files: { not: 'tests' }, mayNotImport: 'tests', because: 'Code that ships never needs a test file.', examples: { rejected: ['src/shared/a.ts imports src/shared/a.test.ts'] }, }), ], }); ``` ## Parts A part is a named area of your code. Archangel gives each file one part at most, so a part answers the question “where does this file live?”. Parts are the names that most tables and rules use. * A part is a path pattern, or a list of path patterns. Paths start at the config folder. * A file that no part finds is in no part. That is not an error. To make it an error, write a rule with [`mustNotExist`](/archangel/reference/conditions/) on the files that are in no part. * If two parts find one file, archangel shows the error [`part-overlap`](/archangel/reference/diagnostics/part-overlap/). It cannot know which part judges the file. Narrow one pattern with `not`, or make one of the two a group. * If a part finds no file, archangel shows the error [`element-empty`](/archangel/reference/diagnostics/element-empty/). A part that finds nothing usually has a typing error in its pattern. A part can have a **capture**, such as `{feature}`. A capture makes each folder one **instance** of the part. [Captures and instances](/archangel/concepts/captures-and-instances/) tells you more. Older spelling Archangel 0.1 called parts `elements`. A config can still use `elements`, but not both spellings. ## Groups A group is a named set of files that can cross parts. Use a group for files that have something in common, but live in many places: tests, stories, client components, generated code. * A file can be in many groups, and in a part too. * A group can be a path pattern, a list, a directive such as `{ directive: 'use client' }`, or a combination with `all`, `any` and `not`. [Sets of files](/archangel/reference/sets-of-files/) lists each form. * A group that finds no file is the error [`selector-empty`](/archangel/reference/diagnostics/selector-empty/). ## Externals sets An externals set is a named set of outside packages. Use one in a table cell, so that a row may import those packages. On the other side of a rule, you can use the name, or write the packages in place with `{ packages: ['react'] }`. * A name matches the package and each of its subpaths. `react` matches `react/jsx-runtime`. * `@aws-sdk/*` matches each package in a scope. `*` matches each package. * Write a Node built-in module with `node:`, for example `node:fs`. Archangel refuses `fs`. * Archangel refuses a name with a subpath, a name that starts with `@types/`, and the name of a package in your repository. ## See the names of a file `archangel explain` shows the part, the instance and the groups of one file: Terminal ```ansi $ npx archangel explain src/features/cart/cart-button.tsx src/features/cart/cart-button.tsx part: "feature", instance "cart" groups: client rules that apply: app.feature, tests-stay-out app.feature: feature may import only shared, browser, and itself in the same instance (judges its imports) tests-stay-out: files in not tests may not import tests (judges its imports) imports: line 2 'react' -> package react ("browser") line 3 '../../shared/money' -> src/shared/money.ts ("shared") imported by: none ``` The file is in the part `feature`, in the instance `cart`, and in the group `client`, because it starts with `'use client'`. The import of `react` shows its externals set, `browser`. The same file is in the table row `feature`, and the rule `tests-stay-out` judges it, because the file is not in the group `tests`. A group is the right tool for this rule. A test file lives next to the code that it tests, so a part cannot hold the test files. Terminal ```ansi $ npx archangel rules app 2 rows archangel.config.ts:16 app.feature: feature may import only shared, browser, and itself in the same instance 1 file · 2 imports judged · 0 problems · 0 exceptions app.shared: shared may import only itself 2 files · 1 import judged · 0 problems · 0 exceptions app.every-file-has-a-kind: a file in no row is not checked here; add `files` to check it 3 files · 0 problems tests-stay-out archangel.config.ts:21 because: Code that ships never needs a test file. files in not tests may not import tests 3 files · 3 imports judged · 0 problems · 0 exceptions Not judged by any rule: 0 files, 0 imports between repository files. Outside packages that no rule names: vitest (1 import). ``` The last line lists `vitest`. No rule names it, so no rule can refuse an import of it. A table judges a package only when a cell names an externals set that holds it. [Tables, rows and cells](/archangel/concepts/tables-rows-and-cells/) tells you why. ## Which name to use * **Use a part** for an area that has its own name and that many tests use: an app, a layer, a feature, a package. * **Use a group** for files that cross parts: tests, stories, client components. * **Use a kind** when one pattern gives the names, for example each `*.entity.ts` file. A kind needs no declaration, but it has a meaning only in its table. [Kinds](/archangel/concepts/kinds/) tells you more. * **Use an externals set** for packages that a table row may import. # Rules and conditions > A rule has a set of files and one condition, and reads as a sentence. Tables, shorthands, folder rules and plugin rules are forms of rules. Each test in a config is a **rule**. A rule has a set of files and one **condition**: what those files must do, or must not do. You can read each rule as a sentence: ```ts rule('no-react-in-shared', { files: 'shared', mayNotImport: { packages: ['react'] } }) ``` “Files in `shared` may not import `react`.” ## The forms of a rule | Form | Write it as | Use it for | | ------------------------- | ------------------------------------------------------ | ------------------------------------------------------ | | Table | `mayImport(name, rows, options)` | What each set of files may import. Each row is a rule. | | Rule | `rule(name, { files, condition })` | One decision about a set of files. | | Folder rule | `rule(name, { folders, condition })` | Names, contents or presence of folders. | | Rule of several sentences | `rule(name, { sentences })` | One decision that needs more than one condition. | | Shorthand | `layers(parts)`, `isolate(part)`, `entry(part, files)` | Common import rules, in one line. | | Plugin rule | `rule(name, { files, plugin, options })` | A condition that you write in TypeScript. | This config has a shorthand of each type, a rule that follows chains of imports, and a rule of two sentences: archangel.config.ts ```ts import { defineConfig } from '@enhance-eng/archangel'; export default defineConfig({ parts: { app: 'src/app/**', feature: 'src/features/{feature}/**', shared: 'src/shared/**', }, rules: ({ entry, layers, rule }) => [ layers(['app', 'feature', 'shared'], { because: 'Code depends only on the layers below it.', examples: { rejected: ['src/shared/a.ts imports src/app/main.ts'] }, }), entry('feature', 'index.ts', { examples: { rejected: ['src/app/main.ts imports src/features/cart/cart.ts'] }, }), rule('no-react-in-shared', { files: 'shared', mayNotImport: { packages: ['react'] }, transitively: true, because: 'Shared code also runs on the server.', examples: { rejected: ['src/shared/a.ts imports src/shared/b.ts imports react'] }, }), rule('no-utils', { sentences: [ { folders: 'src/**/utils', mustNotExist: true }, { files: 'src/**/utils.ts', mustNotExist: true }, ], because: 'A name says what the code does, not that it is a utility.', examples: { rejected: ['src/shared/utils.ts'] }, }), ], }); ``` `archangel rules` writes each rule as the sentences that it checks. Read this output to see what your config says: Terminal ```ansi $ npx archangel rules layers(app, feature, shared) archangel.config.ts:10 because: Code depends only on the layers below it. files in feature may not import app files in shared may not import app, feature 3 files · 3 imports judged · 0 problems · 0 exceptions entry(feature) archangel.config.ts:15 files in feature may be imported from outside their instance only through its entry files index.ts 2 files · 2 imports judged · 0 problems · 0 exceptions no-react-in-shared archangel.config.ts:19 because: Shared code also runs on the server. files in shared may not import react, directly or through other files 1 file · 0 imports judged · 0 problems · 0 exceptions no-utils archangel.config.ts:27 because: A name says what the code does, not that it is a utility. no folder may be in src/**/utils files in src/**/utils.ts must not exist 0 folders · 0 problems Files that no rule judges: 1 src/app/main.ts Outside packages that no rule names: none. ``` * `layers(...)` becomes one sentence for each part after the first. * A shorthand without a `name` gets a name from the rule itself, such as `layers(app, feature, shared)`. * `no-utils` finds 0 folders in this repository, because no folder is named `utils`. That is correct for a rule that says “must not exist”. Archangel shows the count, so that you can see it. ## The conditions A rule has exactly one condition. The conditions are in three groups. | Group | Conditions | A rule reports | | --------- | ------------------------------------------------------------------ | ------------------------------------------------------------------ | | Imports | `mayNotImport`, `mayOnlyImport`, `mayOnlyBeImportedBy`, `noCycles` | An import, or a cycle of imports. | | Structure | `mustNotExist`, `mustBeNamed`, `mustContain` | A file or folder that exists, has the wrong name, or lacks a file. | | Exports | `mustExport`, `mayNotExport`, `mustOnlyReExport` | A file that exports the wrong names, or has code of its own. | Each condition is in [the conditions reference](/archangel/reference/conditions/), with an example. ### Make an import condition narrower An import condition can apply to some imports only. Add an option such as `names`, `specifier`, `instance`, `dependency` or `within`: ```ts rule('no-effects-in-view', { files: 'view', mayNotImport: { packages: ['react'] }, names: ['useEffect'], examples: { rejected: [{ 'src/view/a.ts': "import { useEffect } from 'react';" }] }, }) ``` “Files in `view` may not import `useEffect` from `react`.” The [import options reference](/archangel/reference/import-options/) lists each option. ### Follow chains of imports A rule with `transitively: true` refuses a chain of imports, not only a direct import. In the config above, `no-react-in-shared` refuses `shared/a.ts → shared/b.ts → react`. The report shows the shortest chain. `stopAt` ends a chain at some files. A transitive rule counts only runtime imports, because a chain of type imports never runs. ## Options for each rule | Option | What it does | | ---------- | ------------------------------------------------------------------------------------------------- | | `name` | The name in each report, exception and baseline entry. Write it first in `rule('name', { ... })`. | | `because` | Your reason. Each report shows it. Tell the next reader why the rule exists. | | `link` | A link that each report shows. | | `imports` | `'all'`, `'runtime'`, `'static'` or `'types'`: which imports the rule counts. | | `examples` | Code that the rule must accept, and code that it must reject. | A `mustNotExist` rule passes when it finds nothing, so its `files` or `folders` may match nothing. `no-utils` above uses both: a folder named `utils`, and a file named `utils.ts`. ## One rule, one decision Write one rule for each decision, and give it a `because`. A problem then names the decision that the code broke, and the reason for it. A rule with a reason is easy to keep, change or delete. A rule without a reason is hard to question. # Tables, rows and cells > How a table decides. Each row is a set of files, and its cells are what those files may import. The table refuses each other import into the repository. A table is the main form of an architecture test. It says, for each set of files, what those files may import. You can read the whole import architecture of an app in one table. archangel.config.ts ```ts import { defineConfig } from '@enhance-eng/archangel'; export default defineConfig({ parts: { ui: 'src/ui/**', domain: 'src/domain/**', db: 'src/db/**', log: 'src/log.ts', }, externals: { driver: ['pg'], view: ['react'], }, rules: ({ mayImport }) => [ mayImport('app', { ui: ['domain', 'view'], domain: [], db: ['domain', 'driver'], log: [], }, { everyRowMayImport: ['log'], because: 'The UI shows the domain. Only db talks to the database.', }), ], }); ``` ## The words * **Table:** one `mayImport(...)` call. Its name, here `app`, is the first word of each rule that it makes. * **Row:** one key of the table, such as `ui`. A row is a set of files: a part, a group, or a [kind](/archangel/concepts/kinds/). Each row is a rule with the name `.`, here `app.ui`. Use this name in an exception, in the baseline and in `off`. * **Cell:** one name in the list of a row, such as `'domain'` in `ui: ['domain', 'view']`. A cell is a row, a part, a group, an externals set or a path pattern. Archangel refuses a cell that is none of these. ## How a row decides Archangel takes each import of each file, one at a time: 1. **Find the row of the importing file.** If the file has no row, no row of this table judges the import. 2. **Find what the import goes to.** For a file in the repository, this is the file. For a package, this is the package name. 3. **Skip the packages that no cell names.** A table judges an import of a package only if a cell of the table, or `everyRowMayImport`, names an externals set that holds the package. Otherwise no row judges the import. 4. **Permit the import if the row permits it.** A row permits only: * the names in its list; * its own name, in the same [instance](/archangel/concepts/captures-and-instances/). Use `mayNotImportItself` to remove this; * the names in `everyRowMayImport`. 5. **Refuse all other imports.** The error names the row, the target and what the row permits. If the target has no row in the table, the message says “no row for” the target. Here is the result for the table above: Terminal ```ansi $ npx archangel check error[may-import]: domain may not import the package `pg`, through "pg": domain may import only log, and itself --> src/domain/order-store.ts:1:16 | 1 | import pg from 'pg'; | ^^^^ domain may import only log, and itself | = help: remove the import, or move the code to a file whose row permits the import = note: rule: app.domain = note: because: The UI shows the domain. Only db talks to the database. checked 6 files and 9 imports: 1 problem For what a code means, run `archangel docs may-import`. ``` The file `order-store.ts` is in `domain`. It imports `pg`. The externals set `driver` holds `pg`, and the cell `driver` is on the row `db`, so the table judges this import. The row `domain` does not list `driver`, so it refuses the import. The row `domain` permits `log`, because `everyRowMayImport` adds `log` to each row. ## A package that no cell names `order-row.ts` imports `date-fns`. No externals set holds `date-fns`, so the table does not judge the import. `archangel explain` shows that each row skipped it: Terminal ```ansi $ npx archangel explain src/ui/order-row.ts:2 src/ui/order-row.ts:2 'date-fns' -> package date-fns file: part ui target: package date-fns skipped by: app.ui date-fns is outside the repository app.domain the file is not in domain app.db the file is not in db app.log the file is not in log app.every-file-has-a-kind this rule checks files, not imports ``` `archangel rules` also lists it at the end, as a package that no rule names: Terminal ```ansi $ npx archangel rules app 4 rows archangel.config.ts:15 because: The UI shows the domain. Only db talks to the database. app.ui: ui may import only domain, view, log, and itself 1 file · 2 imports judged · 0 problems · 0 exceptions app.domain: domain may import only log, and itself 2 files · 3 imports judged · 1 problem · 0 exceptions app.db: db may import only domain, driver, log, and itself 1 file · 2 imports judged · 0 problems · 0 exceptions app.log: log may import only log, and itself 1 file · 0 imports judged · 0 problems · 0 exceptions app.every-file-has-a-kind: a file in no row is not checked here; add `files` to check it 5 files · 0 problems Not judged by any rule: 0 files, 0 imports between repository files. Outside packages that no rule names: date-fns (1 import). ``` A row with an empty list can still import each package `domain: []` refuses each import into the repository. But it refuses a package only when a cell somewhere in the table names that package. To refuse all packages that a row does not list, put each package that your code uses in an externals set, and name the sets in the cells. You can also write a separate rule, for example with `mayNotImport: { packages: ['react'] }`. ## Each file has a row Each table makes one more rule: `
.every-file-has-a-kind`. It refuses a file in the table’s `files` that has no row. * Without `files`, the table has no set of files to check, so this rule checks nothing. `archangel rules` says this on the rule’s line: “a file in no row is not checked here; add `files` to check it”. * With `files`, each file in that set must have a row. A new file with no row is then an error, not a file that no rule judges. [Kinds](/archangel/concepts/kinds/) shows this. * Only source files count. The config file is in no table. ## Problems with a table itself | Error | Cause | | -------------------------------------------------------------------------- | ----------------------------------------------- | | [`table-row-overlap`](/archangel/reference/diagnostics/table-row-overlap/) | Two rows find one file. | | [`table-empty`](/archangel/reference/diagnostics/table-empty/) | The table has no row, or its rows find no file. | A single row with no file is not an error, because you can write a row before its first file exists. `archangel check` and `archangel rules` list that row. ## Tables need no written example A rule that is not a table row must have a rejected example. A table row does not need one: * `archangel rules` shows what each row judged; * a table whose rows find no file is the error `table-empty`; * a cell that names nothing is a config error. You can still write examples for a table. They run against all its rows. [Examples prove rules](/archangel/concepts/examples/) tells you how. [The table reference](/archangel/reference/tables/) lists each option: `kinds`, `files`, `within`, `everyRowMayImport`, `mayNotImportItself` and the import options. # What a rule judges > A rule judges an import when it decides to permit or refuse it. archangel rules and archangel explain show what each rule judged, and what no rule judges. “No problems” is true only for the code that your rules look at. This page tells you how to see what each rule looks at, so that you know what your tests cover. ## Judge and skip A rule **judges** an import when it decides to permit or refuse that import. A rule **skips** an import when the import is not its business. For example: * the importing file is not in the rule’s `files`; * the target is a package that no cell of the table names; * a narrowing option leaves the import out, such as `names`, `specifier`, `instance` or `dependency`; * the import is outside the rule’s `within` area; * the rule counts runtime imports only, and the import is a type import. A skipped import is not a problem, and it is not permitted either. It is outside the test. ## Ask about one import Give `archangel explain` a file and a line. It shows each rule that judged the import, with its result, and each rule that skipped it, with the reason: Terminal ```ansi $ npx archangel explain src/features/checkout/checkout.ts:1 src/features/checkout/checkout.ts:1 '../cart/cart' -> src/features/cart/cart.ts file: part feature (instance checkout) target: part feature (instance cart) judged by: app.feature refuses: another instance of feature is not in its row (feature may import only shared, and itself in the same instance) skipped by: app.shared the file is not in shared app.every-file-has-a-kind this rule checks files, not imports no-react-in-shared the file is not in shared ``` Here is an import of a package that no cell names. Each row of the table skipped it: Terminal ```ansi $ npx archangel explain src/ui/order-row.ts:2 src/ui/order-row.ts:2 'date-fns' -> package date-fns file: part ui target: package date-fns skipped by: app.ui date-fns is outside the repository app.domain the file is not in domain app.db the file is not in db app.log the file is not in log app.every-file-has-a-kind this rule checks files, not imports ``` ## Ask about one file Without a line, `archangel explain` shows the file’s part, instance, kind and groups, the rules that apply to it, its imports, and the files that import it: Terminal ```ansi $ npx archangel explain src/features/checkout/checkout.ts src/features/checkout/checkout.ts part: "feature", instance "checkout" groups: none rules that apply: app.feature app.feature: feature may import only shared, and itself in the same instance (judges its imports) imports: line 1 '../cart/cart' -> src/features/cart/cart.ts ("feature", instance "cart") breaks app.feature imported by: none ``` ## Ask about the whole config `archangel rules` shows each rule as a sentence, with counts of what it judged: Terminal ```ansi $ npx archangel rules app 2 rows archangel.config.ts:9 because: A feature changes alone. Code that two features need goes in shared. app.feature: feature may import only shared, and itself in the same instance 3 files · 3 imports judged · 1 problem · 0 exceptions app.shared: shared may import only itself 1 file · 0 imports judged · 0 problems · 0 exceptions app.every-file-has-a-kind: a file in no row is not checked here; add `files` to check it 4 files · 0 problems no-react-in-shared archangel.config.ts:16 because: Shared code also runs on the server. files in shared may not import react 1 file · 0 imports judged · 0 problems · 0 exceptions Not judged by any rule: 0 files, 0 imports between repository files. Outside packages that no rule names: none. ``` Read each line as a fact about your tests: | The output says | It means | | ------------------------------------------------------------- | ------------------------------------------------------------------------- | | `3 files · 3 imports judged` | The rule judged the imports of 3 files. | | `1 problem · 0 exceptions` | One judged import broke the rule. No exception comment permits a problem. | | `checks nothing: no file is in ...` | The rule found no file. For most rules, this is an error. | | `a file in no row is not checked here; add files to check it` | The table has no `files`, so a file with no row is not an error. | | `Not judged by any rule: 0 files, 0 imports` | Each file and each import between repository files has a rule. | | `Outside packages that no rule names: none.` | Each package that the code imports is in a rule. | When some files or imports have no rule, `archangel rules` lists each one. That list is the gap in your tests. A file in that list can import anything, and no test fails. Why the counts differ by one The count in `archangel check` includes the config file. The counts in `archangel rules` do not. So `archangel check` can show one more file. ## Ask how one file reaches another `archangel why` shows the shortest chain of imports from a file to another file or a package: Terminal ```ansi $ npx archangel why src/features/checkout/checkout.ts src/shared/money.ts src/features/checkout/checkout.ts:1 imports '../cart/cart' src/features/cart/cart.ts:1 imports '../../shared/money' src/shared/money.ts ``` `why` shows a chain. It does not judge it. A rule with `transitively: true` judges chains, and its report shows the chain that broke it. ## Use it to trust your rules 1. Run `archangel rules` after each change to the config. 2. Read the rules that check nothing, and the files and imports that no rule judges. 3. For each item, decide: add a rule, add the file to a row, or accept the gap. 4. Run `archangel explain` on a file when a result surprises you. # Layer a backend > Write the layers of a backend as a table, keep the database driver in one layer, give the domain files kinds, and stop import cycles. This guide writes the architecture of a small backend as tests. It uses two tables and one rule. ## Before you start * Read [Getting started](/archangel/start/getting-started/). * You need a backend whose code is in folders by layer. The example is a users API. A route calls a service. A service uses the domain and the database code. Only the `db` folder talks to PostgreSQL through the package `pg`. * package.json * src/ * routes/ * health.ts * users.ts * services/ * sign-up.ts * db/ * users.ts * domain/ * email.value-object.ts * user-created.event.ts * user.entity.ts This is the config. The steps below tell you about each part of it: archangel.config.ts ```ts import { defineConfig } from '@enhance-eng/archangel'; export default defineConfig({ parts: { routes: 'src/routes/**', services: 'src/services/**', db: 'src/db/**', domain: 'src/domain/**', }, externals: { driver: ['pg'], }, rules: ({ mayImport, rule }) => [ mayImport('backend', { routes: ['services'], services: ['domain', 'db'], db: ['domain', 'driver'], domain: [], }, { because: 'A route calls a service. Only db talks to the database. The domain depends on nothing.', }), mayImport('model', { entity: ['value-object'], event: ['entity', 'value-object'], 'value-object': [], }, { files: 'domain', kinds: 'src/domain/*.{kind}.ts', because: 'An entity does not know its events. A service publishes them.', }), rule('no-cycles', { files: 'src/**', noCycles: true, because: 'Files that import each other cannot change one at a time.', examples: { rejected: ['src/services/a.ts imports src/services/b.ts imports src/services/a.ts'], }, }), ], }); ``` ## Write the layers 1. Give a name to each layer in `parts`. Each file is in one part at most. 2. Give a name to the database driver in `externals`. An externals set is a named set of outside packages. Here, `driver` holds `pg`. 3. Write the table `backend`. It has one row for each layer: * `routes` may import `services`. * `services` may import `domain` and `db`. * `db` may import `domain` and the externals set `driver`. * `domain` may import only itself. A row always permits its own part. The table refuses each other import into the repository. A table refuses a package only when a cell names it The cell `driver` on the row `db` tells the table about `pg`. So the table judges each import of `pg`, and refuses it in each row that does not list `driver`. A table does not judge a package that no cell names. Without the cell, each row could import `pg`. ## Give the domain files kinds The domain has three kinds of file: entities, value objects and events. The file name gives the kind, for example `user.entity.ts`. A second table, `model`, uses the kinds as its rows: * `kinds: 'src/domain/*.{kind}.ts'` is the pattern that gives a file its kind. In `src/domain/user.entity.ts`, the capture `{kind}` takes `entity`. * `files: 'domain'` makes the table judge only the files of the part `domain`. * Because the table has `files`, each file in `domain` must have a row. A file such as `src/domain/helpers.ts` has no kind, so the rule `model.every-file-has-a-kind` refuses it. You do not declare a kind. The pattern gives the names. Read [Kinds](/archangel/concepts/kinds/) for more patterns. ## Stop import cycles The rule `no-cycles` refuses files that import each other. It counts only runtime imports by default, because the compiler removes an `import type`. In the example, `user.entity.ts` imports `user-created.event.ts`, and the event imports the type `User` from the entity. This is a cycle of one runtime import and one type import. It cannot break when the code runs, so `no-cycles` does not refuse it. To refuse type cycles too, add `imports: 'all'` to the rule. The rule has a rejected example. `archangel test` makes the two files of the example in memory and checks that the rule refuses them. ## Check the backend 1. Run the check: Terminal ```ansi $ 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`. ``` The check finds two problems: * `user.entity.ts` imports an event. The row `entity` lists only `value-object`. To fix it, let the service make the event after it creates the user. * `health.ts` imports `pg` in a route. To fix it, move the query to a function in `db`, and call it through a service. 2. Run the examples: Terminal ```ansi $ 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. See what each row judged: Terminal ```ansi $ 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. ``` The table `backend` has no `files`, so its rule `backend.every-file-has-a-kind` does not check files that have no row. The line says this. The table `model` has `files`, so each file of the domain must have a row. ## A table or `layers` The shorthand `layers(['routes', 'services', 'db'])` also writes layers. It says only that a layer may not import a layer before it in the list. So a route may import `db` directly. A table says what each layer may import, so it refuses that import. Use a table when you want to say what each layer may use, not only what it may not use. ## Next steps * [Tables, rows and cells](/archangel/concepts/tables-rows-and-cells/) explains each cell type. * [Exceptions and baselines](/archangel/guides/exceptions-and-baselines/) tells you what to do with code that breaks a rule on purpose. * [Monorepos and workspace tables](/archangel/guides/monorepos/). # Use archangel in CI > Fail a pull request that breaks an architecture test, and show each problem on its line in the pull request. This guide runs archangel on each pull request. A problem fails the check and shows on its line in the diff. ## Before you start * Your repository has an archangel config, and `@enhance-eng/archangel` is in its `devDependencies`. * `npx archangel check` and `npx archangel test` pass on your default branch. If old code breaks a rule, record it first with a [baseline](/archangel/guides/existing-repository/). ## What CI runs Run two commands: * `archangel check` checks the code against each rule. * `archangel test` checks the examples of each rule. A rule that cannot find a problem fails here. Each command exits with one of three codes: | Code | Meaning | | ---- | ----------------------------------------------------------------------------------- | | `0` | No problems. | | `1` | Archangel found one or more problems. | | `2` | Archangel could not run: the config, the baseline or the command line has an error. | So a CI step fails on each problem, without more setup. ## GitHub Actions 1. Add a workflow, or add the two steps to a workflow that you have: .github/workflows/architecture.yml ```yaml name: Architecture on: [pull_request] permissions: contents: read jobs: archangel: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - uses: actions/setup-node@v7 with: node-version: 24 - run: npm ci - run: npx archangel check --format github - run: npx archangel test --format github ``` 2. Open a pull request. `--format github` writes each problem as a GitHub Actions annotation. GitHub shows it on its line in the **Files changed** tab of the pull request. This is the output for the table in [Getting started](/archangel/start/getting-started/): Terminal ```ansi $ npx archangel check --format github ::error file=src/features/checkout/checkout.ts,line=1,col=27,endColumn=41,title=archangel app.feature::feature may not import another instance of feature, through "../cart/cart": feature may import only shared, and itself in the same instance%0Ahelp: feature may import only shared, and itself in the same instance%0Areason: A feature changes alone. Code that two features need goes in shared. checked 5 files and 4 imports: 1 problem ``` Pin each action The example uses tags such as `@v7` to keep it short. To make your workflow safe against a moved tag, pin each action to a full commit SHA. ## GitHub code scanning `--format sarif` writes a SARIF 2.1.0 log. GitHub code scanning reads it and keeps each problem as an alert. Each result has the identity of its problem, so the alert stays the same when its line moves. ```yaml permissions: contents: read security-events: write steps: # ... checkout, setup-node and npm ci, as above - run: npx archangel check --format sarif > archangel.sarif - uses: github/codeql-action/upload-sarif@v4 if: always() with: sarif_file: archangel.sarif ``` `archangel check` exits with code 1 when it finds a problem, so the upload step needs `if: always()`. Code scanning is free for public repositories. A private repository needs GitHub Advanced Security. ## Other CI systems Use the text format. It shows the line of code under each problem, and the exit codes are the same: * GitLab CI ```yaml architecture: image: node:24 script: - npm ci - npx archangel check - npx archangel test ``` * Any shell ```sh npm ci npx archangel check --color always npx archangel test --color always ``` `--color always` keeps the colors in a CI log that can show them. A tool that reads JSON can use `--format json`. [Output formats and exit codes](/archangel/reference/output-and-exit-codes/) describes each format. ## Make the check fast Archangel reads each file one time, so a check of a large repository takes seconds. It does not need a cache or a build step. `--stats` adds the work that archangel did and the time of each stage, if you want to see where the time goes. ## Next steps * [Editors and coding agents](/archangel/guides/editors-and-agents/), to see problems before CI. * [Commands](/archangel/reference/commands/). # Give these docs to a coding agent > Point a coding agent at the Markdown of these docs, tell it to use archangel in your repository, and let it read each diagnostic code without a network. A coding agent works best with archangel when it can read the docs and run the checks itself. This page tells you how to give it both. ## The docs as Markdown Each page of this site is also a Markdown file. An agent reads Markdown with fewer tokens than HTML, and it gets the code blocks and the command output exactly. | What the agent needs | Where it is | | ----------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | | An index of every page, with a one-line description of each | [`/archangel/llms.txt`](https://enhancelabs.ai/archangel/llms.txt) | | One page | The link **Markdown for agents** under the title of each page. The HTML of each page also names it in a `` tag. | | One section, such as all of the reference | The section files that `llms.txt` lists, for example `_llms-txt/reference.txt` | | Everything, in one file | [`/archangel/llms-full.txt`](https://enhancelabs.ai/archangel/llms-full.txt). It is large, so give it only to an agent with a large context. | Give the agent the index first. It can then read only the pages that it needs. ## The meaning of a diagnostic code, with no network `archangel docs ` prints the page of a diagnostic code: what it means, an example, and what to do. It needs no config and no network, so an agent can run it in a sandbox: ```sh npx archangel docs may-import ``` The output of `archangel check` ends with this command, for each code that it shows. The [diagnostic code pages](/archangel/reference/diagnostics/) of this site show the same text. ## Tell the agent to use archangel Add these lines to the instructions that your agent reads, such as `AGENTS.md` or `CLAUDE.md`: ```md ## Architecture This repository's architecture is tested by archangel. The rules are in `archangel.config.ts`. - After each change, run `npx archangel check`. Fix each problem before you continue. - To see what a rule permits, run `npx archangel rules`. To see why an import is refused, run `npx archangel explain :`. - To learn what a diagnostic code means, run `npx archangel docs `. - Do not add an `archangel-allow` comment or a baseline entry to make a check pass. Ask first. - The docs are at https://enhancelabs.ai/archangel/llms.txt. ``` ## Give the agent the result of each change 1. Start watch mode with JSON output beside the agent: ```sh npx archangel watch --format json ``` 2. The agent reads one JSON line after each edit. The line lists the diagnostics that are new and the diagnostics that are gone. 3. The agent fixes what is new before it moves on. [Editors and coding agents](/archangel/guides/editors-and-agents/) tells you about the language server and about each field of the JSON line. # Editors and coding agents > See each problem as you type, in VS Code, Neovim or another editor, and give a coding agent the result of each change. This guide shows archangel’s problems in your editor while you type, and gives a coding agent the result of each change it makes. ## Before you start * Install archangel in the repository: `npm install --save-dev @enhance-eng/archangel`. * The repository has an archangel config. ## The language server `archangel lsp` is a language server on standard input and output. Your editor starts it for you. * When the editor starts, it shows the problems of a full check. * It follows the text that you type, before you save. * It offers each fix that archangel has checked as a quick fix. * It finds the config as the command line does: the nearest config at or above the folder that the editor opened. ## Set up your editor * VS Code The Archangel extension for VS Code is in the archangel repository, in [`editors/vscode`](https://github.com/enhance-labs-builds/archangel/tree/main/editors/vscode). It is not on the Marketplace yet. To run it from a clone of the repository: 1. Build the extension: ```sh cd editors/vscode npm ci npm run bundle ``` 2. Start VS Code with the extension, and open your repository: ```sh code --extensionDevelopmentPath="$PWD" /path/to/your/repository ``` The extension starts in a workspace whose folder has an archangel config. It runs the first archangel that it finds: 1. the `archangel.path` setting; 2. the workspace’s `node_modules/.bin/archangel`; 3. `archangel` on `PATH`, for example after `cargo install archangel`. The **Archangel** output channel says which one it runs. If it finds none, it shows an error that names the three places. Install archangel, then run **Archangel: Restart Server**. In a workspace with more than one folder, the extension checks the first folder. * Neovim Neovim 0.11 and later start a language server from a config. Put this in `init.lua`: ```lua vim.lsp.config('archangel', { cmd = { 'npx', 'archangel', 'lsp' }, filetypes = { 'typescript', 'typescriptreact', 'javascript', 'javascriptreact' }, root_markers = { 'archangel.config.ts', 'archangel.config.mts', 'archangel.config.js', 'archangel.config.mjs', 'archangel.config.json', }, }) vim.lsp.enable('archangel') ``` If you installed archangel with Cargo, use `cmd = { 'archangel', 'lsp' }`. * Zed Zed starts a language server only through an extension, and archangel has no Zed extension yet. Run `npx archangel watch` in Zed’s terminal. After each save, it prints each problem that is new and each problem that is gone. * Other editors Each editor that can start a language server can run archangel. Configure the command `npx archangel lsp`, for the file types TypeScript and JavaScript, with or without JSX. Use a folder that holds an archangel config as the root. ## Coding agents A coding agent writes code faster than a person reviews it. If it does not know your architecture, it adds the import that makes its task work. Give it the check while it writes, not ten minutes later in CI. 1. Start watch mode beside the agent, with JSON output: ```sh npx archangel watch --format json ``` 2. Tell the agent to read the last line after each edit, and to fix each new problem before it moves on. Each check prints one line, a JSON document with these keys: | Key | What it holds | | --------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | | `schemaVersion` | The version of this format, now `1`. | | `new` | The problems that this check found and the last check did not. | | `gone` | The problems of the last check that this check did not find. | | `summary` | The counts of files, imports and problems, the exceptions and baseline entries for each rule, the work that the check did, and `polling`. | The first line lists each problem as new. Each problem has its `code`, `rule`, `file`, `line`, `column`, `message`, `help` and `reason`, as in `archangel check --format json`. When the system has no file watches left, archangel polls the files. Each summary then says so in `polling`. Tell the agent what to run Put the commands in the instructions file of your agent, for example `AGENTS.md`: run `npx archangel check` before a task is done, and `npx archangel explain :` to see why an import is refused. An agent can also run single commands: * `npx archangel check src/features/cart/index.ts` shows only the problems of one file. It still checks the whole repository, because a cycle depends on all files. * `npx archangel explain ` shows the part, the rules and the imports of a file. * `npx archangel why ` shows the shortest chain of imports between two points. ## Next steps * [Use archangel in CI](/archangel/guides/ci/). * [Commands](/archangel/reference/commands/). # Exceptions and baselines > Permit one import that breaks a rule on purpose, with a reason. Record the old problems of a codebase in a baseline that only gets smaller. This guide tells you how to let code break a rule for now, and keep each case visible. Archangel has two ways: * An **exception** is a comment above one import, with the rule and a reason. * The **baseline** is a file that records each problem that existed when a rule started. Each check counts both, for each rule, in its summary. So a team can see the numbers go down. Neither one turns a rule off. ## Before you start * Read [Getting started](/archangel/start/getting-started/). The example is a shop whose features must not import each other: archangel.config.ts ```ts import { defineConfig } from '@enhance-eng/archangel'; export default defineConfig({ parts: { feature: 'src/features/{feature}/**', shared: 'src/shared/**', }, rules: ({ mayImport }) => [ mayImport('app', { feature: ['shared'], shared: [], }, { because: 'A feature changes alone. Code that two features need goes in shared.', }), ], }); ``` ## Write an exception An exception is a comment directly above the import: above the line of its specifier, or above the first line of its statement when the statement takes more than one line. It names the rule, and it gives the reason after a colon: ```ts // archangel-allow app.feature: checkout shows the cart total until the total moves to shared import { cartTotal } from '../cart'; ``` 1. Look at the checkout feature. It imports two other features, and each import has an exception: src/features/checkout/index.ts ```ts // archangel-allow app.feature: checkout shows the cart total until the total moves to shared import { cartTotal } from '../cart'; // archangel-allow app.feature import { currentUser } from '../account'; export function checkoutSummary(prices: number[]): string { return `${currentUser()}: ${cartTotal(prices)}`; } ``` 2. Check: Terminal ```ansi $ npx archangel check error[exception-without-reason]: the exception for "app.feature" gives no reason, so it permits nothing; write the reason after the colon --> src/features/checkout/index.ts:3:1 error[may-import]: feature may not import another instance of feature, through "../account": feature may import only shared, and itself in the same instance --> src/features/checkout/index.ts:4:29 | 4 | import { currentUser } from '../account'; | ^^^^^^^^^^^^ feature may import only shared, and itself in the same instance | = help: remove the import, or move the code to a file whose row permits the import = note: rule: app.feature = note: because: A feature changes alone. Code that two features need goes in shared. checked 5 files and 4 imports: 2 problems app.feature: 1 permitted by exceptions, 0 by the baseline For what a code means, run `archangel docs exception-without-reason` or `archangel docs may-import`. ``` The first exception permits its import. The second exception has no reason, so it permits nothing. Archangel shows `exception-without-reason`, and it also shows the import that the exception did not permit. 3. Give the second exception a reason: src/features/checkout/index.ts ```ts // archangel-allow app.feature: checkout shows the cart total until the total moves to shared import { cartTotal } from '../cart'; // archangel-allow app.feature: the account feature has no shared session yet import { currentUser } from '../account'; export function checkoutSummary(prices: number[]): string { return `${currentUser()}: ${cartTotal(prices)}`; } ``` 4. Check again: Terminal ```ansi $ npx archangel check checked 5 files and 4 imports: no problems app.feature: 2 permitted by exceptions, 0 by the baseline ``` The summary counts the two imports that exceptions permit. An exception permits one import to break one rule. Archangel still checks the import against each other rule. To permit one import for two rules, write two comments on the lines directly above it. A blank line between a comment and the import breaks the link. ## Archangel checks each exception An exception must stay correct as the code changes. **The rule name must exist.** A typing error in the name is `exception-unknown-rule`, and the error lists the names of the rules: src/features/account/index.ts ```ts // archangel-allow app.featur: the profile reads the cart size import { cartTotal } from '../cart'; export function currentUser(): string { return `Sam (${cartTotal([])})`; } ``` Terminal ```ansi $ npx archangel check error[exception-unknown-rule]: the exception names "app.featur", which is not a rule; the rules are app.feature, app.shared, app.every-file-has-a-kind --> src/features/account/index.ts:1:1 error[may-import]: feature may not import another instance of feature, through "../cart": feature may import only shared, and itself in the same instance --> src/features/account/index.ts:2:27 | 2 | import { cartTotal } from '../cart'; | ^^^^^^^^^ feature may import only shared, and itself in the same instance | = help: remove the import, or move the code to a file whose row permits the import = note: rule: app.feature = note: because: A feature changes alone. Code that two features need goes in shared. checked 5 files and 5 imports: 2 problems app.feature: 2 permitted by exceptions, 0 by the baseline For what a code means, run `archangel docs exception-unknown-rule` or `archangel docs may-import`. ``` **An exception must permit something.** When the import below it no longer breaks the rule, the exception is `exception-unused`. So an exception goes away when the code is fixed: src/features/cart/index.ts ```ts // archangel-allow app.feature: the cart reads the user import { formatMoney } from '../../shared/money'; export function cartTotal(prices: number[]): string { return formatMoney(prices.reduce((sum, price) => sum + price, 0)); } ``` Terminal ```ansi $ npx archangel check error[exception-unused]: the exception for "app.feature" permits nothing, because the next line does not break that rule; delete it --> src/features/cart/index.ts:1:1 checked 5 files and 4 imports: 1 problem app.feature: 2 permitted by exceptions, 0 by the baseline For what a code means, run `archangel docs exception-unused`. ``` ## Use the baseline for old problems When you start a rule on code that already breaks it, run `archangel baseline add`. It writes each current problem to `archangel.baseline.json`. A check then fails only for new problems. * An entry records the rule, the importing file, the import specifier and a count. It does not record a line, so an edit above the import does not break the match. * A check never writes the baseline. When the code fixes a problem, its entry is a `baseline-stale` problem. `archangel baseline prune` lowers or removes the entry. It never adds one. So the baseline can only get smaller. * An entry for a rule that the config does not have is also `baseline-stale`. A baseline that is not valid JSON stops the check with `baseline-invalid`. [Add archangel to an existing repository](/archangel/guides/existing-repository/) shows the full cycle, from `baseline add` to `baseline prune`, with the output of each step. A problem without an import An exception is a comment above an import. So it cannot permit a problem that has no import, for example a file name or a file with no row in a table. Use the baseline for these problems. ## Which one to use | You have | Use | | --------------------------------------------------------------------- | ---------------------------------------------- | | One import that breaks a rule on purpose, with a reason worth reading | An exception | | Many problems from before the rule existed | The baseline | | A rule that is wrong for part of the code | Change the rule or the parts, not an exception | | Files that archangel must not check, such as generated code | The `ignore` key of the config | | A rule that you do not want, from a preset | The `off` key of the config | `ignore` takes lines in `.gitignore` syntax. A file that `ignore` removes is not in the check. ## Next steps * [Rule options](/archangel/reference/rule-options/). * [Config file](/archangel/reference/config/) lists `ignore` and `off`. # Add archangel to an existing repository > Turn on a rule today in a repository that already breaks it. Record the old problems in a baseline, and stop each new problem. This guide turns on an architecture rule in a repository that already breaks it. The check then fails only for new problems, and the old problems can only go down. ## Before you start * You need Node 22.18 or later. * Read [Getting started](/archangel/start/getting-started/) if you do not know what a table is. The example repository is a shop with three features and some shared code. Some of its code already breaks the decision “a feature does not import another feature”: * package.json * src/ * features/ * account/ * index.ts * cart/ * index.ts * checkout/ * index.ts * shared/ * greeting.ts * money.ts ## Write the rule 1. Install archangel and write a starter config: ```sh npm install --save-dev @enhance-eng/archangel npx archangel init --typescript ``` `init` makes one part for each folder of source files. Change these parts to the parts of your architecture. 2. Write the decision as a table: archangel.config.ts ```ts import { defineConfig } from '@enhance-eng/archangel'; export default defineConfig({ parts: { feature: 'src/features/{feature}/**', shared: 'src/shared/**', }, rules: ({ mayImport }) => [ mayImport('app', { feature: ['shared'], shared: [], }, { because: 'A feature changes alone. Code that two features need goes in shared.', }), ], }); ``` 3. Check the repository: Terminal ```ansi $ npx archangel check error[may-import]: feature may not import another instance of feature, through "../cart": feature may import only shared, and itself in the same instance --> src/features/checkout/index.ts:1:27 | 1 | import { cartTotal } from '../cart'; | ^^^^^^^^^ feature may import only shared, and itself in the same instance | = help: remove the import, or move the code to a file whose row permits the import = note: rule: app.feature = note: because: A feature changes alone. Code that two features need goes in shared. error[may-import]: feature may not import another instance of feature, through "../account": feature may import only shared, and itself in the same instance --> src/features/checkout/index.ts:2:29 | 2 | import { currentUser } from '../account'; | ^^^^^^^^^^^^ feature may import only shared, and itself in the same instance | = help: remove the import, or move the code to a file whose row permits the import = note: rule: app.feature = note: because: A feature changes alone. Code that two features need goes in shared. error[may-import]: shared may not import feature, through "../features/account": shared may import only itself --> src/shared/greeting.ts:1:29 | 1 | import { currentUser } from '../features/account'; | ^^^^^^^^^^^^^^^^^^^^^ shared may import only itself | = help: remove the import, or move the code to a file whose row permits the import = note: rule: app.shared = note: because: A feature changes alone. Code that two features need goes in shared. checked 6 files and 5 imports: 3 problems For what a code means, run `archangel docs may-import`. ``` The check finds three problems. Each one existed before the rule. The check exits with code 1, so CI fails. Now you have two choices. You can fix all the problems before you merge the rule. Or you can record them, merge the rule today, and fix them later. In a large repository, the second choice is usually the only one that is possible. ## Record the old problems in a baseline 1. Write the baseline: Terminal ```ansi $ npx archangel baseline add wrote 3 entries to archangel.baseline.json ``` Archangel writes `archangel.baseline.json` next to the config: archangel.baseline.json ```json { "entries": [ { "rule": "app.feature", "file": "src/features/checkout/index.ts", "specifier": "../account", "count": 1 }, { "rule": "app.feature", "file": "src/features/checkout/index.ts", "specifier": "../cart", "count": 1 }, { "rule": "app.shared", "file": "src/shared/greeting.ts", "specifier": "../features/account", "count": 1 } ] } ``` An entry records the rule, the file, the import specifier and a count. It does not record a line number, so an edit above the import does not break the match. The file is sorted, so two runs on the same code write the same bytes. 2. Check again: Terminal ```ansi $ npx archangel check checked 6 files and 5 imports: no problems app.feature: 0 permitted by exceptions, 2 by the baseline app.shared: 0 permitted by exceptions, 1 by the baseline ``` The check passes. The summary shows, for each rule, how many problems the baseline permits. 3. Commit `archangel.config.ts` and `archangel.baseline.json`. ## A new problem still fails The baseline holds only the problems of today. Suppose that a new feature imports the cart: src/features/billing/index.ts ```ts import { cartTotal } from '../cart'; export const invoiceTotal = cartTotal; ``` Terminal ```ansi $ npx archangel check error[may-import]: feature may not import another instance of feature, through "../cart": feature may import only shared, and itself in the same instance --> src/features/billing/index.ts:1:27 | 1 | import { cartTotal } from '../cart'; | ^^^^^^^^^ feature may import only shared, and itself in the same instance | = help: remove the import, or move the code to a file whose row permits the import = note: rule: app.feature = note: because: A feature changes alone. Code that two features need goes in shared. checked 7 files and 6 imports: 1 problem app.feature: 0 permitted by exceptions, 2 by the baseline app.shared: 0 permitted by exceptions, 1 by the baseline For what a code means, run `archangel docs may-import`. ``` The new import fails the check. The three old problems stay permitted. ## The baseline only gets smaller A check never writes the baseline. When the code fixes an old problem, the baseline entry becomes a problem of its own. 1. Fix an old problem. Here, `greeting.ts` gets the name as an argument, so it does not import the account feature: src/shared/greeting.ts ```ts export function greeting(name: string): string { return `Hello, ${name}`; } ``` 2. Check: Terminal ```ansi $ npx archangel check error[baseline-stale]: the baseline permits 1 more violation(s) of "app.shared" in src/shared/greeting.ts than the code has, because they were fixed; run `archangel baseline prune` checked 7 files and 4 imports: 1 problem app.feature: 0 permitted by exceptions, 2 by the baseline For what a code means, run `archangel docs baseline-stale`. ``` The diagnostic `baseline-stale` tells you that the baseline permits more than the code needs. 3. Remove the entries that the code does not need: Terminal ```ansi $ npx archangel baseline prune wrote 2 entries to archangel.baseline.json ``` `baseline prune` lowers or removes entries. It never adds an entry, and it never raises a count. archangel.baseline.json ```json { "entries": [ { "rule": "app.feature", "file": "src/features/checkout/index.ts", "specifier": "../account", "count": 1 }, { "rule": "app.feature", "file": "src/features/checkout/index.ts", "specifier": "../cart", "count": 1 } ] } ``` 4. Check again, and commit the smaller baseline: Terminal ```ansi $ npx archangel check checked 7 files and 4 imports: no problems app.feature: 0 permitted by exceptions, 2 by the baseline ``` Prune in the pull request that fixes the code Each pull request that fixes old code also prunes the baseline. Then the diff of the pull request shows which problems it fixed. ## Find what no rule checks A new rule can check less than you think. Run `npx archangel rules` after you add a rule. At the end, it lists the files and imports that no rule judges, and the outside packages that no rule names. Add a part, a row or a rule for each item that matters. ## Next steps * [Exceptions and baselines](/archangel/guides/exceptions-and-baselines/) tells you when to use an exception comment instead of the baseline. * [Use archangel in CI](/archangel/guides/ci/). * [Layer a backend](/archangel/guides/backend-layers/) shows a larger table. # Feature-Sliced Design > Check a Feature-Sliced Design front end with the fsd preset, which holds the rules of Steiger, each with examples. This guide checks a front end that uses [Feature-Sliced Design](https://feature-sliced.design) (FSD) with the `fsd` preset. The preset holds the rules of Steiger, the official linter for FSD. ## Before you start * Read [Getting started](/archangel/start/getting-started/). * Your front end has the FSD layers in one folder: `app`, `pages`, `widgets`, `features`, `entities` and `shared`. A repository needs only some of them. The example is a small store front. An import between two slices uses the alias `@/`, which `tsconfig.json` points at `src`: * archangel.config.ts * tsconfig.json * src/ * app/ * index.ts * pages/ * home/ * index.ts * ui/ * home-page.ts * features/ * add-to-cart/ * index.ts * model/ * cart.ts * ui/ * add-to-cart-button.ts * entities/ * product/ * index.ts * model/ * product.ts * ui/ * product-card.ts * shared/ * ui/ * button.ts * index.ts ## Add the preset 1. Put the preset in `extends`. `root` is the folder that holds the layers: archangel.config.ts ```ts import { defineConfig, fsd } from '@enhance-eng/archangel'; export default defineConfig({ extends: [fsd({ root: 'src' })], }); ``` The config has no parts and no rules of its own. The preset declares each layer as a part, and each slice as an instance. 2. Check the repository: Terminal ```ansi $ npx archangel check error[plugin]: This slice has only one reference in slice "pages/home". Consider merging them. --> src/entities/product = note: rule: fsd/insignificant-slice = note: reason: A slice that at most one other slice uses belongs in that slice. = note: see: https://feature-sliced.github.io/steiger/rules/insignificant-slice error[may-not-import]: src/entities/product/ui/product-card.ts may not import "@/features/add-to-cart", because files in "entities" may not import "fsd/features", "fsd/widgets", "fsd/pages", "fsd/app" --> src/entities/product/ui/product-card.ts:1:33 | 1 | import { addToCartButton } from '@/features/add-to-cart'; | ^^^^^^^^^^^^^^^^^^^^^^^^ remove the import, or move the code to files that may import it | = note: rule: fsd/forbidden-imports = note: reason: A module may import only from layers below its own, and a slice not from another slice of its layer, except through @x. = note: see: https://feature-sliced.github.io/steiger/rules/forbidden-imports checked 12 files and 13 imports: 2 problems fsd/widgets matches nothing in this repository fsd/processes matches nothing in this repository For what a code means, run `archangel docs plugin` or `archangel docs may-not-import`. ``` The check finds two problems: * `fsd/forbidden-imports`: the entity `product` imports the feature `add-to-cart`. In FSD, a layer imports only from the layers below it. * `fsd/insignificant-slice`: only one other slice uses the entity `product`. Steiger tells you to merge it into that slice. The summary also lists the preset parts that find no file, here `widgets` and `processes`. This is not an error. A preset declares each layer of FSD, and most repositories have only some of them. 3. Run the examples: Output of `archangel test` Terminal ```ansi $ npx archangel test pass fsd/forbidden-imports accepted #1 src/entities/example-user/index.ts, src/features/example-login/model/a.ts pass fsd/forbidden-imports accepted #2 src/entities/example-a/model/a.ts, src/entities/example-b/@x/example-a.ts pass fsd/forbidden-imports rejected #1 src/features/example-login/index.ts, src/shared/example-lib/index.ts pass fsd/forbidden-imports rejected #2 src/features/example-a/model/a.ts, src/features/example-b/index.ts pass fsd/no-public-api-sidestep accepted #1 src/features/example-login/index.ts, src/pages/example-home/ui/a.ts pass fsd/no-public-api-sidestep rejected #1 src/features/example-login/model/x.ts, src/pages/example-home/ui/a.ts pass fsd/no-public-api-sidestep rejected #2 src/features/example-login/model/a.ts, src/shared/example-api/client.ts, src/shared/example-api/index.ts pass fsd/public-api accepted #1 src/features/example-login/index.ts, src/features/example-login/model/x.ts pass fsd/public-api rejected #1 src/features/example-login/model/x.ts pass fsd/no-layer-public-api rejected #1 src/entities/index.ts pass fsd/no-reserved-folder-names rejected #1 src/shared/example-kit/ui/button.ts pass fsd/no-segmentless-slices accepted #1 src/features/example-login/index.ts, src/features/example-login/model/login.ts pass fsd/no-segmentless-slices rejected #1 src/features/example-login/index.ts, src/features/example-login/login.ts pass fsd/no-segments-on-sliced-layers rejected #1 src/features/ui/button.ts pass fsd/no-ui-in-app rejected #1 src/app/ui/root.tsx pass fsd/no-ui-in-app rejected #2 src/app/ui.tsx pass fsd/segments-by-purpose rejected #1 src/features/example-login/utils/format.ts pass fsd/segments-by-purpose rejected #2 src/shared/helpers.ts pass fsd/no-processes rejected #1 src/processes/example-checkout/index.ts pass fsd/typo-in-layer-name accepted #1 src/features/example-login/index.ts pass fsd/typo-in-layer-name rejected #1 src/featurez/example.ts pass fsd/ambiguous-slice-names accepted #1 src/features/example-login/model/a.ts pass fsd/ambiguous-slice-names rejected #1 src/features/example-kit/model/a.ts, src/shared/example-kit/index.ts pass fsd/inconsistent-naming accepted #1 src/entities/example-group/apples/model/a.ts, src/entities/example-group/pears/model/a.ts pass fsd/inconsistent-naming rejected #1 src/entities/example-group/order-line/model/a.ts, src/entities/example-group/orders/model/a.ts pass fsd/insignificant-slice accepted #1 src/entities/example-used/index.ts, src/entities/example-used/model/a.ts, src/pages/example-one/ui/a.ts, src/pages/example-two/ui/b.ts pass fsd/insignificant-slice rejected #1 src/entities/example-lonely/index.ts, src/entities/example-lonely/model/a.ts pass fsd/repetitive-naming accepted #1 src/features/example-group/drop/model/a.ts, src/features/example-group/load/model/a.ts, src/features/example-group/save/model/a.ts pass fsd/repetitive-naming rejected #1 src/features/example-group/item-drop/model/a.ts, src/features/example-group/item-load/model/a.ts, src/features/example-group/item-save/model/a.ts pass fsd/excessive-slicing accepted #1 src/widgets/example-group/footer/ui/a.ts, src/widgets/example-group/header/ui/a.ts pass fsd/excessive-slicing rejected #1 src/widgets/example-group/w01/ui/a.ts, src/widgets/example-group/w02/ui/a.ts, src/widgets/example-group/w03/ui/a.ts, src/widgets/example-group/w04/ui/a.ts, src/widgets/example-group/w05/ui/a.ts, src/widgets/example-group/w06/ui/a.ts, src/widgets/example-group/w07/ui/a.ts, src/widgets/example-group/w08/ui/a.ts, src/widgets/example-group/w09/ui/a.ts, src/widgets/example-group/w10/ui/a.ts, src/widgets/example-group/w11/ui/a.ts, src/widgets/example-group/w12/ui/a.ts, src/widgets/example-group/w13/ui/a.ts, src/widgets/example-group/w14/ui/a.ts, src/widgets/example-group/w15/ui/a.ts, src/widgets/example-group/w16/ui/a.ts, src/widgets/example-group/w17/ui/a.ts, src/widgets/example-group/w18/ui/a.ts, src/widgets/example-group/w19/ui/a.ts, src/widgets/example-group/w20/ui/a.ts, src/widgets/example-group/w21/ui/a.ts pass fsd/shared-lib-grouping accepted #1 src/shared/lib/example-dates/index.ts pass fsd/shared-lib-grouping rejected #1 src/shared/lib/example-01.ts, src/shared/lib/example-02.ts, src/shared/lib/example-03.ts, src/shared/lib/example-04.ts, src/shared/lib/example-05.ts, src/shared/lib/example-06.ts, src/shared/lib/example-07.ts, src/shared/lib/example-08.ts, src/shared/lib/example-09.ts, src/shared/lib/example-10.ts, src/shared/lib/example-11.ts, src/shared/lib/example-12.ts, src/shared/lib/example-13.ts, src/shared/lib/example-14.ts, src/shared/lib/example-15.ts, src/shared/lib/example-16.ts 33 examples, none failed; 0 rules without a rejected example ``` Each rule of the preset has examples. Archangel checks them in your repository, under `root`, with your `tsconfig.json`. So a passing test also tells you that `root` is correct. ## Fix the problems 1. Remove the import of the feature from the entity. An entity shows a product. It does not add it to a cart: src/entities/product/ui/product-card.ts ```ts import { button } from '@/shared/ui'; export function productCard(name: string) { return button(name, () => {}); } ``` 2. Check again: Terminal ```ansi $ npx archangel check error[plugin]: This slice has only one reference in slice "pages/home". Consider merging them. --> src/entities/product = note: rule: fsd/insignificant-slice = note: reason: A slice that at most one other slice uses belongs in that slice. = note: see: https://feature-sliced.github.io/steiger/rules/insignificant-slice error[plugin]: This slice has only one reference in slice "pages/home". Consider merging them. --> src/features/add-to-cart = note: rule: fsd/insignificant-slice = note: reason: A slice that at most one other slice uses belongs in that slice. = note: see: https://feature-sliced.github.io/steiger/rules/insignificant-slice checked 12 files and 12 imports: 2 problems fsd/widgets matches nothing in this repository fsd/processes matches nothing in this repository For what a code means, run `archangel docs plugin`. ``` Now no entity imports `add-to-cart`, so only the page uses each slice. `fsd/insignificant-slice` finds both slices. ## Change the preset A store front this small has slices that only one page uses. The team decides that this is correct. It also wants the four rules of Steiger that are off by default. 1. Add `strict: true` and put the rule name in `off`: archangel.config.ts ```ts import { defineConfig, fsd } from '@enhance-eng/archangel'; export default defineConfig({ extends: [fsd({ root: 'src', strict: true })], off: ['fsd/insignificant-slice'], }); ``` `strict: true` adds the rules `fsd/no-cross-imports`, `fsd/no-higher-level-imports`, `fsd/import-locality` and `fsd/no-wildcard-exports`. 2. Check again: Terminal ```ansi $ npx archangel check checked 12 files and 12 imports: no problems fsd/widgets matches nothing in this repository fsd/processes matches nothing in this repository ``` import-locality needs an alias `fsd/import-locality` wants a relative import inside a slice, and an alias such as `@/` between slices and layers. If your code uses relative paths between slices, `strict: true` refuses each of them. You can also change one rule of the preset: * **Remove a rule.** Put its name in `off`, as above. `archangel rules` lists the rules that `off` removes. * **Replace a rule.** Write a rule with the same name, for example `fsd/public-api`. Your rule replaces the rule of the preset. * **A typing error stops the check.** A name in `off`, and the name of a replacement rule, must match a rule of the preset. If it does not, archangel stops and shows the nearest correct name. ## More than one front end To use the preset two times, give the second use its own `name`, for example `fsd({ root: 'apps/admin/src', name: 'fsd-admin' })`. Each name in a preset starts with the preset’s name, so the rules of the second use are `fsd-admin/forbidden-imports` and so on. ## What changes from Steiger * Archangel finds a slice by its folder. Each folder in a sliced layer is a slice. Steiger finds slices by the segments in them. * Each rule has examples, and `archangel test` runs them. * If all parts of the preset find no file, the `root` is probably wrong. Archangel shows `preset-empty`. Read [Migrate from Steiger](/archangel/guides/from-steiger/) to move an existing Steiger setup. ## Next steps * [Presets](/archangel/reference/presets/) lists the options of `fsd` and `nextjs`. * [Exceptions and baselines](/archangel/guides/exceptions-and-baselines/), to start on code that already breaks the rules. # Migrate from dependency-cruiser > Move forbidden rules from dependency-cruiser to archangel, and know which results change. This guide moves the rules of a `.dependency-cruiser.cjs` file to archangel. Each rule keeps its name, so a team can find it again. At the end, archangel checks the same decisions, with examples that prove each rule. ## Before you start * Install archangel: `npm install --save-dev @enhance-eng/archangel`. * Keep dependency-cruiser until archangel gives the results that you expect. ## The config before This repository has three forbidden rules: .dependency-cruiser.cjs ```js module.exports = { forbidden: [ { name: 'no-circular', severity: 'error', from: {}, to: { circular: true }, }, { name: 'ui-not-to-db', severity: 'error', comment: 'The UI reads data through the api folder.', from: { path: '^src/ui' }, to: { path: '^src/db' }, }, { name: 'not-to-dev-dep', severity: 'error', from: { path: '^src', pathNot: '\\.test\\.ts$' }, to: { dependencyTypes: ['npm-dev'] }, }, ], }; ``` ## Map each rule | dependency-cruiser | archangel | | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | | `from: { path }` | The rule’s `files`: a part, a group or a path pattern. | | `to: { path }` | `mayNotImport`, with a part, a group or a path pattern. | | `pathNot` | `{ all: [..., { not: ... }] }` in `files` or on the other side. | | `to: { circular: true }` | `noCycles: true`. Add `imports: 'all'` to count type imports too. | | `to: { dependencyTypes: ['npm-dev'] }` | `mayNotImport: { packages: ['*'] }` with `dependency: 'dev'`. | | `to: { dependencyTypes: ['npm-no-pkg'] }` | `mayNotImport: { packages: ['*'] }` with `dependency: 'undeclared'`. | | `allowed` with `allowedSeverity` | A [table](/archangel/concepts/tables-rows-and-cells/): each row lists what it may import, and the table refuses the rest. | | `comment` | `because`. Archangel shows it with each problem. | | `severity: 'warn'` | No equivalent. A rule is on or off. | | `no-orphans` | No built-in condition. Write a [plugin](/archangel/guides/plugins/) condition that reads `importers`. | ## The config after archangel.config.ts ```ts import { defineConfig } from '@enhance-eng/archangel'; export default defineConfig({ parts: { ui: 'src/ui/**', api: 'src/api/**', db: 'src/db/**', }, groups: { tests: '**/*.test.ts', }, rules: ({ rule }) => [ rule('no-circular', { files: 'src/**', noCycles: true, imports: 'all', examples: { rejected: ['src/a.ts imports src/b.ts imports src/a.ts'] }, }), rule('ui-not-to-db', { files: 'ui', mayNotImport: 'db', because: 'The UI reads data through the api folder.', examples: { rejected: ['src/ui/list.ts imports src/db/client.ts'] }, }), rule('not-to-dev-dep', { files: { all: ['src/**', { not: 'tests' }] }, mayNotImport: { packages: ['*'] }, dependency: 'dev', examples: { rejected: [{ 'src/api/a.ts': "import 'vitest';" }], }, }), ], }); ``` * The paths become parts: `ui`, `api` and `db`. Many rules can use a part, and a part that finds no file is an error. * `pathNot: '\\.test\\.ts$'` becomes the group `tests`, and `files` takes all of `src` except that group. * Each rule has a rejected example. `archangel test` fails if a rule cannot refuse its example. ## Run it 1. Prove the rules: Terminal ```ansi $ npx archangel test pass no-circular rejected #1 src/a.ts, src/b.ts pass ui-not-to-db rejected #1 src/db/client.ts, src/ui/list.ts pass not-to-dev-dep rejected #1 src/api/a.ts 3 examples, none failed; 0 rules without a rejected example ``` 2. Check the repository: Terminal ```ansi $ npx archangel check error[no-cycles]: 2 files import each other in a cycle: src/api/note.ts → src/api/notes.ts → src/api/note.ts --> src/api/note.ts:1:32 = help: remove one import of the cycle, or move what both files need into a file that neither of them imports = note: rule: no-circular error[may-not-import]: src/ui/note-list.ts may not import "../db/client", because files in "ui" may not import "db" --> src/ui/note-list.ts:2:22 | 2 | import { pool } from '../db/client'; | ^^^^^^^^^^^^^^ remove the import, or move the code to files that may import it | = note: rule: ui-not-to-db = note: because: The UI reads data through the api folder. error[may-not-import]: src/ui/note-list.ts may not import "vitest", because files in "all of (src/**, not tests)" may not import any package that package.json lists only under devDependencies --> src/ui/note-list.ts:3:20 | 3 | import { vi } from 'vitest'; | ^^^^^^^^ remove the import, or move the code to files that may import it | = note: rule: not-to-dev-dep checked 6 files and 10 imports: 3 problems For what a code means, run `archangel docs no-cycles` or `archangel docs may-not-import`. ``` 3. Compare each problem with the output of dependency-cruiser. The next section lists the results that can change. 4. See what the rules cover: Terminal ```ansi $ npx archangel rules no-circular archangel.config.ts:13 files in src/** may not import each other in a cycle 5 files · 1 problem ui-not-to-db archangel.config.ts:20 because: The UI reads data through the api folder. files in ui may not import db 1 file · 3 imports judged · 1 problem · 0 exceptions not-to-dev-dep archangel.config.ts:27 files in all of (src/**, not tests) may not import * that the nearest package.json lists only for development 4 files · 1 import judged · 1 problem · 0 exceptions Not judged by any rule: 0 files, 0 imports between repository files. Outside packages that no rule names: none. ``` ## Results that change * **The cycle above is made of type imports.** `notes.ts` and `note.ts` import each other with `import type`. dependency-cruiser with `tsPreCompilationDeps` reports cycles of type imports. Archangel’s `noCycles` counts only runtime imports by default, because the compiler removes a type import. The config above adds `imports: 'all'` to keep the result of dependency-cruiser. Remove it to report only the cycles that exist when the code runs. * **An import that does not resolve is an error.** Archangel reports [`import-unresolved`](/archangel/reference/diagnostics/import-unresolved/) for an import with no file and no installed package. dependency-cruiser does not stop on it. So expect some new errors when you move: each one is an import or a path that was wrong before. For an import that a tool makes at build time, add it to `allowUnresolved` in the config, with the reason. * **A rule that finds no files is an error.** A path in dependency-cruiser that matches nothing makes a rule that never fires, and the run passes. In archangel, a part, a group or a pattern that matches nothing gives [`selector-empty`](/archangel/reference/diagnostics/selector-empty/) or [`element-empty`](/archangel/reference/diagnostics/element-empty/). * **One table can replace many forbidden rules.** A set of `forbidden` rules that each refuse one pair of folders says, in effect, what each folder may import. A table says that directly, and it also refuses an import into a folder that no rule named. Keep the names Use the dependency-cruiser rule name as the archangel rule name. A team member who searches for `not-to-dev-dep` then finds the new rule. ## Next steps * Turn the forbidden rules about folders into a table: [Layer a backend](/archangel/guides/backend-layers/). * Start on code that already breaks the rules: [Exceptions and baselines](/archangel/guides/exceptions-and-baselines/). # Migrate from eslint-plugin-boundaries > Turn boundaries elements into parts, element-types into a table, and entry-point into the entry shorthand. This guide moves an eslint-plugin-boundaries setup to archangel. The two tools have the same idea: give names to areas of the code, then say which area may import which. In archangel, the names are **parts**, and the main rule is a **table**. ## The config before eslint.config.js ```js import boundaries from 'eslint-plugin-boundaries'; export default [ { plugins: { boundaries }, settings: { 'boundaries/elements': [ { type: 'app', pattern: 'src/app/**', mode: 'full' }, { type: 'feature', pattern: 'src/features/*', capture: ['featureName'] }, { type: 'shared', pattern: 'src/shared/**', mode: 'full' }, ], }, rules: { 'boundaries/element-types': ['error', { default: 'disallow', rules: [ { from: 'app', allow: ['feature', 'shared'] }, { from: 'feature', allow: ['shared', ['feature', { featureName: '${from.featureName}' }]] }, { from: 'shared', allow: ['shared'] }, ], }], 'boundaries/entry-point': ['error', { default: 'disallow', rules: [{ target: ['feature'], allow: 'index.ts' }], }], }, }, ]; ``` ## Map each setting | eslint-plugin-boundaries | archangel | | ----------------------------------------------------------- | --------------------------------------------------------------------------------------- | | An element in `boundaries/elements` | A part in `parts`. | | `pattern` with `mode: 'full'` | A path pattern that ends in `/**`, from the config folder. | | `pattern: 'src/features/*'` with `capture: ['featureName']` | `'src/features/{featureName}/**'`. The capture goes in the pattern. | | `boundaries/element-types` with `default: 'disallow'` | A table. Each `from` is a row, and its `allow` list is the row’s list. | | `allow` of the same element with `${from.featureName}` | Nothing. A row may import its own part in the same instance by default. | | `allow` of another instance of the same element | Add the part to its own row, for example `feature: ['shared', 'feature']`. | | `boundaries/entry-point` | `entry('feature', 'index.ts')`. | | `boundaries/no-private` | `entry`, or `isolate` for instances that may not import each other at all. | | `boundaries/external` | A cell that names an externals set, or a rule with `mayNotImport: { packages: [...] }`. | | `boundaries/no-unknown-files` | A rule with `mustNotExist` on the files that are in no part. | ## The config after archangel.config.ts ```ts import { defineConfig } from '@enhance-eng/archangel'; export default defineConfig({ parts: { app: 'src/app/**', feature: 'src/features/{featureName}/**', shared: 'src/shared/**', }, rules: ({ entry, mayImport }) => [ mayImport('element-types', { app: ['feature', 'shared'], feature: ['shared'], shared: [], }), entry('feature', 'index.ts', { name: 'entry-point', examples: { rejected: ['src/app/main.ts imports src/features/login/form.ts'], }, }), ], }); ``` * Each element type is a part. The capture `{featureName}` makes each folder in `src/features` one instance. * The table `element-types` keeps the name of the old rule. Its rows are named `element-types.app`, `element-types.feature` and `element-types.shared`. * The `entry` shorthand takes the name `entry-point`, with a rejected example. ## Run it 1. Prove the rules: Terminal ```ansi $ npx archangel test pass entry-point rejected #1 src/app/main.ts, src/features/login/form.ts 1 examples, none failed; 0 rules without a rejected example 3 table rows need no written example; `archangel rules` shows what each covers ``` 2. Check the repository: Terminal ```ansi $ npx archangel check error[entry]: "../features/cart/cart-total" reaches inside "cart" of "feature" past its entry files --> src/app/main.ts:1:27 | 1 | import { cartTotal } from '../features/cart/cart-total'; | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ import it through "../features/cart" | = note: rule: entry-point error[may-import]: feature may not import another instance of feature, through "../cart": feature may import only shared, and itself in the same instance --> src/features/checkout/index.ts:1:27 | 1 | import { cartTotal } from '../cart'; | ^^^^^^^^^ feature may import only shared, and itself in the same instance | = help: remove the import, or move the code to a file whose row permits the import = note: rule: element-types.feature checked 6 files and 6 imports: 2 problems For what a code means, run `archangel docs entry` or `archangel docs may-import`. ``` `app/main.ts` reaches past the entry file of `cart`, and `checkout` imports another feature. Compare these problems with what ESLint reports before you remove the plugin. 3. Read what each row judged: Terminal ```ansi $ npx archangel rules element-types 3 rows archangel.config.ts:10 element-types.app: app may import only feature, shared, and itself 1 file · 2 imports judged · 0 problems · 0 exceptions element-types.feature: feature may import only shared, and itself in the same instance 3 files · 3 imports judged · 1 problem · 0 exceptions element-types.shared: shared may import only itself 1 file · 0 imports judged · 0 problems · 0 exceptions element-types.every-file-has-a-kind: a file in no row is not checked here; add `files` to check it 5 files · 0 problems entry-point archangel.config.ts:16 files in feature may be imported from outside their instance only through its entry files index.ts 3 files · 4 imports judged · 1 problem · 0 exceptions Not judged by any rule: 0 files, 0 imports between repository files. Outside packages that no rule names: none. ``` ## Results that change * **One run checks all files.** ESLint runs the plugin on each file, and the plugin resolves the imports of that file. `archangel check` reads the repository one time, and it can also check cycles and chains of imports. * **Each file is in one part at most.** boundaries takes the first element that matches a file. In archangel, a file that two parts find is the error [`part-overlap`](/archangel/reference/diagnostics/part-overlap/). Make the patterns disjoint, or make one of them a group. * **An element that finds no file is an error.** In archangel, this is [`element-empty`](/archangel/reference/diagnostics/element-empty/). It usually shows a pattern with a typing error. * **A table refuses imports into the repository only.** A table judges a package only when a cell names an externals set that holds it. See [Tables, rows and cells](/archangel/concepts/tables-rows-and-cells/). Keep ESLint for the rest Remove eslint-plugin-boundaries when archangel gives the same results. Keep ESLint for code style and for rules that need types. # Migrate from eslint-plugin-check-file > Move file and folder naming rules from eslint-plugin-check-file to mustBeNamed and mustNotExist rules. eslint-plugin-check-file checks the names of files and folders. Archangel checks them with `mustBeNamed`, and checks where files may be with `mustNotExist`. This guide moves four common check-file rules. ## The config before eslint.config.js ```js import checkFile from 'eslint-plugin-check-file'; export default [ { files: ['src/**/*.{ts,tsx}'], plugins: { 'check-file': checkFile }, rules: { 'check-file/filename-naming-convention': [ 'error', { 'src/**/*.{ts,tsx}': 'KEBAB_CASE' }, { ignoreMiddleExtensions: true }, ], 'check-file/folder-naming-convention': ['error', { 'src/**/': 'KEBAB_CASE' }], 'check-file/filename-blocklist': ['error', { '**/*.util.ts': '*.helpers.ts' }], 'check-file/folder-match-with-fex': ['error', { '*.test.ts': '**/__tests__/' }], }, }, ]; ``` ## Map each rule | eslint-plugin-check-file | archangel | | ------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | | `filename-naming-convention` | A rule on `files` with `mustBeNamed` and a style. | | `folder-naming-convention` | A rule on `folders` with `mustBeNamed` and a style. | | `KEBAB_CASE`, `CAMEL_CASE`, `PASCAL_CASE`, `SNAKE_CASE`, `SCREAMING_SNAKE_CASE` | `'kebab-case'`, `'camelCase'`, `'PascalCase'`, `'snake_case'`, `'SCREAMING_SNAKE_CASE'` | | `ignoreMiddleExtensions: true` | The default. A style reads the name up to the first dot, so `money.util.ts` is checked as `money`. | | A glob as the convention, such as `*.view` | A name pattern, or a list of them: `mustBeNamed: ['*.view', '*.container']`. | | `filename-blocklist` | A rule on the blocked files with `mustNotExist: true`, and the new name in `because`. | | `folder-match-with-fex` | A rule with `mustNotExist: true` on the files outside their folder: `{ all: ['**/*.test.ts', { not: '**/__tests__/**' }] }`. | | `no-index` | A rule on `**/index.ts` with `mustNotExist: true`. | ## The config after archangel.config.ts ```ts import { defineConfig } from '@enhance-eng/archangel'; export default defineConfig({ rules: ({ rule }) => [ rule('filename-naming-convention', { files: 'src/**/*.{ts,tsx}', mustBeNamed: 'kebab-case', examples: { rejected: ['src/lib/formatDate.ts'] }, }), rule('folder-naming-convention', { folders: ['src/**', '!src/**/__tests__'], mustBeNamed: 'kebab-case', examples: { rejected: ['src/userProfile/index.ts'] }, }), rule('filename-blocklist', { files: '**/*.util.ts', mustNotExist: true, because: 'Name it *.helpers.ts.', examples: { rejected: ['src/lib/date.util.ts'] }, }), rule('folder-match-with-fex', { files: { all: ['**/*.test.ts', { not: '**/__tests__/**' }] }, mustNotExist: true, because: 'A test file goes in a __tests__ folder.', examples: { rejected: ['src/lib/date.test.ts'] }, }), ], }); ``` * The folder rule leaves out `__tests__` with a `!` pattern, as in `.gitignore`. `__tests__` is not kebab-case, so the rule would refuse it. * `mustNotExist` reports each file that its `files` find. Write the set of files that must not exist. * Each rule has a rejected example on one line. For a naming rule, the one line is the path of one empty file. ## Run it 1. Prove the rules: Terminal ```ansi $ npx archangel test pass filename-naming-convention rejected #1 src/lib/formatDate.ts pass folder-naming-convention rejected #1 src/userProfile/index.ts pass filename-blocklist rejected #1 src/lib/date.util.ts pass folder-match-with-fex rejected #1 src/lib/date.test.ts 4 examples, none failed; 0 rules without a rejected example ``` 2. Check the repository: Terminal ```ansi $ npx archangel check error[must-not-exist]: src/lib/money.util.ts must not exist, because it is in **/*.util.ts --> src/lib/money.util.ts = help: move the file to where the architecture puts it, or delete it = note: rule: filename-blocklist = note: because: Name it *.helpers.ts. error[must-not-exist]: src/lib/parse-price.test.ts must not exist, because it is in all of (**/*.test.ts, not **/__tests__/**) --> src/lib/parse-price.test.ts = help: move the file to where the architecture puts it, or delete it = note: rule: folder-match-with-fex = note: because: A test file goes in a __tests__ folder. error[must-be-named]: the name of parsePrice.ts is parsePrice, which is not kebab-case; in kebab-case the file is parse-price.ts --> src/lib/parsePrice.ts = help: name it parse-price.ts = note: rule: filename-naming-convention checked 6 files and 4 imports: 3 problems For what a code means, run `archangel docs must-not-exist` or `archangel docs must-be-named`. ``` Each problem has the name to use. The style error shows the name in the correct style. ## Results that change * **A style checks the name up to the first dot.** Archangel checks `button` in `button.view.tsx`. A name pattern, such as `*.view`, reads the full name without the last extension, `button.view`. * **No rules for the first or last character.** Archangel has no rule for the first or the last character of a name. Write a name pattern, or a plugin condition. * **A name can use its folder.** A name pattern can use the name of the file or its folder in each style, for example `{folder-name}.test`. See [File and folder names](/archangel/guides/names/). A blocklist rule on a clean repository A `mustNotExist` rule on `files` that finds no file is not an error, because no file is the goal. So the blocklist rule stays correct after you rename the last blocked file. # Migrate from Steiger > Replace Steiger with the fsd preset. Each Steiger rule has a rule of the same name, with examples, and the differences are listed. Steiger is the linter of Feature-Sliced Design. Archangel’s `fsd` preset has a rule for each of Steiger’s 21 rules, with the same name and a link to Steiger’s page for it. A test in the archangel repository runs Steiger and the preset on the same repositories, and compares the files that each one reports. [Feature-Sliced Design](/archangel/guides/feature-sliced-design/) tells you how to use the preset. This page tells you how to move from Steiger. ## The config before steiger.config.ts ```ts import { defineConfig } from 'steiger'; import fsd from '@feature-sliced/steiger-plugin'; export default defineConfig([ ...fsd.configs.recommended, { rules: { 'fsd/insignificant-slice': 'off', }, }, ]); ``` ## The config after archangel.config.ts ```ts import { defineConfig, fsd } from '@enhance-eng/archangel'; export default defineConfig({ extends: [fsd({ root: 'src' })], off: ['fsd/insignificant-slice'], }); ``` | Steiger | archangel | | -------------------------------- | ------------------------------------------------------------------------------------------------- | | `...fsd.configs.recommended` | `extends: [fsd({ root: 'src' })]` | | All rules on | `fsd({ root: 'src', strict: true })` | | `'fsd/': 'off'` | `off: ['fsd/']` | | `'fsd/': 'warn'` | No equivalent. A rule is on or off. | | `ignores: [...]` | `ignore: [...]` in the config, in `.gitignore` syntax. It removes the files from the whole check. | | `files: [...]` with a rule `off` | Write a rule with the same name as the preset rule. Your rule replaces it. | | A second FSD root | A second `fsd({ root, name })` with its own `name`, for example `fsd-admin`. | ## Run it 1. Check the repository: Terminal ```ansi $ npx archangel check error[may-not-import]: src/features/login/ui/login-form.ts may not import "../../signup", because files in "features" may not import "all of (fsd/features, not src/features/*/@x/**)" in a different instance --> src/features/login/ui/login-form.ts:1:28 | 1 | import { signupLink } from '../../signup'; | ^^^^^^^^^^^^^^ remove the import, or move the code to files that may import it | = note: rule: fsd/forbidden-imports = note: reason: A module may import only from layers below its own, and a slice not from another slice of its layer, except through @x. = note: see: https://feature-sliced.github.io/steiger/rules/forbidden-imports checked 8 files and 8 imports: 1 problem fsd/shared matches nothing in this repository fsd/entities matches nothing in this repository fsd/widgets matches nothing in this repository fsd/processes matches nothing in this repository For what a code means, run `archangel docs may-not-import`. ``` The feature `login` imports the feature `signup`, so `fsd/forbidden-imports` refuses it. The lines that say “matches nothing” are parts of the preset with no files. A repository usually has only some layers, so these lines are not errors. 2. Run `npx archangel test`. It checks the examples of each preset rule in your repository, under the preset’s `root`, with your tsconfig. 3. Remove Steiger when the results are the same. ## The rules Each rule name has the prefix of the preset, `fsd/` by default. The rules that Steiger recommends are on by default. `strict: true` adds the four rules that Steiger has as options. | Steiger rule | archangel rule | On | | ---------------------------------- | ---------------------------------- | -------------- | | `fsd/forbidden-imports` | `fsd/forbidden-imports` | Default | | `fsd/no-public-api-sidestep` | `fsd/no-public-api-sidestep` | Default | | `fsd/public-api` | `fsd/public-api` | Default | | `fsd/no-layer-public-api` | `fsd/no-layer-public-api` | Default | | `fsd/no-reserved-folder-names` | `fsd/no-reserved-folder-names` | Default | | `fsd/no-segmentless-slices` | `fsd/no-segmentless-slices` | Default | | `fsd/no-segments-on-sliced-layers` | `fsd/no-segments-on-sliced-layers` | Default | | `fsd/no-ui-in-app` | `fsd/no-ui-in-app` | Default | | `fsd/segments-by-purpose` | `fsd/segments-by-purpose` | Default | | `fsd/no-processes` | `fsd/no-processes` | Default | | `fsd/typo-in-layer-name` | `fsd/typo-in-layer-name` | Default | | `fsd/ambiguous-slice-names` | `fsd/ambiguous-slice-names` | Default | | `fsd/inconsistent-naming` | `fsd/inconsistent-naming` | Default | | `fsd/insignificant-slice` | `fsd/insignificant-slice` | Default | | `fsd/repetitive-naming` | `fsd/repetitive-naming` | Default | | `fsd/excessive-slicing` | `fsd/excessive-slicing` | Default | | `fsd/shared-lib-grouping` | `fsd/shared-lib-grouping` | Default | | `fsd/no-higher-level-imports` | `fsd/no-higher-level-imports` | `strict: true` | | `fsd/no-cross-imports` | `fsd/no-cross-imports` | `strict: true` | | `fsd/import-locality` | `fsd/import-locality` | `strict: true` | | `fsd/no-wildcard-exports` | `fsd/no-wildcard-exports` | `strict: true` | The rules about imports and folders are built-in conditions of archangel. The seven rules about names and counts, from `typo-in-layer-name` to `shared-lib-grouping`, are conditions of the preset’s plugin, and they read the whole tree as Steiger does. ## Results that change These differences are on purpose. The test that compares the two tools names each one. * **A slice is any folder in a sliced layer.** The built-in rules take each folder directly in Entities, Features, Widgets or Pages as a slice, with or without segments. They do not know slice groups. Steiger finds a slice by the segments inside it. The plugin rules find slices and slice groups as Steiger does. * **Each folder in `shared/ui` and `shared/lib` needs an index file.** `fsd/public-api` asks for it even when the segment has an index file of its own. Steiger checks those folders only when the segment has none. * **A misspelt layer is reported beside the real one.** `fsd/typo-in-layer-name` reports `src/featurez` even when `src/features` exists. Steiger then reports nothing. * **A layer has its exact name.** The built-in rules take only a folder named exactly as a layer. Steiger also reads `1_shared` or `_app` as a layer. * **Code outside the layers is checked too.** `fsd/no-public-api-sidestep` checks imports from files outside the layers, such as tests. A deep import from a test is still a sidestep. Steiger reads only the files in the layers. * **A re-export is an import.** Each rule counts `export ... from` as an import, because a re-export loads the module. One tool for more rules Steiger checks Feature-Sliced Design only. With archangel, you can add your own tables and rules next to the preset, in the same config, and check them in one run. # Monorepos and workspace tables > Check which apps and packages of a workspace may use which, and make each app list the packages that it imports. This guide checks the architecture of a monorepo with one config at its root. A table says which apps and packages may use which. A second rule makes each app list the packages that it imports. ## Before you start * Read [Getting started](/archangel/start/getting-started/). * Your monorepo uses workspaces of npm, pnpm or Yarn, so that each workspace package is linked in `node_modules`. The example has two apps and two packages: * archangel.config.ts * package.json * apps/ * api/ * package.json * src/ * index.ts * web/ * package.json * src/ * index.ts * packages/ * contract/ * package.json * src/ * index.ts * ui/ * package.json * src/ * index.ts ## How archangel sees a workspace package An app imports a package of the workspace by its name, for example `@acme/ui`. The package manager links `node_modules/@acme/ui` to `packages/ui`. Archangel follows the link, as Node and TypeScript do. So the import goes to a file in `packages/ui`, and a rule about parts judges it as an import of that file. Note For this reason, an externals set cannot hold the name of a workspace package. Archangel refuses the config. Use a part for the folder of the package. ## Write the workspace table 1. Write one config at the root of the monorepo, with one part for each app and each package: archangel.config.ts ```ts import { defineConfig } from '@enhance-eng/archangel'; export default defineConfig({ parts: { web: 'apps/web/**', api: 'apps/api/**', ui: 'packages/ui/**', contract: 'packages/contract/**', }, rules: ({ mayImport, rule }) => [ mayImport('workspace', { web: ['ui', 'contract'], api: ['contract'], ui: [], contract: [], }, { because: 'An app uses packages. A package never uses an app, and the contract depends on nothing.', }), rule('declared-packages', { files: 'apps/*/src/**', mayNotImport: { packages: ['*'] }, dependency: 'undeclared', because: 'Each app lists what it uses in its own package.json, so it builds alone.', examples: { rejected: [{ 'apps/web/package.json': '{ "name": "@acme/web" }', 'apps/web/src/a.ts': "import 'react';", }], }, }), ], }); ``` The table `workspace` says: * The web app may use the UI kit and the contract. * The API may use only the contract. * The UI kit and the contract may use no other workspace. 2. Check the monorepo: Terminal ```ansi $ npx archangel check error[may-import]: api may not import ui, through "@acme/ui": api may import only contract, and itself --> apps/api/src/index.ts:2:23 | 2 | import { Price } from '@acme/ui'; | ^^^^^^^^^^ api may import only contract, and itself | = help: remove the import, or move the code to a file whose row permits the import = note: rule: workspace.api = note: because: An app uses packages. A package never uses an app, and the contract depends on nothing. error[may-not-import]: apps/api/src/index.ts may not import "zod", because files in "apps/*/src/**" may not import any package that apps/api/package.json does not list --> apps/api/src/index.ts:3:19 | 3 | import { z } from 'zod'; | ^^^^^ remove the import, or move the code to files that may import it | = note: rule: declared-packages = note: because: Each app lists what it uses in its own package.json, so it builds alone. checked 5 files and 7 imports: 2 problems For what a code means, run `archangel docs may-import` or `archangel docs may-not-import`. ``` The check finds two problems. The first is from the table: the API imports the UI kit. The second is from the rule `declared-packages`, in the next section. Here is the API file: apps/api/src/index.ts ```ts import type { Order } from '@acme/contract'; import { Price } from '@acme/ui'; import { z } from 'zod'; const order = z.object({ id: z.string(), total: z.number() }); export function parseOrder(body: unknown): Order { Price({ cents: 0 }); return order.parse(body); } ``` ## Make each app list its packages In a monorepo, a package that one workspace installs is often visible to all of them. An app can then import a package that its own `package.json` does not list. It works on your laptop and fails when the app builds alone. The rule `declared-packages` refuses this. It uses two options: * `mayNotImport: { packages: ['*'] }` names each outside package. * `dependency: 'undeclared'` limits the rule to packages that the nearest `package.json` of the importing file does not list. The API imports `zod`. The root `package.json` installs it, but `apps/api/package.json` does not list it: apps/api/package.json ```json { "name": "@acme/api", "private": true, "type": "module", "main": "src/index.ts", "dependencies": {"@acme/contract": "*", "fastify": "5.6.0"} } ``` The API also imports `@acme/ui`, which its `package.json` does not list either. The rule does not refuse it, because the import goes to a file of the repository, not to an outside package. The table judges it. To find packages that are only in `devDependencies`, use `dependency: 'dev'`. ## Prove the rules Terminal ```ansi $ npx archangel test pass declared-packages rejected #1 apps/web/package.json, apps/web/src/a.ts 1 examples, none failed; 0 rules without a rejected example 4 table rows need no written example; `archangel rules` shows what each covers ``` The example of `declared-packages` writes its own `package.json`. A rule with `dependency` needs files that you write, because the text of the manifest decides the result. One-line examples do not work here. Terminal ```ansi $ npx archangel rules workspace 4 rows archangel.config.ts:11 because: An app uses packages. A package never uses an app, and the contract depends on nothing. workspace.web: web may import only ui, contract, and itself 1 file · 2 imports judged · 0 problems · 0 exceptions workspace.api: api may import only contract, and itself 1 file · 2 imports judged · 1 problem · 0 exceptions workspace.ui: ui may import only itself 1 file · 0 imports judged · 0 problems · 0 exceptions workspace.contract: contract may import only itself 1 file · 0 imports judged · 0 problems · 0 exceptions workspace.every-file-has-a-kind: a file in no row is not checked here; add `files` to check it 4 files · 0 problems declared-packages archangel.config.ts:20 because: Each app lists what it uses in its own package.json, so it builds alone. files in apps/*/src/** may not import * that the nearest package.json does not list 2 files · 1 import judged · 1 problem · 0 exceptions Not judged by any rule: 0 files, 0 imports between repository files. Outside packages that no rule names: none. ``` ## One config or many Archangel uses the nearest config at or above the folder where it starts. One config at the root can check all workspaces in one run, and a table can say how they depend on each other. A config in a workspace sees only that workspace. Use one root config for the rules between workspaces. ## Next steps * [Import options](/archangel/reference/import-options/) lists `dependency`, `specifier`, `instance` and `within`. * [Next.js](/archangel/guides/nextjs/) shows how to use a preset for one app, with its `root`. # File and folder names > Check the style of file and folder names, permit a list of names, make a file export the name of its file, and make each folder hold its entry file. This guide checks the names of files and folders, and which files a folder must hold. ## Before you start * Read [Getting started](/archangel/start/getting-started/). The example is a React app with components and features. Some of its names break the team’s conventions: * archangel.config.ts * src/ * components/ * Avatar.tsx * priceTag.tsx * features/ * cart/ * index.ts * ui/ * cart-row\.tsx * cart.container.tsx * cart.view\.tsx * orderHistory/ * ui/ * orders.view\.tsx ## Write the rules This config has five rules. The sections below tell you about each one. archangel.config.ts ```ts import { defineConfig } from '@enhance-eng/archangel'; export default defineConfig({ rules: ({ rule }) => [ rule('folder-names', { folders: 'src/**', mustBeNamed: 'kebab-case', because: 'One style for folders, so an import path is easy to guess.', examples: { rejected: ['src/features/UserProfile/index.ts'] }, }), rule('component-names', { files: 'src/components/**/*.tsx', mustBeNamed: 'PascalCase', because: 'A component file has the name of the component.', examples: { rejected: ['src/components/user-avatar.tsx'] }, }), rule('component-exports', { files: 'src/components/**/*.tsx', mustExport: '{FileName}', because: 'Code imports a component by the name of its file.', examples: { rejected: [{ 'src/components/Avatar.tsx': 'export function Picture() {}' }], }, }), rule('ui-file-names', { files: 'src/features/*/ui/**/*.tsx', mustBeNamed: ['*.view', '*.container'], because: 'A view only shows data. A container gets the data.', examples: { rejected: ['src/features/cart/ui/cart-list.tsx'] }, }), rule('feature-entry', { folders: 'src/features/*', mustContain: 'index.ts', because: 'Other code imports a feature through its index file.', examples: { rejected: ['src/features/search/ui/search.view.tsx'] }, }), ], }); ``` ### A style for folders `folder-names` uses `folders`, not `files`. The pattern `src/**` matches each folder inside `src`. `mustBeNamed: 'kebab-case'` gives the style. The styles are `kebab-case`, `camelCase`, `PascalCase`, `snake_case` and `SCREAMING_SNAKE_CASE`. ### A style for files `component-names` gives each component file a name in `PascalCase`. For a style, archangel reads the name up to the first dot. So `Avatar.tsx` has the name `Avatar`, and `Button.test.tsx` has the name `Button`. ### A list of permitted names `ui-file-names` permits two name patterns: `*.view` and `*.container`. A name pattern reads the full name of the file without its last extension, so `cart.view.tsx` has the full name `cart.view`. In a name pattern, `*` is any text or no text. * `*.view` matches `cart.view.tsx`. * `*.view` does not match `view.tsx`, whose full name is `view`. * `*.view` does not match `cart.view.extra.tsx`, whose full name is `cart.view.extra`. One entry of the list must match. A list can mix styles and name patterns. An empty list is a config error. ### A file exports the name of its file `component-exports` uses an export condition: `mustExport: '{FileName}'`. `{FileName}` is the name of the file in `PascalCase`. So `Avatar.tsx` must export `Avatar`. A re-export and `export *` count. The name of the file or its folder can go in a name pattern in each style: `{FileName}`, `{fileName}`, `{file-name}`, `{file_name}`, `{FILE_NAME}`, and the same spellings of `{FolderName}`. ### Each folder has its entry file `feature-entry` uses `mustContain`. Each folder that `src/features/*` matches must hold `index.ts`. To permit one file of a list, write a list: `mustContain: ['index.ts', 'index.tsx']`. ## Check the names 1. Check the app: Terminal ```ansi $ npx archangel check error[must-be-named]: the name of priceTag.tsx is priceTag, which is not PascalCase; in PascalCase the file is PriceTag.tsx --> src/components/priceTag.tsx = help: name it PriceTag.tsx = note: rule: component-names = note: because: A component file has the name of the component. error[must-be-named]: the full name of cart-row.tsx is cart-row, and it must be *.view or *.container --> src/features/cart/ui/cart-row.tsx = help: rename the file so that its name without the extension is *.view or *.container = note: rule: ui-file-names = note: because: A view only shows data. A container gets the data. error[must-contain]: the folder src/features/orderHistory has no file that matches index.ts --> src/features/orderHistory = help: add src/features/orderHistory/index.ts = note: rule: feature-entry = note: because: Other code imports a feature through its index file. error[must-be-named]: the folder src/features/orderHistory is not kebab-case; in kebab-case it is src/features/order-history --> src/features/orderHistory = help: name it src/features/order-history = note: rule: folder-names = note: because: One style for folders, so an import path is easy to guess. checked 8 files and 4 imports: 4 problems For what a code means, run `archangel docs must-be-named` or `archangel docs must-contain`. ``` Each error says what is wrong and gives the correct name: * `priceTag.tsx` is not in `PascalCase`. * `cart-row.tsx` matches neither `*.view` nor `*.container`. * `src/features/orderHistory` has no `index.ts`. * `src/features/orderHistory` is not in `kebab-case`. 2. Run the examples: Terminal ```ansi $ npx archangel test pass folder-names rejected #1 src/features/UserProfile/index.ts pass component-names rejected #1 src/components/user-avatar.tsx pass component-exports rejected #1 src/components/Avatar.tsx pass ui-file-names rejected #1 src/features/cart/ui/cart-list.tsx pass feature-entry rejected #1 src/features/search/ui/search.view.tsx 5 examples, none failed; 0 rules without a rejected example ``` An example sees your repository Archangel lays the files of an example over the files of your repository. So the example of `feature-entry` uses a new feature, `search`. An example in `src/features/cart` would also see the real `src/features/cart/index.ts`, and the rule would not refuse it. ## See what the rules cover Terminal ```ansi $ npx archangel rules folder-names archangel.config.ts:5 because: One style for folders, so an import path is easy to guess. folders in src/** must have names in kebab-case 6 folders · 1 problem component-names archangel.config.ts:12 because: A component file has the name of the component. files in src/components/**/*.tsx must have names in PascalCase 2 files · 1 problem component-exports archangel.config.ts:19 because: Code imports a component by the name of its file. files in src/components/**/*.tsx must export a name that matches {FileName} 2 files · 0 problems ui-file-names archangel.config.ts:28 because: A view only shows data. A container gets the data. files in src/features/*/ui/**/*.tsx must have full names that match *.view or *.container 4 files · 1 problem feature-entry archangel.config.ts:35 because: Other code imports a feature through its index file. each folder in src/features/* must have a file that matches index.ts 2 folders · 1 problem Files that no rule judges: 1 src/features/cart/index.ts Imports between repository files that no rule judges: 3 src/features/cart/index.ts:1 imports src/features/cart/ui/cart.container.tsx src/features/cart/ui/cart.container.tsx:1 imports src/features/cart/ui/cart.view.tsx src/features/cart/ui/cart.view.tsx:1 imports src/features/cart/ui/cart-row.tsx Outside packages that no rule names: none. ``` The end of the output lists one file and three imports that no rule judges. Name rules judge files, not imports. To check the imports too, add a table. ## What archangel does not check * Archangel has no rule for the first or the last character of a name, for example a name that starts with `_`. * Archangel does not check the names inside a file, such as names of functions. `mustExport` and `mayNotExport` check only exported names. ## Next steps * [Conditions](/archangel/reference/conditions/) lists each structure and export condition. * [Migrate from eslint-plugin-check-file](/archangel/guides/from-eslint-plugin-check-file/). # Next.js > Keep server code out of client components with the nextjs preset, which follows chains of imports from each 'use client' file. This guide keeps server-only code out of the browser in a Next.js app, with the `nextjs` preset. ## Before you start * Read [Getting started](/archangel/start/getting-started/). * You have a Next.js app that uses the App Router and `'use client'` files. ## What the preset checks A file that starts with `'use client'` runs in the browser. So does each file that it reaches through runtime imports. The preset refuses a chain of imports from a client file to the package `server-only` or to a Node built-in module, such as `node:fs`. A chain stops at a file that starts with `'use server'`. Next.js changes an import of a server action into a network call, so the code of the action does not go to the browser. The preset has one rule, `nextjs/client-stays-client`. It counts only runtime imports, because the compiler removes an `import type`. ## Add the preset The example app has a cart page, a client button, and a module that reads the cart from disk: * archangel.config.ts * tsconfig.json * app/ * cart/ * actions.ts * page.tsx * components/ * cart-button.tsx * lib/ * cart.ts * format.ts 1. Put the preset in `extends`: archangel.config.ts ```ts import { defineConfig, nextjs } from '@enhance-eng/archangel'; export default defineConfig({ extends: [nextjs()], }); ``` `nextjs()` checks the whole repository. To check one app of a monorepo, give its folder: `nextjs({ root: 'apps/web' })`. 2. Check the app: Terminal ```ansi $ npx archangel check error[may-not-import]: components/cart-button.tsx reaches server-only through components/cart-button.tsx → lib/format.ts → lib/cart.ts → server-only --> components/cart-button.tsx:3:29 | 3 | import { formatTotal } from '@/lib/format'; | ^^^^^^^^^^^^^^ remove an import of the chain, or move what the file needs into code that does not reach it | = note: rule: nextjs/client-stays-client = note: reason: Code that runs in the browser cannot use server-only code or Node built-in modules. = note: see: https://nextjs.org/docs/app/getting-started/server-and-client-components checked 6 files and 9 imports: 1 problem For what a code means, run `archangel docs may-not-import`. ``` The button is a client component. It imports `lib/format.ts` to format the total. `format.ts` imports `lib/cart.ts` for one constant, and `cart.ts` imports `server-only`. So the button pulls server code into the browser. The error shows the full chain. These are the files of the chain: components/cart-button.tsx ```tsx 'use client'; import { checkout } from '@/app/cart/actions'; import { formatTotal } from '@/lib/format'; export function CartButton({ total }: { total: number }) { return ; } ``` lib/format.ts ```ts import { currency } from './cart'; export function formatTotal(cents: number): string { return `${currency} ${(cents / 100).toFixed(2)}`; } ``` lib/cart.ts ```ts import 'server-only'; import { readFile } from 'node:fs/promises'; export const currency = 'USD'; export async function cartTotal(): Promise { return Number(await readFile('cart.txt', 'utf8')); } export async function saveCart(): Promise {} ``` The button also imports `checkout` from `app/cart/actions.ts`, which also imports `lib/cart.ts`. The rule does not refuse that chain, because `actions.ts` starts with `'use server'`. ## Fix the chain 1. Move the constant into `format.ts`, so that `format.ts` imports nothing from the server: lib/format.ts ```ts export const currency = 'USD'; export function formatTotal(cents: number): string { return `${currency} ${(cents / 100).toFixed(2)}`; } ``` 2. Check again: Terminal ```ansi $ npx archangel check checked 6 files and 8 imports: no problems ``` ## Find a chain yourself `archangel why` shows the shortest chain of imports from one file to a file or a package: Terminal ```ansi $ npx archangel why components/cart-button.tsx server-only components/cart-button.tsx:2 imports '@/app/cart/actions' app/cart/actions.ts:2 imports '@/lib/cart' lib/cart.ts:1 imports 'server-only' server-only ``` why does not know where a rule stops `why` shows the shortest chain of all imports. This chain goes through `app/cart/actions.ts`, a `'use server'` file, so the preset rule does not refuse it. Use `why` to see how code connects. Use `archangel check` to see what a rule refuses. ## Prove the preset Terminal ```ansi $ npx archangel test pass nextjs/client-stays-client accepted #1 app/example-actions.ts, app/example-page.tsx pass nextjs/client-stays-client rejected #1 app/example-helper.ts, app/example-page.tsx 2 examples, none failed; 0 rules without a rejected example ``` The preset brings its own examples: one client page that reaches `node:fs` through a helper, and one that reaches it only through a `'use server'` file. ## Next steps * [Presets](/archangel/reference/presets/) lists the options of `nextjs`. * [Conditions](/archangel/reference/conditions/) tells you how to write your own rule about chains of imports, with `transitively` and `stopAt`. # Write a plugin > Write your own condition in TypeScript, use it in a rule, prove it with examples, and publish it. This guide writes a plugin with two conditions, uses them in rules, and proves them with examples. Use a plugin when the [built-in conditions](/archangel/reference/conditions/) cannot say what you want. ## Before you start * Read [Getting started](/archangel/start/getting-started/). * Your config is `archangel.config.ts`. A JSON config cannot load a plugin, because a plugin is code. ## How a plugin works 1. Archangel makes a map of your code. For each file, the map has its path, its part, its groups, what it imports, what imports it, and what it exports. 2. A rule names a condition and some files. 3. Archangel calls the condition’s `check` function with the part of the map that the rule’s files cover. `check` gives back a list of problems. The plugin runs in the Node process that loads your config, not in archangel’s own process. ## Write the plugin 1. Write the conditions in `plugins/team.ts`: plugins/team.ts ```ts import { defineCondition, definePlugin } from '@enhance-eng/archangel'; const maxImports = defineCondition({ name: 'max-imports', description: 'A file imports at most `max` other files.', reads: ['imports'], optionsSchema: { type: 'object', properties: { max: { type: 'integer', minimum: 1 } }, required: ['max'], }, check(view, options: { max: number }) { return view.files.flatMap((file) => { // A package is not another file of the repository, so only imports of files count. const files = file.imports.filter((each) => each.file !== null).length; return files > options.max ? [{ file: file.path, message: `${file.path} imports ${files} files, and the limit is ${options.max}` }] : []; }); }, }); const noOrphans = defineCondition({ name: 'no-orphans', description: 'Another file imports each file.', reads: ['importers'], check(view) { return view.files .filter((file) => file.importers.length === 0) .map((file) => ({ file: file.path, message: `no file imports ${file.path}; delete it or import it` })); }, }); export default definePlugin({ name: 'team', schemaVersion: 1, conditions: [maxImports, noOrphans] }); ``` A condition has these fields: | Field | What it is | | --------------- | ------------------------------------------------------------------------------------------------------------------ | | `name` | The name that a rule uses after the plugin name, for example `team/max-imports`. | | `description` | One sentence that says what the condition requires. | | `reads` | The facts that `check` needs: `'imports'`, `'importers'` or `'exports'`. Archangel sends only these. | | `optionsSchema` | A JSON Schema for the `options` of a rule. Archangel refuses a rule whose options do not match it. It is optional. | | `check` | The function. Archangel calls it as `check(view, options)`. | `max-imports` reads `imports`, and refuses a file that imports more than `max` other files. `no-orphans` reads `importers`, and refuses a file that no other file imports. 2. Load the plugin in the config, and use each condition in a rule: archangel.config.ts ```ts import { defineConfig } from '@enhance-eng/archangel'; import team from './plugins/team.ts'; export default defineConfig({ parts: { feature: 'src/features/{feature}/**', shared: 'src/shared/**', }, plugins: { team }, rules: ({ rule }) => [ rule('small-files', { files: 'feature', plugin: 'team/max-imports', options: { max: 2 }, because: 'A file with many imports does too many things.', examples: { accepted: [{ 'src/features/a/x.ts': "import './y';", 'src/features/a/y.ts': '' }], rejected: [{ 'src/features/a/x.ts': "import './y';\nimport './z';\nimport './w';", 'src/features/a/y.ts': '', 'src/features/a/z.ts': '', 'src/features/a/w.ts': '', }], }, }), rule('no-orphans', { files: 'shared', plugin: 'team/no-orphans', because: 'Shared code that nothing imports is dead code.', examples: { rejected: [{ 'src/shared/unused.ts': 'export const unused = 1;' }], }, }), ], }); ``` * `plugins: { team }` loads the plugin with the name `team`. * `plugin: 'team/max-imports'` names the condition. * `options` goes to `check` without changes. ## Prove and run the plugin 1. Run the examples: Terminal ```ansi $ npx archangel test pass small-files accepted #1 src/features/a/x.ts, src/features/a/y.ts pass small-files rejected #1 src/features/a/w.ts, src/features/a/x.ts, src/features/a/y.ts, src/features/a/z.ts pass no-orphans rejected #1 src/shared/unused.ts 3 examples, none failed; 0 rules without a rejected example ``` A one-line example does not work for a plugin condition, because archangel cannot know which text of a file the condition reads. Write the files of each example yourself, as in the config above. 2. Check the repository: Terminal ```ansi $ npx archangel check error[plugin]: src/features/cart/index.ts imports 4 files, and the limit is 2 --> src/features/cart/index.ts = note: rule: small-files = note: because: A file with many imports does too many things. error[plugin]: no file imports src/shared/dates.ts; delete it or import it --> src/shared/dates.ts = note: rule: no-orphans = note: because: Shared code that nothing imports is dead code. checked 8 files and 7 imports: 2 problems For what a code means, run `archangel docs plugin`. ``` `src/features/cart/index.ts` has four imports. `src/shared/dates.ts` is a file that nothing imports. 3. See what the rules judged: Terminal ```ansi $ npx archangel rules small-files archangel.config.ts:11 because: A file with many imports does too many things. files in feature must meet the plugin condition team/max-imports 4 files · 1 problem no-orphans archangel.config.ts:27 because: Shared code that nothing imports is dead code. files in shared must meet the plugin condition team/no-orphans 2 files · 1 problem Files that no rule judges: 1 plugins/team.ts Imports between repository files that no rule judges: 4 src/features/cart/index.ts:1 imports src/shared/money.ts src/features/cart/index.ts:2 imports src/features/cart/discount.ts src/features/cart/index.ts:3 imports src/features/cart/tax.ts src/features/cart/index.ts:4 imports src/features/cart/total.ts Outside packages that no rule names: @enhance-eng/archangel (1 import). ``` A plugin rule judges files, not imports. So `archangel rules` lists the imports of the cart as not judged. The plugin file itself is a source file, and no rule judges it. ## What `check` receives `view.files` has one entry for each file of the rule’s `files`, sorted by path. Each entry has these fields: | Field | What it is | | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `path` | The path from the config folder. | | `element` | The part of the file, or `null`. | | `captures` | The values of the part’s captures, for example `{ "feature": "cart" }`. | | `selectors` | The groups that hold the file. | | `imports` | Only when the condition reads `'imports'`. Each import has `specifier`, `occurrence`, `line`, `column`, `kind` (`'runtime'` or `'type'`), `names`, `file` and `package`. | | `importers` | Only when the condition reads `'importers'`. The paths of the files that import this file. | | `exports` | Only when the condition reads `'exports'`. It has `names`, `default` and `star`. | The TypeScript types are in `@enhance-eng/archangel`: `View`, `FileRecord`, `ImportRecord`, `ExportsRecord` and `Violation`. [Plugin API](/archangel/reference/plugin-api/) lists each field. ## What a problem can say A problem names a file of the view, or a folder that holds a file of the view: ```ts { file: 'src/features/cart/index.ts', message: 'src/features/cart/index.ts imports 4 files, and the limit is 2' } ``` A problem can also name an import. Then an exception comment above that import can permit it: ```ts { file: 'src/a.ts', import: { specifier: './b', occurrence: 0 }, message: 'a.ts may not import b.ts' } ``` `check` can also give back a promise of a list. ## When a plugin fails A plugin rule never passes because its code did not run. Archangel shows `plugin-failed`, and the check fails, when: * a condition throws an error; * a condition reports a file that is not in its view; * the plugin process stops. Each line that a plugin prints goes to standard error, or to the editor’s log, with the name of the plugin. In the editor The language server shows archangel’s own problems at once. It shows the problems of a plugin when the plugin answers. ## Publish a plugin * Name the package `archangel-plugin-` or `@scope/archangel-plugin`. * A new field in the view does not change `schemaVersion`. A plugin reads only the fields that it knows, so a new release of archangel does not break it. * To give a set of rules with the plugin, publish a preset next to it. A preset is a function that returns part of a config. Read [Presets](/archangel/reference/presets/). ## Next steps * [Plugin API](/archangel/reference/plugin-api/). * [Migrate from dependency-cruiser](/archangel/guides/from-dependency-cruiser/). The condition `no-orphans` on this page does the work of the dependency-cruiser rule of the same name. # Commands > Each archangel command, its arguments and options, and what it prints. Run each command with `npx archangel `, or `archangel ` when archangel is on your `PATH`. | Command | What it does | | ----------------------------- | -------------------------------------------------------------------------------------- | | [`check`](#check) | Checks the repository, and shows each problem and a summary. | | [`test`](#test) | Checks the examples of each rule. | | [`rules`](#rules) | Shows each rule as a sentence, what it judged, and what no rule judges. | | [`explain`](#explain) | Shows the place of a file in the architecture, or each rule that judged one import. | | [`why`](#why) | Shows the shortest chain of imports from a file to a file or a package. | | [`watch`](#watch) | Checks, and then checks again after each change. | | [`baseline`](#baseline) | Records the problems of today, or removes the entries that the code does not need now. | | [`lsp`](#lsp) | Starts the language server. | | [`init`](#init) | Writes a starter config. | | [`docs`](#docs) | Shows what a diagnostic code means. | | [`completions`](#completions) | Writes the completion script of a shell. | | [`man`](#man) | Writes the manual page. | ## Options of each command | Option | Values | Default | What it does | | ------------------- | --------------------------------- | ------------------ | -------------------------------------------------------------------------------------------------- | | `--config ` | a path | the nearest config | Uses this config. Without it, archangel uses the nearest config in the current folder or above it. | | `--format ` | `text`, `json`, `github`, `sarif` | `text` | The output format. Read [Output and exit codes](/archangel/reference/output-and-exit-codes/). | | `--stats` | | off | Adds the work that archangel did, and the time of each stage. | | `--color ` | `auto`, `always`, `never` | `auto` | When to use color in text output. `auto` uses color on a terminal, unless `NO_COLOR` is set. | | `-h`, `--help` | | | Shows the help. `-h` shows a summary. | | `-V`, `--version` | | | Shows the version. Write it before the command: `archangel --version`. | `rules` and `explain` write only `text` or `json`. For another format, they stop with exit code 2. ## `check` ```sh archangel check [FILES]... ``` Checks the repository with the config, and shows each problem and a summary. This is the command for CI. Terminal ```ansi $ npx archangel check error[may-import]: feature may not import another instance of feature, through "../cart/cart": feature may import only shared, and itself in the same instance --> src/features/checkout/checkout.ts:1:27 | 1 | import { cartTotal } from '../cart/cart'; | ^^^^^^^^^^^^^^ feature may import only shared, and itself in the same instance | = help: remove the import, or move the code to a file whose row permits the import = note: rule: app.feature = note: because: A feature changes alone. Code that two features need goes in shared. checked 5 files and 4 imports: 1 problem For what a code means, run `archangel docs may-import`. ``` With file arguments, archangel shows only the problems of those files. It still checks the whole repository, because a cycle or an empty part depends on all files. The exit code then follows the problems that it shows. The summary counts the files and the imports that archangel checked. It also shows the exceptions and the baseline entries of each rule, and the parts of a preset that find no file. ## `test` ```sh archangel test ``` Checks each accepted and rejected example of each rule, in memory. It prints one line for each example. It fails when an example fails, and when a rule has no rejected example. Terminal ```ansi $ npx archangel test pass no-react-in-shared rejected #1 src/shared/money.ts 1 examples, none failed; 0 rules without a rejected example 2 table rows need no written example; `archangel rules` shows what each covers ``` Read [Examples](/archangel/reference/examples/). ## `rules` ```sh archangel rules [PATH] ``` Shows each rule as a sentence, with its reason, where you wrote it, the files and imports that it judged, and its problems and exceptions. Terminal ```ansi $ npx archangel rules app 2 rows archangel.config.ts:9 because: A feature changes alone. Code that two features need goes in shared. app.feature: feature may import only shared, and itself in the same instance 3 files · 3 imports judged · 1 problem · 0 exceptions app.shared: shared may import only itself 1 file · 0 imports judged · 0 problems · 0 exceptions app.every-file-has-a-kind: a file in no row is not checked here; add `files` to check it 4 files · 0 problems no-react-in-shared archangel.config.ts:16 because: Shared code also runs on the server. files in shared may not import react 1 file · 0 imports judged · 0 problems · 0 exceptions Not judged by any rule: 0 files, 0 imports between repository files. Outside packages that no rule names: none. ``` * A rule that covers no file says that it checks nothing. * A rule from a preset says which preset. A rule that replaces a preset rule says so. * At the end, it lists the files and the imports between repository files that no rule judges, and the outside packages that no rule names. * With a path, it shows only the rules, and the files and imports that no rule judges, under that folder or file. `rules` describes. It does not fail because of a problem in the code. ## `explain` ```sh archangel explain archangel explain : ``` With a file, it shows the part of the file, its groups, the rules that apply to it, its imports and the files that import it. After each rule, it says what the rule judges: the imports of the file (its imports), the imports of the file by other files (imports of it), or both: Terminal ```ansi $ npx archangel explain src/features/checkout/checkout.ts src/features/checkout/checkout.ts part: "feature", instance "checkout" groups: none rules that apply: app.feature app.feature: feature may import only shared, and itself in the same instance (judges its imports) imports: line 1 '../cart/cart' -> src/features/cart/cart.ts ("feature", instance "cart") breaks app.feature imported by: none ``` With a file and a line, it shows each rule that judged the import on that line, with its result, and why each other rule did not judge it: Terminal ```ansi $ npx archangel explain src/features/checkout/checkout.ts:1 src/features/checkout/checkout.ts:1 '../cart/cart' -> src/features/cart/cart.ts file: part feature (instance checkout) target: part feature (instance cart) judged by: app.feature refuses: another instance of feature is not in its row (feature may import only shared, and itself in the same instance) skipped by: app.shared the file is not in shared app.every-file-has-a-kind this rule checks files, not imports no-react-in-shared the file is not in shared ``` * Any line of a statement that spans many lines works. * When a line has more than one import, it shows each of them. * It shows an exception or a baseline entry next to its rule. * A line with no import is an error, with exit code 1. ## `why` ```sh archangel why ``` Shows the shortest chain of imports from a source file to a repository file or a package. Terminal ```ansi $ npx archangel why src/features/checkout/checkout.ts src/shared/money.ts src/features/checkout/checkout.ts:1 imports '../cart/cart' src/features/cart/cart.ts:1 imports '../../shared/money' src/shared/money.ts ``` When no chain exists, it says so and exits with 1. `why` follows each import. It does not know the `stopAt` of a rule, so it can show a chain that a rule stops. ## `watch` ```sh archangel watch ``` Checks, and then checks again after each change. It prints what is new and what is gone after each check. * With `--format json`, it prints one line of JSON for each check. The line has `schemaVersion`, the diagnostics that are `new`, the diagnostics that are `gone`, and a `summary`. The first line lists each diagnostic as new. * When the operating system loses file events, archangel checks again from the start. * When the system has no file watches left, archangel looks for changes at a fixed interval. Each summary then says so, names the limit, and names the command that raises it. In JSON, the field is `polling`. Read [Editors and coding agents](/archangel/guides/editors-and-agents/). ## `baseline` ```sh archangel baseline add archangel baseline prune ``` | Command | What it does | | ---------------- | -------------------------------------------------------------------------------------------------------- | | `baseline add` | Records each current problem in `archangel.baseline.json`, next to the config. | | `baseline prune` | Lowers or removes the entries that the code does not need now. It never adds an entry or raises a count. | Terminal ```ansi $ npx archangel baseline add wrote 3 entries to archangel.baseline.json ``` A check never writes the baseline. Read [Exceptions and baselines](/archangel/guides/exceptions-and-baselines/). ## `lsp` ```sh archangel lsp ``` Starts the language server on standard input and output. Your editor starts it for you. It finds the config as the command line does, from the folder that the editor opened. Read [Editors and coding agents](/archangel/guides/editors-and-agents/). ## `init` ```sh archangel init archangel init --typescript ``` Writes a starter config, with one part for each folder of source files and one rule against import cycles. The config passes from the start. | Option | What it does | | -------------- | ---------------------------------------------------------------------------------------------------- | | `--typescript` | Writes `archangel.config.ts` with `defineConfig`. Without it, `init` writes `archangel.config.json`. | Terminal ```ansi $ npx archangel init --typescript wrote archangel.config.ts with 2 parts: features, shared It imports `@enhance-eng/archangel`, so install the package first: npm install --save-dev @enhance-eng/archangel Write what each part may import in a table, then run `archangel check`; `archangel docs may-import` shows how. ``` When a config already exists, `init` writes nothing and exits with 2. ## `docs` ```sh archangel docs ``` Shows what a diagnostic code means, an example, and what to do. It needs no config. Terminal ````ansi $ npx archangel docs rule-unknown-element # rule-unknown-element A rule or a group names a part, a group or an externals set that the config does not declare. A string without a `/` or a `*` is a name, not a path pattern. The message lists the declared names. ## Example ```ts rule({ files: 'entity', mayNotImport: 'reactt' }) ``` ## What to do Use one of the declared names, or write a package as `{ packages: ['reactt'] }`. With the TypeScript config, the editor shows a misspelt name in a rule as a type error. ```` Each code also has a page on this site. Read [Diagnostic codes](/archangel/reference/diagnostics/). ## `completions` ```sh archangel completions ``` Writes the completion script for `bash`, `zsh`, `fish`, `elvish` or `powershell`. For example, in zsh: ```sh archangel completions zsh > "${fpath[1]}/_archangel" ``` ## `man` ```sh archangel man ``` Writes the manual page of archangel, in roff. The list of commands in `--help` does not show this command. ## Exit codes | Code | Meaning | | ---- | -------------------------------------------------------------------------------------------------------------------------- | | `0` | The command succeeded, and found no problem. | | `1` | Archangel found a problem, or the answer is no: a failed example, no chain for `why`, no import on the line for `explain`. | | `2` | Archangel could not run: the config, the baseline or the command line has an error. | # Conditions > Each condition of a rule, for files and for folders, and the rule forms that hold them. A rule has some files and one condition. Read it as a sentence: “These files may not import React.” ```ts rule('no-react-in-shared', { files: 'shared', mayNotImport: { packages: ['react'] }, examples: { rejected: ['src/shared/a.ts imports react'] } }) ``` ## Rule forms | Form | What it is | | ----------------------------------------------------------- | ------------------------------------------------------------------------------------ | | `rule('', { files: ..., : ... })` | A rule about files, with its name first. | | `rule({ name: '', files: ..., : ... })` | The same rule, with the name as an option. | | `rule('', { folders: ..., : ... })` | A rule about folders. | | `rule('', { sentences: [...] })` | One name for a group of conditions. | | `rule('', { files: ..., plugin: ..., options: ... })` | A rule with a plugin condition. Read [Plugin API](/archangel/reference/plugin-api/). | A rule without a name gets a name from its condition, for example `isolate(feature)`. Give each rule a name, because you use it in `off`, in exception comments and in the baseline. A rule’s own name cannot have a `/`. Only the names of a preset have a prefix, such as `fsd/public-api`. To replace a preset rule, write a rule with the same name. Each rule that is not a table row needs a rejected example. Read [Examples](/archangel/reference/examples/). ## Import conditions | Condition | Value | Archangel reports | | --------------------- | -------------- | --------------------------------------------------------------------- | | `mayNotImport` | the other side | an import from the files into the other side | | `mayOnlyImport` | the other side | an import from the files into something that is not on the other side | | `mayOnlyBeImportedBy` | the other side | an import into the files from a file that is not on the other side | | `noCycles` | `true` | files that import each other in a cycle | The other side is a [set of files](/archangel/reference/sets-of-files/), packages, or a list of these. ### `mayNotImport` ```ts rule('ui-not-to-db', { files: 'ui', mayNotImport: 'db', examples: { rejected: ['src/ui/a.ts imports src/db/a.ts'] } }) ``` Add `transitively: true` to find chains of imports. Read [Import options](/archangel/reference/import-options/#transitively). ### `mayOnlyImport` ```ts rule('views-import-little', { files: 'view', mayOnlyImport: ['shared', { packages: ['react'] }], examples: { rejected: ['src/view/a.ts imports src/db/a.ts'] } }) ``` * The rule always permits its own files. When `files` is one part with a capture, it permits only the files of the same instance. To permit each instance, add the part to the list. * Without `within`, the rule judges each import, packages too. * An empty list is a config error, unless the rule has `within`. With `within`, an empty list refuses each import into the area, except into the rule’s own files. ### `mayOnlyBeImportedBy` ```ts rule('db-behind-api', { files: 'db', mayOnlyBeImportedBy: 'api', examples: { rejected: ['src/view/a.ts imports src/db/a.ts'] } }) ``` The rule permits imports from its own files. An import from a file that is in no part is a problem too. ### `noCycles` ```ts rule('no-cycles', { files: 'src/**', noCycles: true, examples: { rejected: ['src/a.ts imports src/b.ts imports src/a.ts'] } }) ``` * The rule reports each group of files that import each other. The report lists each file of the group and shows the shortest cycle. * It counts only runtime imports by default. The compiler removes an `import type`, so a cycle of type imports cannot break when the code runs. To count type imports too, add `imports: 'all'`. ## Structure conditions | Condition | Value | Archangel reports | | -------------- | ------------------------------------------- | ------------------------------------------------- | | `mustNotExist` | `true` | each file, or folder, that the rule finds | | `mustBeNamed` | a style, a name pattern, or a list of these | a name that does not match | | `mustContain` | a path pattern, or a list of patterns | an instance or a folder that has no matching file | ### `mustNotExist` ```ts rule('every-file-in-a-part', { files: { all: ['src/**', { not: { element: '*' } }] }, mustNotExist: true, examples: { rejected: ['src/stray.ts'] } }) ``` This rule reports each file in `src` that is in no part. ### `mustBeNamed` ```ts rule('component-names', { files: 'src/view/**/*.tsx', mustBeNamed: 'PascalCase', examples: { rejected: ['src/view/user-avatar.tsx'] } }) ``` ```ts rule('ui-file-names', { files: 'src/ui/**/*.tsx', mustBeNamed: ['*.view', '*.container'], examples: { rejected: ['src/ui/list.tsx'] } }) ``` A file has two names: | File | Name | Full name | | ----------------- | ---------- | ------------- | | `button.view.tsx` | `button` | `button.view` | | `UserCard.tsx` | `UserCard` | `UserCard` | The **name** is the text up to the first dot. The **full name** is the text without the last extension. * A **style** checks the name: `kebab-case`, `camelCase`, `PascalCase`, `snake_case` or `SCREAMING_SNAKE_CASE`. * A **name pattern** checks the full name. In a name pattern, `*` is any text or no text. `*.view` matches `cart.view.tsx`. It does not match `view.tsx` or `cart.view.extra.tsx`. * A name pattern can use the name of the file, or of the folder that holds it, in each style: | Spelling | Example for `src/user-profile/avatar-image.tsx` | | --------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | | `{FileName}` | `AvatarImage` | | `{fileName}` | `avatarImage` | | `{file-name}` | `avatar-image` | | `{file_name}` | `avatar_image` | | `{FILE_NAME}` | `AVATAR_IMAGE` | | `{FolderName}`, `{folderName}`, `{folder-name}`, `{folder_name}`, `{FOLDER_NAME}` | `UserProfile`, `userProfile`, `user-profile`, `user_profile`, `USER_PROFILE` | ```ts rule('test-names', { files: 'src/**/*.test.ts', mustBeNamed: '{folder-name}.test', examples: { rejected: ['src/cart/total.test.ts'] } }) ``` A list passes when one entry matches. A list with one entry gives the same result and the same message as the single value. An empty list is a config error. Each entry follows the rules of a single value. ### `mustContain` ```ts rule('feature-index', { files: 'feature', mustContain: ['index.ts', 'index.tsx'], examples: { rejected: ['src/features/search/model.ts'] } }) ``` * With `files`, the files must be one part with a capture. The pattern starts at the root of each instance. Other `files` give [`rule-needs-capture`](/archangel/reference/diagnostics/rule-needs-capture/). * With `folders`, the pattern starts at each folder. * In a list, one match is sufficient. ## Export conditions | Condition | Value | Archangel reports | | ------------------ | --------------------------------------- | ---------------------------------------- | | `mustExport` | a name pattern | a file that exports no name that matches | | `mayNotExport` | `'default'`, `'star'` or a name pattern | each export of that type | | `mustOnlyReExport` | `true` | a file with code of its own | ### `mustExport` ```ts rule('view-exports', { files: 'src/view/**/*.tsx', mustExport: '{FileName}', examples: { rejected: [{ 'src/view/Avatar.tsx': 'export function Picture() {}' }] } }) ``` Re-exports count, and so do the names that an `export *` chain brings in. ### `mayNotExport` ```ts rule('no-default-exports', { files: 'src/**', mayNotExport: 'default', examples: { rejected: [{ 'src/a.ts': 'export default 1;' }] } }) ``` * `'default'` refuses a default export. * `'star'` refuses `export *` and `export * as`. * A name pattern refuses each named export that matches it. Archangel reports one problem for each such export, on its line. ### `mustOnlyReExport` ```ts rule('index-only-re-exports', { files: 'src/**/index.ts', mustOnlyReExport: true, examples: { rejected: [{ 'src/shared/index.ts': 'export const a = 1;' }] } }) ``` A file passes when each top-level statement is an import, an `export ... from`, a directive, or an `export { ... }` of names that the file imported. A type declaration counts as code of the file. Note An export condition decides from the text of a file. So it needs examples with the text of each file, not [one-line examples](/archangel/reference/examples/). ## Rules about folders Write `folders` instead of `files`. A folder rule can use `mustBeNamed`, `mustContain` or `mustNotExist`. ```ts rule('folder-names', { folders: 'src/**', mustBeNamed: 'kebab-case', examples: { rejected: ['src/UserProfile/a.ts'] } }) ``` ```ts rule('shared-entry', { folders: ['src/shared/*', '!src/shared/{ui,lib}'], mustContain: ['index.ts', 'index.tsx'], examples: { rejected: ['src/shared/money/format.ts'] } }) ``` * A pattern finds the folders below its fixed part. So `src/**` is each folder inside `src`, not `src` itself. * A pattern that starts with `!` removes folders, as in `.gitignore`. * Archangel checks each folder one time, however many files it holds. * For `mustBeNamed` on folders, an entry cannot use the file name. ## A rule with more than one condition Use `sentences` to give one name to a group of conditions. The rule fails when one of them fails. ```ts rule('no-ui-in-app', { sentences: [ { folders: 'src/app/ui', mustNotExist: true }, { files: 'src/app/ui.*', mustNotExist: true }, ], examples: { rejected: ['src/app/ui.ts'] }, }) ``` A sentence holds `files` or `folders`, one condition, and the options of that condition. The rule holds `name`, `because`, `link`, `examples` and `imports`. A sentence can also be a shorthand, written with its `kind`, such as `{ kind: 'isolate', element: 'feature' }`. ## In JSON In a JSON config, each rule is the object that you give to `rule(...)`, with `name` as a field: ```json { "name": "no-react-in-shared", "files": "shared", "mayNotImport": { "packages": ["react"] } } ``` # Config file > Where archangel finds its config, the two formats, and each top-level key. 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. ## Where archangel finds the config 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 ` 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`](/archangel/reference/diagnostics/config-missing/) and exit code 2. ## TypeScript or JSON | | `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 ```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." } ] } ``` Note The count of files in `archangel check` includes a TypeScript config, because it is a source file. A JSON config is not a source file. So one repository can show one more file with a TypeScript config. ## The keys | Key | Type | What it does | | ------------------------------------- | ---------------- | ----------------------------------------------------------- | | [`parts`](#parts) | object | Named areas of your code. Each file is in one part at most. | | [`groups`](#groups) | object | Named sets of files. A file can be in many groups. | | [`externals`](#externals) | object | Named sets of outside packages. | | [`rules`](#rules) | list or function | The tables and the rules. | | [`extends`](#extends) | list | Presets, such as `fsd(...)` and `nextjs(...)`. | | [`off`](#off) | list of names | Rules and table rows to turn off. | | [`plugins`](#plugins) | object | Plugins, by the name that rules use. | | [`ignore`](#ignore) | list of lines | Files that archangel does not read. | | [`allowUnresolved`](#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`](/archangel/reference/diagnostics/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. ### `parts` A part is a named area of your code. Its value is a path pattern or a list of path patterns. ```ts 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](/archangel/concepts/captures-and-instances/). * Each file is in one of your parts at most. When two parts find one file, archangel shows [`part-overlap`](/archangel/reference/diagnostics/part-overlap/). * A part that finds no file is the error [`element-empty`](/archangel/reference/diagnostics/element-empty/). * A part holds files, not packages. Write packages in `externals`. ### `groups` 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](/archangel/reference/sets-of-files/). ```ts groups: { test: '**/*.test.ts', client: { directive: 'use client' }, }, ``` A group that finds no file is the error [`selector-empty`](/archangel/reference/diagnostics/selector-empty/). ### `externals` An externals set is a named list of outside packages. ```ts 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'] }`. ### `rules` The tables and the rules. In TypeScript, write a function that gets the rule builders: ```ts 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`](/archangel/reference/diagnostics/rule-name-duplicate/)). | Form | Page | | ---------------------------------------- | ---------------------------------------------- | | `mayImport(name, rows, options)` | [Tables](/archangel/reference/tables/) | | `rule(name, { files, })` | [Conditions](/archangel/reference/conditions/) | | `rule(name, { folders, })` | [Conditions](/archangel/reference/conditions/) | | `rule(name, { sentences: [...] })` | [Conditions](/archangel/reference/conditions/) | | `rule(name, { files, plugin, options })` | [Plugin API](/archangel/reference/plugin-api/) | | `layers`, `isolate`, `entry` | [Shorthands](/archangel/reference/shorthands/) | ### `extends` A list of presets. Each preset adds parts, groups, rules and plugins, with its name as a prefix. ```ts extends: [fsd({ root: 'src' }), nextjs()], ``` Read [Presets](/archangel/reference/presets/). ### `off` A list of rule names and table row names to turn off. Archangel applies `off` after it merges the presets. ```ts 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. ### `plugins` The plugins that the config loads, by the name that rules use: ```ts plugins: { team }, ``` A rule then uses a condition of the plugin as `plugin: 'team/max-imports'`. Read [Plugin API](/archangel/reference/plugin-api/). ### `ignore` Lines in `.gitignore` syntax. Archangel does not read a file that a line matches. ```ts 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/reference/diagnostics/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. ### `allowUnresolved` Specifiers that may stay unresolved, each with a reason. A specifier can end in `*`. ```ts 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`](/archangel/reference/diagnostics/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`](/archangel/reference/diagnostics/resolution-file-invalid/) error for it. The files below that tsconfig are still checked. ## How archangel resolves imports 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. ## Older spellings 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('', { ... })` | `rule({ 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. # Diagnostic codes > Every diagnostic code that archangel gives, by group: rule violations, config problems, repository problems, exceptions, the baseline and examples. Each problem that archangel shows has a code, such as `may-import` or `selector-empty`. The code is in the first line of the problem: `error[may-import]`. * Run `archangel docs ` to see what a code means, an example, and what to do. * Each code has a page on this site with the same text. A rule violation exits with code 1. A config problem that stops the check exits with code 2. Some config problems, such as a group that finds no file, do not stop the check. They exit with code 1, because archangel can still check the other rules. ## Rule violations The code broke a rule of the config. The check fails with exit code 1. | Code | What it means | | -------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | [`may-import`](/archangel/reference/diagnostics/may-import/) | A file of a row of an import table imports something that its row does not list. | | [`every-file-has-a-kind`](/archangel/reference/diagnostics/every-file-has-a-kind/) | A file in the \`files\` of an import table has no row in that table. | | [`layers`](/archangel/reference/diagnostics/layers/) | An import goes from one part of a layers rule to a part earlier in its list. | | [`isolate`](/archangel/reference/diagnostics/isolate/) | A file in one instance of a part imports a file in a different instance of the same part, such as one feature importing another. | | [`entry`](/archangel/reference/diagnostics/entry/) | Code outside an instance of a part imports a file inside it that is not one of the instance's entry files. A sibling instance counts as outside. | | [`may-not-import`](/archangel/reference/diagnostics/may-not-import/) | A file in the \`files\` of a \`mayNotImport\` rule imports a file or package on the rule's other side, directly or, for a transitive rule, through a chain of imports. | | [`may-only-import`](/archangel/reference/diagnostics/may-only-import/) | A file in the \`files\` of a \`mayOnlyImport\` rule imports a file or package that is neither on the rule's list nor in those files. When \`files\` is a part with a capture, only the files of the same instance count as those files. | | [`may-only-be-imported-by`](/archangel/reference/diagnostics/may-only-be-imported-by/) | A file imports a file in the \`files\` of a \`mayOnlyBeImportedBy\` rule, and the importing file is neither on the rule's list nor in those files. A file in no part counts as outside the list. | | [`no-cycles`](/archangel/reference/diagnostics/no-cycles/) | A group of files import each other, so each can reach the others. The diagnostic lists every file in the group and shows its shortest cycle. | | [`must-not-exist`](/archangel/reference/diagnostics/must-not-exist/) | A file is in the \`files\` of a \`mustNotExist\` rule, such as a file in no part or in a reserved folder. | | [`must-be-named`](/archangel/reference/diagnostics/must-be-named/) | A file or folder in a \`mustBeNamed\` rule has a name that is not in the rule's style, or a full name that does not match the rule's name pattern. For a rule with a list, the name matches no entry in the list. | | [`must-contain`](/archangel/reference/diagnostics/must-contain/) | An instance of a part, or a folder, in a \`mustContain\` rule has no file that matches the rule's pattern. | | [`must-export`](/archangel/reference/diagnostics/must-export/) | A file in a \`mustExport\` rule exports no name that matches the rule's name pattern. | | [`may-not-export`](/archangel/reference/diagnostics/may-not-export/) | A file in a \`mayNotExport\` rule has an export of the kind the rule refuses: a default export, an \`export \*\` or \`export \* as\`, or a named export whose name matches the rule's pattern. | | [`must-only-re-export`](/archangel/reference/diagnostics/must-only-re-export/) | A file in a \`mustOnlyReExport\` rule has a top-level statement of its own: a declaration, a type included, or code that runs. It may hold only imports, exports from other files, directives, and \`export { … }\` of names it imported. | | [`plugin`](/archangel/reference/diagnostics/plugin/) | A file breaks a rule whose condition a plugin defines, such as \`team/one-store-per-entity\`. The plugin's condition wrote the message. | ## Config problems The config has an error, or a rule in it can never find a problem. | Code | What it means | | -------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | [`config-missing`](/archangel/reference/diagnostics/config-missing/) | Archangel found no config in the current folder or any folder above it. | | [`config-needs-node`](/archangel/reference/diagnostics/config-needs-node/) | The config is TypeScript or JavaScript, and Node 22.18 or later is not on the search path. | | [`config-load-failed`](/archangel/reference/diagnostics/config-load-failed/) | The config's code threw while it loaded, has no default export, or uses TypeScript that Node cannot strip, such as an enum. | | [`config-invalid`](/archangel/reference/diagnostics/config-invalid/) | The config has a field archangel does not know, a value of the wrong type, or text that is not valid JSON. The diagnostic names the field, such as \`rules\[0].reasn\`. | | [`pattern-invalid`](/archangel/reference/diagnostics/pattern-invalid/) | A pattern has an empty folder name, a \`\*\*\` that is not a whole folder name, a capture without a name, or the same capture name twice. | | [`rule-unknown-element`](/archangel/reference/diagnostics/rule-unknown-element/) | A rule or a group names a part, a group or an externals set that the config does not declare. A string without a \`/\` or a \`\*\` is a name, not a path pattern. The message lists the declared names. | | [`rule-needs-capture`](/archangel/reference/diagnostics/rule-needs-capture/) | An isolate rule names a part whose pattern captures nothing, so it has only one instance. | | [`rule-name-duplicate`](/archangel/reference/diagnostics/rule-name-duplicate/) | Two rules have the same name, often the default name of two rules about the same files, such as \`mayNotImport(entity, react)\`. Exceptions and the baseline refer to rules by name, so each needs its own. | | [`part-overlap`](/archangel/reference/diagnostics/part-overlap/) | A file is in two of the config's own parts. A file can be in one part at most, so a rule about parts cannot know which part judges the file. | | [`element-empty`](/archangel/reference/diagnostics/element-empty/) | A part or an externals set matches nothing, so no rule about it can fire. For a part, no file matches its patterns. For an externals set, no import uses its packages and no package manifest lists them. | | [`selector-empty`](/archangel/reference/diagnostics/selector-empty/) | A group that the config declares, a path pattern written inline in a rule, or the \`folders\` of a folder rule matches nothing, so no rule about it can fire. | | [`preset-empty`](/archangel/reference/diagnostics/preset-empty/) | A preset the config extends matches no file with any of its parts, so none of its rules can fire. | | [`table-empty`](/archangel/reference/diagnostics/table-empty/) | A table has no row, or its rows together have no file, so the table checks nothing. | | [`table-row-overlap`](/archangel/reference/diagnostics/table-row-overlap/) | A file in a table's files is in two rows of that table. Each of the two rows judges the file. | ## Repository problems Archangel could not read a file, an import or a setting of the repository. | Code | What it means | | -------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | [`file-unreadable`](/archangel/reference/diagnostics/file-unreadable/) | Archangel cannot open a folder or a file, or a source file is not UTF-8 or UTF-16 text. | | [`parse-failed`](/archangel/reference/diagnostics/parse-failed/) | A source file has syntax errors, uses Flow syntax, or is larger than the parser accepts. Archangel uses none of its imports, because a parser that recovers from errors can invent or lose imports. | | [`import-unresolved`](/archangel/reference/diagnostics/import-unresolved/) | Archangel cannot find the file or package that an import names. The message says why in plain words. | | [`resolution-file-invalid`](/archangel/reference/diagnostics/resolution-file-invalid/) | A tsconfig or package manifest that resolution needs is not valid: it does not parse, cannot be read, or extends a package that is not installed. Archangel reports it once, not once per import. | | [`ignore-line-invalid`](/archangel/reference/diagnostics/ignore-line-invalid/) | A line of the config's \`ignore\` list is not valid \`.gitignore\` syntax. | | [`exports-unknown`](/archangel/reference/diagnostics/exports-unknown/) | A \`mustExport\` rule needs the names a file exports, and the file passes names on through an \`export \*\` from a package or from a specifier that does not resolve, so archangel cannot know them. | ## Exceptions and the baseline An exception comment or the baseline file has a problem. | Code | What it means | | ---------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | [`exception-without-reason`](/archangel/reference/diagnostics/exception-without-reason/) | An \`archangel-allow\` comment gives no reason after the colon, so it permits nothing. | | [`exception-unknown-rule`](/archangel/reference/diagnostics/exception-unknown-rule/) | An \`archangel-allow\` comment names a rule that the config does not have. The message lists the rules that exist. | | [`exception-unused`](/archangel/reference/diagnostics/exception-unused/) | An \`archangel-allow\` comment permits nothing, because the import after it does not break the rule it names. The import after it is the one whose specifier is on the next line, or whose statement starts on the next line. Exceptions go away when the code is fixed. | | [`baseline-invalid`](/archangel/reference/diagnostics/baseline-invalid/) | The baseline file, \`archangel.baseline.json\`, does not parse. | | [`baseline-stale`](/archangel/reference/diagnostics/baseline-stale/) | A baseline entry permits more violations than the code has, because they were fixed, or it names a rule that no longer exists. A check never rewrites the baseline. | ## Examples and plugins A rule is not proved, an example failed, or a plugin did not run. | Code | What it means | | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | [`rule-unproven`](/archangel/reference/diagnostics/rule-unproven/) | A rule has no rejected example, so nothing proves that it can find a violation. | | [`example-proves-nothing`](/archangel/reference/diagnostics/example-proves-nothing/) | An accepted example passed, but its rule never looked at it, so it proves nothing. An import rule looks at imports, a rule about files at files, a folder rule at folders, a \`mustContain\` rule on a part at its instances, and a plugin rule at the files of its view. The message says which one the rule never looked at. | | [`example-failed`](/archangel/reference/diagnostics/example-failed/) | A rule did not judge an example as the example says: it accepted a rejected example, or rejected an accepted one. When another rule rejected the example instead, the message names it. | | [`plugin-failed`](/archangel/reference/diagnostics/plugin-failed/) | A plugin could not give its condition's result: the condition threw, it reported a file that was not in its view, or the plugin's Node process stopped. | # baseline-invalid > The baseline file, `archangel.baseline.json`, does not parse. Exceptions and the baseline. This text is what `archangel docs baseline-invalid` prints. The baseline file, `archangel.baseline.json`, does not parse. ## Example ```sh baseline-invalid: archangel.baseline.json, line 3, column 5: expected `,` ``` ## What to do Fix the file at the line the message names, or delete it and run `archangel baseline add`. # baseline-stale > A baseline entry permits more violations than the code has, because they were fixed, or it names a rule that no longer exists. A check never rewrites the baseline. Exceptions and the baseline. This text is what `archangel docs baseline-stale` prints. A baseline entry permits more violations than the code has, because they were fixed, or it names a rule that no longer exists. A check never rewrites the baseline. ## Example ```sh baseline-stale: the baseline permits 1 more violation(s) of "layers(feature, shared)" in src/shared/a.ts ``` ## What to do Run `archangel baseline prune`, which lowers or removes those entries and never adds one. # config-invalid > The config has a field archangel does not know, a value of the wrong type, or text that is not valid JSON. The diagnostic names the field, such as `rules[0].reasn`. Config problems. This text is what `archangel docs config-invalid` prints. The config has a field archangel does not know, a value of the wrong type, or text that is not valid JSON. The diagnostic names the field, such as `rules[0].reasn`. ## Example ```json { "files": "src/**", "noCycles": true, "reasn": "typo" } ``` ## What to do Fix the field the message names. Every problem in the config is reported at once. # config-load-failed > The config's code threw while it loaded, has no default export, or uses TypeScript that Node cannot strip, such as an enum. Config problems. This text is what `archangel docs config-load-failed` prints. The config’s code threw while it loaded, has no default export, or uses TypeScript that Node cannot strip, such as an enum. ## Example ```ts enum Layer { App, Shared } // Node cannot strip an enum export default defineConfig({ ... }); ``` ## What to do Fix the error the message names. Use a constant object with `as const` instead of an enum, add `.ts` to relative imports, and give the file a default export. # config-missing > Archangel found no config in the current folder or any folder above it. Config problems. This text is what `archangel docs config-missing` prints. Archangel found no config in the current folder or any folder above it. ## Example ```sh $ archangel check config-missing: no config in /repo/packages/web or any folder above it ``` ## What to do Create `archangel.config.ts` or `archangel.config.json` at the top of the repository, or pass `--config `. # config-needs-node > The config is TypeScript or JavaScript, and Node 22.18 or later is not on the search path. Config problems. This text is what `archangel docs config-needs-node` prints. The config is TypeScript or JavaScript, and Node 22.18 or later is not on the search path. ## Example ```sh config-needs-node: the `node` on the search path is version 20.11.0, and archangel needs 22.18 or later ``` ## What to do Install Node 22.18 or later, or write the config as `archangel.config.json`, which needs no Node. # element-empty > A part or an externals set matches nothing, so no rule about it can fire. For a part, no file matches its patterns. For an externals set, no import uses its packages and no package manifest lists them. Config problems. This text is what `archangel docs element-empty` prints. A part or an externals set matches nothing, so no rule about it can fire. For a part, no file matches its patterns. For an externals set, no import uses its packages and no package manifest lists them. ## Example ```json { "elements": { "shared": "src/shraed/**" } } ``` ## What to do Fix the pattern or the package name, or remove the part or the externals set. # entry > Code outside an instance of a part imports a file inside it that is not one of the instance's entry files. A sibling instance counts as outside. Rule violations. This text is what `archangel docs entry` prints. Code outside an instance of a part imports a file inside it that is not one of the instance’s entry files. A sibling instance counts as outside. ## Example ```ts // entry('entity', 'index.ts'), with src/entities/{entity}/** import { store } from '@/entities/ledger/model/store'; ``` `entry('entity', 'index.ts')` is one sentence for each instance, such as `ledger`, whose entry files are relative to the instance root: ```ts rule({ files: { all: [{ element: 'entity', capture: { entity: 'ledger' } }, { not: 'src/entities/ledger/index.ts' }] }, mayOnlyBeImportedBy: { element: 'entity', capture: { entity: 'ledger' } }, }) ``` ## What to do Import the instance through its entry file, such as `@/entities/ledger`. When the entry file does not export what you need, export it there first. # every-file-has-a-kind > A file in the `files` of an import table has no row in that table. Rule violations. This text is what `archangel docs every-file-has-a-kind` prints. A file in the `files` of an import table has no row in that table. ## Example ```ts mayImport('domain', { entity: ['value-object'], 'value-object': [] }, { files: 'apps/api/src/*/domain/**', kinds: '**/*.{kind}.ts', }) ``` The file `apps/api/src/order/domain/order.helper.ts` has the kind `helper`, and the table has no row for it. A file with no kind gives the same problem. ## What to do Add a row for the kind, rename the file so that it has a kind with a row, or use `not` in the table’s `files` to leave the file out. To stop this check for all the table, put `
.every-file-has-a-kind` in `off`. An exception comment cannot permit this problem, because no import is on its line. The baseline can hold it. Only source files count. The config file is in no table. # example-failed > A rule did not judge an example as the example says: it accepted a rejected example, or rejected an accepted one. When another rule rejected the example instead, the message names it. Examples and plugins. This text is what `archangel docs example-failed` prints. A rule did not judge an example as the example says: it accepted a rejected example, or rejected an accepted one. When another rule rejected the example instead, the message names it. ## Example ```sh example-failed: entry(entity) did not reject the example; layers(app, entity, shared) did ``` ## What to do Fix the example so it shows what the rule is about, or fix the rule. # example-proves-nothing > An accepted example passed, but its rule never looked at it, so it proves nothing. An import rule looks at imports, a rule about files at files, a folder rule at folders, a `mustContain` rule on a part at its instances, and a plugin rule at the files of its view. The message says which one the rule never looked at. Examples and plugins. This text is what `archangel docs example-proves-nothing` prints. An accepted example passed, but its rule never looked at it, so it proves nothing. An import rule looks at imports, a rule about files at files, a folder rule at folders, a `mustContain` rule on a part at its instances, and a plugin rule at the files of its view. The message says which one the rule never looked at. ## Example ```ts // isolate('feature'), with an accepted example whose only file is in shared // rule({ files: 'src/**/*.ts', mustBeNamed: 'kebab-case' }), with an accepted example whose only file is README.md ``` ## What to do Give the example what the rule looks at: an import between the parts the rule names, or a file, folder or instance the rule covers. A `mustNotExist` rule rejects every file or folder it looks at, so no accepted example can prove it; give it only rejected examples. # exception-unknown-rule > An `archangel-allow` comment names a rule that the config does not have. The message lists the rules that exist. Exceptions and the baseline. This text is what `archangel docs exception-unknown-rule` prints. An `archangel-allow` comment names a rule that the config does not have. The message lists the rules that exist. ## Example ```ts // archangel-allow entry(entitty): old code ``` ## What to do Use the rule’s name as the config gives it, or as archangel makes it, such as `entry(entity)`. # exception-unused > An `archangel-allow` comment permits nothing, because the import after it does not break the rule it names. The import after it is the one whose specifier is on the next line, or whose statement starts on the next line. Exceptions go away when the code is fixed. Exceptions and the baseline. This text is what `archangel docs exception-unused` prints. An `archangel-allow` comment permits nothing, because the import after it does not break the rule it names. The import after it is the one whose specifier is on the next line, or whose statement starts on the next line. Exceptions go away when the code is fixed. ## Example ```ts // archangel-allow entry(entity): the store was moved import { store } from '@/entities/ledger'; ``` ## What to do Delete the comment. # exception-without-reason > An `archangel-allow` comment gives no reason after the colon, so it permits nothing. Exceptions and the baseline. This text is what `archangel docs exception-without-reason` prints. An `archangel-allow` comment gives no reason after the colon, so it permits nothing. ## Example ```ts // archangel-allow entry(entity): import { store } from '@/entities/ledger/model/store'; ``` ## What to do Write why the import is allowed after the colon. # exports-unknown > A `mustExport` rule needs the names a file exports, and the file passes names on through an `export *` from a package or from a specifier that does not resolve, so archangel cannot know them. Repository problems. This text is what `archangel docs exports-unknown` prints. A `mustExport` rule needs the names a file exports, and the file passes names on through an `export *` from a package or from a specifier that does not resolve, so archangel cannot know them. ## Example src/chip.view\.tsx ```ts // rule({ files: '**/*.view.tsx', mustExport: '{FileName}' }) export * from '@acme/chips'; ``` Archangel follows `export *` from files in the repository, through any number of files, and counts every name the chain brings in except `default`. It gives `exports-unknown` only when the name the rule needs is not among the names it found and some link of the chain leads into a package or does not resolve. The message names the rule, the file and each specifier it could not follow. It reports no violation of the rule for that file, because it cannot prove one, and it does not pass the rule either: `exports-unknown` fails the check. A file that does not parse gets [`parse-failed`](/archangel/reference/diagnostics/parse-failed/) instead, and no export diagnostic. ## What to do Export the names the rule needs by name, such as `export { Chip } from '@acme/chips'`, or fix the specifier that does not resolve. # file-unreadable > Archangel cannot open a folder or a file, or a source file is not UTF-8 or UTF-16 text. Repository problems. This text is what `archangel docs file-unreadable` prints. Archangel cannot open a folder or a file, or a source file is not UTF-8 or UTF-16 text. ## Example ```sh file-unreadable: src/private: archangel cannot open it: permission was refused ``` ## What to do Fix the permissions or the encoding, or add the path to the config’s `ignore` list. # ignore-line-invalid > A line of the config's `ignore` list is not valid `.gitignore` syntax. Repository problems. This text is what `archangel docs ignore-line-invalid` prints. A line of the config’s `ignore` list is not valid `.gitignore` syntax. ## Example ```json { "ignore": ["[unclosed"] } ``` ## What to do Write the line as a `.gitignore` line. # import-unresolved > Archangel cannot find the file or package that an import names. The message says why in plain words. Repository problems. This text is what `archangel docs import-unresolved` prints. Archangel cannot find the file or package that an import names. The message says why in plain words. ## Example ```ts import { missing } from './missing'; ``` ## What to do Fix the specifier. For a module that a tool makes at build time, add the specifier to `allowUnresolved` with the reason, such as `{ 'astro:*': 'Astro makes these at build time' }`. # isolate > A file in one instance of a part imports a file in a different instance of the same part, such as one feature importing another. Rule violations. This text is what `archangel docs isolate` prints. A file in one instance of a part imports a file in a different instance of the same part, such as one feature importing another. ## Example src/features/login/form.ts ```ts // isolate('feature'), with src/features/{feature}/** import { price } from '../billing/price'; ``` `isolate('feature')` is the sentence: ```ts rule({ files: 'feature', mayNotImport: 'feature', instance: 'different' }) ``` ## What to do Move what both instances need into a part they may both import, such as a shared part. # layers > An import goes from one part of a layers rule to a part earlier in its list. Rule violations. This text is what `archangel docs layers` prints. An import goes from one part of a layers rule to a part earlier in its list. ## Example src/shared/format.ts ```ts // layers(['app', 'feature', 'shared']) import { login } from '../features/login'; ``` `layers(['app', 'feature', 'shared'])` is one sentence for each part after the first: ```ts rule({ files: 'feature', mayNotImport: ['app'] }) rule({ files: 'shared', mayNotImport: ['app', 'feature'] }) ``` ## What to do Move the code that both need into the later part, or import it from a part later in the list. Parts outside the rule’s list are not checked by it. # may-import > A file of a row of an import table imports something that its row does not list. Rule violations. This text is what `archangel docs may-import` prints. A file of a row of an import table imports something that its row does not list. ## Example ```ts mayImport('feature-code', { views: ['models', 'shared'], models: ['api', 'shared'], api: ['shared'], }, { kinds: 'src/features/*/{kind}/**' }) ``` The file `src/features/cart/views/cart-page.tsx` imports `src/features/cart/api/fetch-cart.ts`. The row `views` does not list `api`, so archangel reports a violation of the rule `feature-code.views`. ## What a row means Each row is one rule, with the name `
.`. A row has one of two shapes. A row that permits its own name is this sentence: ```ts rule('feature-code.views', { files: 'views', mayOnlyImport: ['models', 'shared'], within: 'the repository and the externals sets that the table names', }) ``` A row in `mayNotImportItself` has this sentence too, without the permission of its own name, and a second sentence: ```ts rule({ files: 'port', mayNotImport: 'port' }) ``` A row permits its own name in the same instance. A capture in a part, or a capture other than `{kind}` in a kind pattern, makes an instance. A row that lists its own name in a cell permits every instance. The own-instance permission applies only when `files` names exactly one part or the row names one kind: a rule with `files: ['feature']`, a list, permits all instances. A table judges an outside package only when one of its cells, or `everyRowMayImport`, names an externals set that holds the package. Each table also makes the rule `
.every-file-has-a-kind`. A file in the table’s files that is in no row breaks this rule. A table without `files` leaves a file with no kind outside the table. Write `files` to close that gap. ## What to do Remove the import, or move the code to a file whose row permits the import. If the design has changed, add the target to the row. To permit one import for a time, write an exception that names the row. # may-not-export > A file in a `mayNotExport` rule has an export of the kind the rule refuses: a default export, an `export *` or `export * as`, or a named export whose name matches the rule's pattern. Rule violations. This text is what `archangel docs may-not-export` prints. A file in a `mayNotExport` rule has an export of the kind the rule refuses: a default export, an `export *` or `export * as`, or a named export whose name matches the rule’s pattern. ## Example ```ts // rule({ files: 'src/**', mayNotExport: 'default' }) export default function App() {} ``` ## What to do Export the value by name, or pass on the names one by one instead of `export *`. The violation is at the line of the export. A violation about a file is permitted only through the baseline, not an exception comment. # may-not-import > A file in the `files` of a `mayNotImport` rule imports a file or package on the rule's other side, directly or, for a transitive rule, through a chain of imports. Rule violations. This text is what `archangel docs may-not-import` prints. A file in the `files` of a `mayNotImport` rule imports a file or package on the rule’s other side, directly or, for a transitive rule, through a chain of imports. ## Example src/entities/ledger/model.ts ```ts // rule({ files: 'entity', mayNotImport: { packages: ['react'] } }) import { useState } from 'react'; ``` Options narrow the imports the rule checks: * `names: ['useEffect']`: only imports that bring in one of these names. A default import brings in `default`; a namespace import, an `export *`, a dynamic import and a `require` bring in every name. * `specifier`: only imports written `'relative'` (`./` or `../`), `'parent'` (`../`), `'alias'` (a tsconfig path or a package `imports` field) or `'package'`. * `instance: 'same'` or `'different'`: only imports within one instance of `files`, or between two; `files` must be a part with a capture. * `dependency: 'dev'` or `'undeclared'`: only imports of packages that the importing file’s nearest `package.json` lists only as development dependencies, or does not list. * `transitively: true`: chains of imports count. The diagnostic shows the shortest chain, such as `app/a.tsx → lib/b.ts → server-only`, and points at the chain’s first import. A chain stops at packages and at files in `stopAt`, such as `{ directive: 'use server' }`. A transitive rule checks runtime imports unless it says otherwise, and takes none of the four options above. ## What to do Remove the import, or move the code to files that may import it. For a chain, remove one of its imports. # may-only-be-imported-by > A file imports a file in the `files` of a `mayOnlyBeImportedBy` rule, and the importing file is neither on the rule's list nor in those files. A file in no part counts as outside the list. Rule violations. This text is what `archangel docs may-only-be-imported-by` prints. A file imports a file in the `files` of a `mayOnlyBeImportedBy` rule, and the importing file is neither on the rule’s list nor in those files. A file in no part counts as outside the list. ## Example src/web/page.ts ```ts // rule({ files: 'db', mayOnlyBeImportedBy: ['api'] }) import { users } from '../db/users'; ``` The options `names`, `specifier`, `instance` and `dependency` narrow the imports the rule checks, as on [`may-not-import`](/archangel/reference/diagnostics/may-not-import/). Add `within` to judge only importers in an area. The rule does not judge an importer outside the area. ```ts // rule({ files: 'db', mayOnlyBeImportedBy: 'api', within: 'apps/**' }) // scripts/seed.ts is outside the area, so the rule does not judge it. ``` ## What to do Go through a file on the list, such as an `api` module, instead of importing the rule’s files directly. # may-only-import > A file in the `files` of a `mayOnlyImport` rule imports a file or package that is neither on the rule's list nor in those files. When `files` is a part with a capture, only the files of the same instance count as those files. Rule violations. This text is what `archangel docs may-only-import` prints. A file in the `files` of a `mayOnlyImport` rule imports a file or package that is neither on the rule’s list nor in those files. When `files` is a part with a capture, only the files of the same instance count as those files. ## Example src/views/cart.view\.tsx ```ts // rule({ files: 'view', mayOnlyImport: ['shared', { packages: ['react'] }] }) import { store } from '../model/store'; ``` An import of a stylesheet counts like any other, so a rule that permits stylesheets lists them, such as `'**/*.css'`. The options `names`, `specifier`, `instance` and `dependency` narrow the imports the rule checks, as on [`may-not-import`](/archangel/reference/diagnostics/may-not-import/). ## Limit the rule to an area Add `within` to judge only imports into an area. The rule does not judge an import whose target is outside the area. An empty list is permitted only with an area. apps/api/src/order/domain/order.entity.ts ```ts // rule({ files: 'entity', mayOnlyImport: ['value-object'], within: 'apps/api/src/**' }) import { save } from '../application/save.use-case'; // reported import { z } from 'zod'; // outside the area, so not judged ``` ## Instances When `files` names exactly one part with a capture, the rule permits its own files only in the same instance. With a list, such as `files: ['feature']`, the rule permits all instances of its own files. To permit all instances of one part, list the part in `mayOnlyImport`. ## What to do Import only what the rule lists, or add the import’s target to the list if the architecture permits it. # must-be-named > A file or folder in a `mustBeNamed` rule has a name that is not in the rule's style, or a full name that does not match the rule's name pattern. For a rule with a list, the name matches no entry in the list. Rule violations. This text is what `archangel docs must-be-named` prints. A file or folder in a `mustBeNamed` rule has a name that is not in the rule’s style, or a full name that does not match the rule’s name pattern. For a rule with a list, the name matches no entry in the list. ## Example ```ts // rule({ files: 'src/components/**/*.tsx', mustBeNamed: 'PascalCase' }) // src/components/icon-button.tsx, whose name icon-button is not PascalCase // rule({ files: 'src/ui/*', mustBeNamed: ['*.view', '*.container'] }) // src/ui/cart.tsx, whose full name cart matches neither entry ``` ## What to do Rename the file or folder to the name the message suggests. The styles are `kebab-case`, `camelCase`, `PascalCase`, `snake_case` and `SCREAMING_SNAKE_CASE`. A style checks the name up to the first dot, so `Card.stories.tsx` is named `Card`. A name pattern checks the full name, the name without its last extension, and can use `{FileName}` or `{folder-name}` in any of the five styles. In a name pattern, `*` is any text or no text. In a list, one match is sufficient. # must-contain > An instance of a part, or a folder, in a `mustContain` rule has no file that matches the rule's pattern. Rule violations. This text is what `archangel docs must-contain` prints. An instance of a part, or a folder, in a `mustContain` rule has no file that matches the rule’s pattern. ## Example ```ts // rule({ files: 'slice', mustContain: 'index.ts' }), with slice: 'src/features/{slice}/**' // src/features/billing, which has no index.ts ``` A list of patterns is met by any one of them, so `['model.*', 'model/**']` takes a file or a folder. In `folders`, a pattern that starts with `!` leaves the folders it matches out, as in `.gitignore`: ```ts rule({ folders: ['src/shared/*', '!src/shared/{ui,lib}'], mustContain: 'index.*' }) ``` ## What to do Add the file the message names. The pattern is relative to the instance’s root, or to each folder for a rule about `folders`, and any file counts, whether or not archangel reads it, so `README.md` is found. # must-export > A file in a `mustExport` rule exports no name that matches the rule's name pattern. Rule violations. This text is what `archangel docs must-export` prints. A file in a `mustExport` rule exports no name that matches the rule’s name pattern. ## Example ```ts // rule({ files: '**/*.view.tsx', mustExport: '{FileName}' }) // src/button.view.tsx, which exports only ButtonView, not Button ``` ## What to do Export the name the message gives. A re-export counts, and so does every name an `export *` chain brings in, except `default`, which `export *` never passes on. # must-not-exist > A file is in the `files` of a `mustNotExist` rule, such as a file in no part or in a reserved folder. Rule violations. This text is what `archangel docs must-not-exist` prints. A file is in the `files` of a `mustNotExist` rule, such as a file in no part or in a reserved folder. ## Example ```ts // rule({ files: { all: ['src/**', { not: { element: '*' } }] }, mustNotExist: true }) // src/stray.ts, which no element's pattern matches ``` With `folders` instead of `files`, the rule gives one violation for each folder it covers, not one for each file in it: ```ts rule({ folders: 'src/processes', mustNotExist: true }) ``` ## What to do Move the file to where the architecture puts it, or delete it. The `files` or `folders` of a `mustNotExist` rule may match nothing, because that is how the rule passes. # must-only-re-export > A file in a `mustOnlyReExport` rule has a top-level statement of its own: a declaration, a type included, or code that runs. It may hold only imports, exports from other files, directives, and `export { … }` of names it imported. Rule violations. This text is what `archangel docs must-only-re-export` prints. A file in a `mustOnlyReExport` rule has a top-level statement of its own: a declaration, a type included, or code that runs. It may hold only imports, exports from other files, directives, and `export { … }` of names it imported. ## Example ```ts // rule({ files: '**/index.ts', mustOnlyReExport: true }) export { a } from './a'; export function b() {} ``` ## What to do Move the declaration into a file of its own and re-export it from there. # no-cycles > A group of files import each other, so each can reach the others. The diagnostic lists every file in the group and shows its shortest cycle. Rule violations. This text is what `archangel docs no-cycles` prints. A group of files import each other, so each can reach the others. The diagnostic lists every file in the group and shows its shortest cycle. ## Example src/a.ts ```ts // rule({ files: 'src/**', noCycles: true }) import { b } from './b'; // src/b.ts import { a } from './a'; ``` ## What to do Remove one import of the cycle, or move what both files need into a third file that neither imports. Type imports do not count unless the rule says `imports: 'all'`. # parse-failed > A source file has syntax errors, uses Flow syntax, or is larger than the parser accepts. Archangel uses none of its imports, because a parser that recovers from errors can invent or lose imports. Repository problems. This text is what `archangel docs parse-failed` prints. A source file has syntax errors, uses Flow syntax, or is larger than the parser accepts. Archangel uses none of its imports, because a parser that recovers from errors can invent or lose imports. ## Example ```ts const = ; ``` ## What to do Fix the errors the message lists. For a Flow file, or a generated file you do not want checked, add it to the config’s `ignore` list. # part-overlap > A file is in two of the config's own parts. A file can be in one part at most, so a rule about parts cannot know which part judges the file. Config problems. This text is what `archangel docs part-overlap` prints. A file is in two of the config’s own parts. A file can be in one part at most, so a rule about parts cannot know which part judges the file. ## Example ```json { "parts": { "model": "src/**/*.model.ts", "feature": "src/features/{feature}/**" } } ``` The file `src/features/login/session.model.ts` is in `model` and in `feature`. ## What to do Leave the file out of one part with a `!` pattern in its list, such as `'!src/shared/widgets/**'`, or make one of the two parts a group. A file can be in many groups. Fix this problem in the config, because the baseline does not hold it. A part of a preset is in a separate set, so it does not cause this problem. # pattern-invalid > A pattern has an empty folder name, a `**` that is not a whole folder name, a capture without a name, or the same capture name twice. Config problems. This text is what `archangel docs pattern-invalid` prints. A pattern has an empty folder name, a `**` that is not a whole folder name, a capture without a name, or the same capture name twice. ## Example ```json { "elements": { "ui": "src/ui**/x" } } ``` ## What to do Write `**` as a whole folder name (`src/**/x`), give each capture a name of letters, digits and underscores, and use each name once. # plugin > A file breaks a rule whose condition a plugin defines, such as `team/one-store-per-entity`. The plugin's condition wrote the message. Rule violations. This text is what `archangel docs plugin` prints. A file breaks a rule whose condition a plugin defines, such as `team/one-store-per-entity`. The plugin’s condition wrote the message. ## Example ```ts // plugins: { team: teamPlugin } // rule({ name: 'one-store', files: 'entity', plugin: 'team/one-store-per-entity', options: { max: 1 } }) // src/entities/user/ has two stores ``` A violation names a file of the condition’s view, or a folder that holds one, as the rules of the Feature-Sliced Design preset’s plugin do, and may name one of the file’s imports. ## What to do Change the code as the message says, or read the condition’s documentation, which the plugin links to. An exception above an import that the violation names permits it; a violation about a whole file can only be baselined. # plugin-failed > A plugin could not give its condition's result: the condition threw, it reported a file that was not in its view, or the plugin's Node process stopped. Examples and plugins. This text is what `archangel docs plugin-failed` prints. A plugin could not give its condition’s result: the condition threw, it reported a file that was not in its view, or the plugin’s Node process stopped. ## Example ```sh plugin-failed: the plugin "team" failed in its condition `team/one-store-per-entity` for the rule "one-store": the condition threw `boom` ``` Each of these gives one `plugin-failed` that names the plugin, its condition and the rule, and says what happened: * the condition’s `check` threw, or its promise was rejected; * it reported a violation without a file, for a file that is not in its view and not a folder that holds a file of its view, with an import on a folder, or with an import the file does not have; * the Node process stopped, with what it printed before it stopped. The check fails, because a rule whose code did not run proves nothing. In the editor, the language server starts the plugin process again on the next change. A plugin written for a view schema version archangel does not send is refused earlier, as [`config-invalid`](/archangel/reference/diagnostics/config-invalid/) naming both versions. ## What to do Read what the plugin printed, which archangel shows with the plugin’s name, and report the failure to the plugin’s author. Until it is fixed, turn the rule off with `off`, which takes any rule’s name after the presets are merged. # preset-empty > A preset the config extends matches no file with any of its parts, so none of its rules can fire. Config problems. This text is what `archangel docs preset-empty` prints. A preset the config extends matches no file with any of its parts, so none of its rules can fire. ## Example ```ts // and the repository has no folder scr export default defineConfig({ extends: [fsd({ root: 'scr' })] }); ``` The message names the preset and its root. A preset declares every part of its architecture, and most repositories have only some of them, so a preset’s parts and groups that match nothing on their own, such as a `widgets` layer the code does not have yet, are not an error: the summary lists them, such as `fsd/widgets` and `fsd/processes`. Only a preset whose parts together match no file at all is `preset-empty`, because that is most likely a wrong root. ## What to do Give the preset the folder it applies to as its `root`, such as `fsd({ root: 'src' })`, or remove it from `extends`. # resolution-file-invalid > A tsconfig or package manifest that resolution needs is not valid: it does not parse, cannot be read, or extends a package that is not installed. Archangel reports it once, not once per import. Repository problems. This text is what `archangel docs resolution-file-invalid` prints. A tsconfig or package manifest that resolution needs is not valid: it does not parse, cannot be read, or extends a package that is not installed. Archangel reports it once, not once per import. ## Example ```json { "extends": "@tsconfig/node20/tsconfig.json" } ``` ## What to do Fix the file, or install the package it extends. When the package is missing on purpose, for example in a sub-project that installs its own packages, add the package to the config’s `allowUnresolved` with the reason. The summary then counts it as a permitted unresolved import. # rule-name-duplicate > Two rules have the same name, often the default name of two rules about the same files, such as `mayNotImport(entity, react)`. Exceptions and the baseline refer to rules by name, so each needs its own. Config problems. This text is what `archangel docs rule-name-duplicate` prints. Two rules have the same name, often the default name of two rules about the same files, such as `mayNotImport(entity, react)`. Exceptions and the baseline refer to rules by name, so each needs its own. ## Example ```ts rule({ files: 'entity', mayNotImport: 'react' }), rule({ files: 'entity', mayNotImport: 'react', imports: 'runtime' }), ``` ## What to do Give each rule its own `name` option. # rule-needs-capture > An isolate rule names a part whose pattern captures nothing, so it has only one instance. Config problems. This text is what `archangel docs rule-needs-capture` prints. An isolate rule names a part whose pattern captures nothing, so it has only one instance. ## Example ```ts // elements: { shared: 'src/shared/**' } isolate('shared') ``` ## What to do Give the part a capture, such as `src/features/{feature}/**`, or remove the rule. # rule-unknown-element > A rule or a group names a part, a group or an externals set that the config does not declare. A string without a `/` or a `*` is a name, not a path pattern. The message lists the declared names. Config problems. This text is what `archangel docs rule-unknown-element` prints. A rule or a group names a part, a group or an externals set that the config does not declare. A string without a `/` or a `*` is a name, not a path pattern. The message lists the declared names. ## Example ```ts rule({ files: 'entity', mayNotImport: 'reactt' }) ``` ## What to do Use one of the declared names, or write a package as `{ packages: ['reactt'] }`. With the TypeScript config, the editor shows a misspelt name in a rule as a type error. # rule-unproven > A rule has no rejected example, so nothing proves that it can find a violation. Examples and plugins. This text is what `archangel docs rule-unproven` prints. A rule has no rejected example, so nothing proves that it can find a violation. ## Example ```ts rule({ files: 'entity', mayNotImport: 'react' }) ``` ## What to do Add a rejected example: a small set of files that the rule must reject, such as `{ rejected: [{ 'src/entities/a/x.ts': "import 'react'" }] }`. # selector-empty > A group that the config declares, a path pattern written inline in a rule, or the `folders` of a folder rule matches nothing, so no rule about it can fire. Config problems. This text is what `archangel docs selector-empty` prints. A group that the config declares, a path pattern written inline in a rule, or the `folders` of a folder rule matches nothing, so no rule about it can fire. ## Example ```json { "selectors": { "views": "**/*.viewz.tsx" } } ``` ```ts // rule({ name: 'legacy', files: 'src/legasy/**', mayNotImport: 'src/next/**' }), and no folder src/legasy // rule({ name: 'readme', folders: 'modules/*', mustContain: 'README.md' }), and no folder modules ``` The message names the group and its definition, or the rule and its pattern. A group counts every file the walk visits, not only source files and not only files an import reaches, so `**/*.png` is not empty while the repository has an image. In the editor, a group can become empty after an edit, such as `{ directive: 'use client' }` when the last client file loses its directive, and the server reports it then. Three cases are exempt, because matching nothing is how they pass or changes nothing: the files or folders of a `mustNotExist` rule, a pattern a rule writes inline only inside a `not`, such as an exemption list shared by rules whose globs match nothing in this repository yet, and everything a preset declares. A group the config declares is not exempt inside a `not`, because declaring it says the repository has such files. A preset’s groups and parts that match nothing are listed in the summary instead; a preset that matches nothing at all is [`preset-empty`](/archangel/reference/diagnostics/preset-empty/). ## What to do Fix the pattern, or remove the group or the rule. A part that matches nothing is [`element-empty`](/archangel/reference/diagnostics/element-empty/). # table-empty > A table has no row, or its rows together have no file, so the table checks nothing. Config problems. This text is what `archangel docs table-empty` prints. A table has no row, or its rows together have no file, so the table checks nothing. ## Example ```ts mayImport('domain', { entity: [] }, { kinds: '**/*.{kind}.tz' }) ``` The repository has only `.ts` files, so no file has a kind. ## What to do Add a row, check the kind pattern and the row names, or remove the table. A row with no file is not an error when the table has other files: the summary lists it. A table in a preset gives one line in the summary, not this error. # table-row-overlap > A file in a table's files is in two rows of that table. Each of the two rows judges the file. Config problems. This text is what `archangel docs table-row-overlap` prints. A file in a table’s files is in two rows of that table. Each of the two rows judges the file. ## Example ```ts groups: { model: 'src/**/*.model.ts', feature: 'src/features/**' }, rules: ({ mayImport }) => [mayImport('app', { model: [], feature: [] })], ``` The file `src/features/cart/cart.model.ts` is in `model` and in `feature`. ## What to do Narrow one of the two rows with `not`, or leave the file out of the table’s files. Fix this problem in the config, because the baseline does not hold it. # Examples > How to write the examples that prove a rule, the one-line form and its limits, and what archangel test checks. An example is code that a rule must accept or must reject. `archangel test` checks each example. A rule that cannot reject its example is wrong, for example because a path in it has a typing error. For why, read [Examples prove rules](/archangel/concepts/examples/). ```ts rule('no-react-in-shared', { files: 'shared', mayNotImport: { packages: ['react'] }, examples: { accepted: ['src/shared/price.ts imports src/shared/money.ts'], rejected: ['src/shared/price.ts imports react'], }, }) ``` | Key | Type | What it holds | | ---------- | ---------------- | ------------------------------ | | `accepted` | list of examples | code that the rule must accept | | `rejected` | list of examples | code that the rule must reject | An example is one line, or an object of files. ## Each rule needs a rejected example `archangel test` fails for a rule that has no rejected example, with [`rule-unproven`](/archangel/reference/diagnostics/rule-unproven/). Only a rejected example shows that the rule can find a problem. A table row needs no written example. `archangel rules` shows what each row judged, and a table that finds no file is the error `table-empty`. The rule `
.every-file-has-a-kind` needs no example either. ## One line Write an example on one line. Archangel makes the files for you. | One line | The files that archangel makes | | ---------------------------------------------- | ----------------------------------------- | | `'src/a.ts imports src/b.ts'` | Two files. `src/a.ts` imports `src/b.ts`. | | `'src/a.ts imports react'` | One file, which imports the package. | | `'src/a.ts imports src/b.ts imports src/a.ts'` | Two files that import each other. | | `'src/app/ui.ts'` | One empty file. | Each file holds one runtime import with a relative specifier for each `imports` after its path, and nothing else. A target is a file when it has a `/`, ends in `.ts`, `.tsx`, `.mts`, `.cts`, `.js`, `.jsx`, `.mjs` or `.cjs`, and does not start with `@` or `node:`. Each other target is a package. So `chart.js` is a package. Write a file in the config folder as `./b.ts`. A package path such as `lodash/fp.js` needs an object of files. The package of an example must be installed, because archangel resolves the import as usual. An example that imports a package that is not installed fails, and the message says that it proves nothing. ### Where one line is refused A file made from one line holds only runtime imports. So archangel refuses one line when the text of the file decides the result: * a rule with `names`, `specifier`, `dependency` or `imports: 'types'`; * a rule whose files, other side or `stopAt` select files by directive; * an export condition: `mustExport`, `mayNotExport`, `mustOnlyReExport`; * a plugin condition. The message names the rule and tells you to write the files. ## An object of files Write each file with its path and its text: ```ts isolate('feature', { examples: { accepted: [{ 'src/features/login/a.ts': "import './b';", 'src/features/login/b.ts': '' }], rejected: [{ 'src/features/login/a.ts': "import '../signup/c';", 'src/features/signup/c.ts': '' }], }, }) ``` * A path starts at the config folder. * A `package.json` among the files, outside `node_modules`, is a package manifest for that example. It replaces the manifest of the repository at the same path. Use it to prove a rule with `dependency`. * An example cannot add a workspace package. * A file of an example that does not parse makes the example fail with `parse-failed`. ## Examples run on top of your repository Archangel puts the files of an example into a check in memory. It does not write a file. The example sees the files of your repository too: * A file of an example can import a file of the repository. * A file of an example at the path of a repository file replaces that file, for that example only. * Other repository files stay. So a folder of an example can already hold files of the repository. For example, a rule says that each folder in `src/features/*` must contain `index.ts`. A rejected example `'src/features/cart/ui/cart.view.tsx'` fails when your repository already has `src/features/cart/index.ts`, because the folder then has its `index.ts`. Use a folder that does not exist, such as `src/features/search/ui/search.view.tsx`. ## When an example passes | Example | It passes when | | -------- | ----------------------------------------------------------------------------------------------------- | | rejected | its own rule gives a problem that involves a file of the example | | accepted | its own rule gives no problem that involves a file of the example, and its rule looked at one of them | * When only other rules reject a rejected example, it fails, and the message names those rules: [`example-failed`](/archangel/reference/diagnostics/example-failed/). * An accepted example that its rule never looked at proves nothing: [`example-proves-nothing`](/archangel/reference/diagnostics/example-proves-nothing/). An import rule looks at imports. A rule about files looks at files. A folder rule looks at folders. * A `mustNotExist` rule rejects each file that it looks at, so it can have only rejected examples. ## Examples of a table A table row needs no example, but you can write examples for a table. They run against all its rows. A rejected example passes when one row refuses one of its imports. An accepted example passes only when a row judged one of its imports. ## Examples of a preset Archangel checks the examples of a preset in your repository, under the preset’s `root`, with your tsconfig. ## The output `archangel test` prints one line for each example, and a summary: Terminal ```ansi $ npx archangel test pass no-react-in-shared rejected #1 src/shared/money.ts 1 examples, none failed; 0 rules without a rejected example 2 table rows need no written example; `archangel rules` shows what each covers ``` It exits with 0 only when each example passes and each rule has a rejected example. # Import options > The options that make an import condition or a table apply to fewer imports, and the options for chains of imports. Add one of these options to an import condition (`mayNotImport`, `mayOnlyImport`, `mayOnlyBeImportedBy`) or to a table. The condition then applies only to some imports. | Option | Values | The condition applies only to | | --------------------------- | ------------------------------------------------ | ------------------------------------------------------ | | [`names`](#names) | list of names | imports that bring in one of these names | | [`specifier`](#specifier) | `'relative'`, `'parent'`, `'alias'`, `'package'` | imports whose specifier has this form | | [`instance`](#instance) | `'same'`, `'different'` | imports inside one instance, or between two instances | | [`dependency`](#dependency) | `'dev'`, `'undeclared'` | packages with this entry in the nearest `package.json` | | [`within`](#within) | set of files | imports into an area, or importers in an area | | [`imports`](#imports) | `'all'`, `'runtime'`, `'static'`, `'types'` | imports of this kind | An import that one of these options leaves out does not count as judged by the rule. `archangel rules` lists it under the imports that no rule judges. ## `names` ```ts rule('no-effects-in-views', { files: 'view', mayNotImport: { packages: ['react'] }, names: ['useEffect'], examples: { rejected: [{ 'src/view/a.ts': "import { useEffect } from 'react';" }] } }) ``` * A default import brings in the name `default`. * A namespace import (`import * as x`), an `export *`, a dynamic `import()` and a `require()` bring in each name of the module, because archangel cannot see which names the code uses. * For a module in a package, archangel reads the names that the module exports, through its relative `export * from` statements too. `import * as fr from 'date-fns/locale/fr'` brings in only the names of that locale. When archangel cannot see every name (a CommonJS module, an `export =`, an `export *` from another package), the import brings in each name there is. ## `specifier` ```ts rule('no-parent-imports', { files: 'feature', mayNotImport: 'feature', specifier: 'parent', examples: { rejected: [{ 'src/features/login/ui/a.ts': "import '../b';", 'src/features/login/b.ts': '' }] } }) ``` | Value | The specifier | | ------------ | ------------------------------------------------------------- | | `'relative'` | starts with `./` or `../` | | `'parent'` | starts with `../` | | `'alias'` | resolves through a tsconfig path or a package `imports` field | | `'package'` | is a package name | ## `instance` ```ts rule('features-apart', { files: 'feature', mayNotImport: 'feature', instance: 'different', examples: { rejected: ['src/features/login/a.ts imports src/features/cart/b.ts'] } }) ``` * `'same'`: imports whose two ends are in one instance of the part in `files`. * `'different'`: imports between two instances of that part. `files` must be one part with a capture. Other `files` give [`rule-needs-capture`](/archangel/reference/diagnostics/rule-needs-capture/). ## `dependency` ```ts rule('no-dev-packages-in-src', { files: 'src/**', mayNotImport: { packages: ['*'] }, dependency: 'dev', examples: { rejected: [{ 'package.json': '{ "devDependencies": { "react": "1.0.0" } }', 'src/a.ts': "import 'react';" }] } }) ``` | Value | The package | | -------------- | -------------------------------------------------------------------------------- | | `'dev'` | is only in `devDependencies` of the nearest `package.json` of the importing file | | `'undeclared'` | is not in the nearest `package.json` of the importing file | The nearest `package.json` is the first one at or above the importing file. In a monorepo, this is the `package.json` of the workspace package. Read [Monorepos](/archangel/guides/monorepos/). ## `within` `within` gives a rule an area. The rule then permits each import outside the area. ```ts rule('entity-imports', { files: 'entity', mayOnlyImport: ['value-object', 'error'], within: 'src/**', examples: { rejected: ['src/entity/a.ts imports src/use-case/a.ts'] } }) ``` | On | `within` limits | | --------------------- | ------------------------------------------------------------------ | | `mayOnlyImport` | the targets: the rule judges only imports into the area | | `mayOnlyBeImportedBy` | the importers: the rule judges only imports from files in the area | | a table | the targets: each row judges only imports into the area | * Without `within`, `mayOnlyImport` judges each import, packages too. * `within` can name an externals set. Then `mayOnlyImport` judges the imports of those packages. * With `within`, the list of `mayOnlyImport` can be empty. The rule then refuses each import into the area, except into its own files. * Archangel refuses `within` on `mayNotImport`, and on a condition that judges no import. To limit `mayNotImport` to an area, write the area on the other side: `mayNotImport: { all: ['use-case', 'src/**'] }`. * An area that finds no file is an error. ## `imports` | Value | The rule counts | | ----------- | ----------------------------------------------------------------------------------------------------------------------------- | | `'all'` | each import. This is the default. | | `'runtime'` | the imports that stay in the compiled code. This is the default for `noCycles` and for a transitive rule. | | `'static'` | the runtime imports other than `import()`: the imports that load with the file, in its bundle chunk. A `require()` is static. | | `'types'` | only type imports, such as `import type` | An `import type`, and an import that names only types with `import { type A }`, 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. ## `transitively` Add `transitively: true` to `mayNotImport`. The rule then finds chains of imports of each length, not only direct imports. The report shows the shortest chain. ```ts rule('client-stays-client', { files: { directive: 'use client' }, mayNotImport: { packages: ['node:*'] }, transitively: true, stopAt: { directive: 'use server' }, examples: { rejected: [{ 'src/view/a.tsx': "'use client';\nimport '../shared/a';", 'src/shared/a.ts': "import 'node:fs';" }], }, }) ``` * A chain stops at a package, and at each file in `stopAt`. * A transitive rule’s side cannot name a package subpath, such as `lodash-es/*`, because a chain ends at a package. * With `imports: 'static'`, a chain also stops at each `import()`. A bundler starts a new chunk there, so the rule then means “the initial bundle may not reach this”: `rule('initial-bundle-no-carousel', { files: 'feature', mayNotImport: { packages: ['carousel'] }, transitively: true, imports: 'static' })`. * A transitive rule counts only runtime imports by default. `imports: 'types'` is refused, because a chain of type imports never runs. * A transitive rule cannot also have `names`, `specifier`, `instance`, `dependency` or `within`. * For each file that reaches the other side, archangel reports one problem with the shortest chain. Among chains of one length, it shows the first in path order. `archangel why ` shows the shortest chain between two files, or from a file to a package. It does not know `stopAt`, so it can show a chain that the rule stops. # Output and exit codes > The text, JSON, GitHub and SARIF formats, the work counts of --stats, and the exit codes. Each example on this page comes from one repository. Its JSON config has one table. The file `src/shared/label.ts` imports a file of the feature `cart`, and the table refuses it: archangel.config.json ```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." } ] } ``` ## `text` The default format, for people. Each problem shows its code, its message, the line of source code with a mark under the import, what to do, the rule and your reason. Terminal ```ansi $ npx archangel check error[may-import]: shared may not import feature, through "../features/cart/total": shared may import only itself --> src/shared/label.ts:1:23 | 1 | import { total } from '../features/cart/total'; | ^^^^^^^^^^^^^^^^^^^^^^^^ shared may import only itself | = help: remove the import, or move the code to a file whose row permits the import = note: rule: app.shared = note: because: Shared code never depends on a feature. checked 2 files and 1 imports: 1 problem For what a code means, run `archangel docs may-import`. ``` | Line | What it is | | ------------------------------ | ------------------------------------------------------------------------------- | | `error[may-import]: ...` | The diagnostic code and the message. `archangel docs ` explains the code. | | `--> src/shared/label.ts:1:23` | The file, the line and the column. | | `= help: ...` | What to do. | | `= note: rule: ...` | The name of the rule, for `off`, an exception or the baseline. | | `= note: because: ...` | Your reason, from `because`. | | `= note: see: ...` | Your link, from `link`. | There are no warnings. Each problem is an error. ## `json` One JSON document, for programs. `check`, `test`, `rules`, `explain` and `why` write JSON. Terminal ```ansi $ npx archangel check --format json { "schemaVersion": 1, "diagnostics": [ { "code": "may-import", "severity": "error", "rule": "app.shared", "file": "src/shared/label.ts", "line": 1, "column": 23, "span": [ 22, 46 ], "specifier": "../features/cart/total", "message": "shared may not import feature, through \"../features/cart/total\": shared may import only itself", "help": "shared may import only itself", "reason": "Shared code never depends on a feature.", "link": null, "id": { "rule": "app.shared", "file": "src/shared/label.ts", "specifier": "../features/cart/total", "occurrence": 0 }, "cycle": [], "group": [], "chain": [], "fix": null } ], "summary": { "files": 2, "imports": 1, "warnings": 0, "exceptions": {}, "baselined": {}, "permittedUnresolved": 0, "unfollowed": 0, "unfollowedImports": [], "linksNotFollowed": [], "nestedRepositories": [], "presetNamesMatchingNothing": [], "work": { "filesFound": 2, "filesRead": 2, "bytesRead": 113, "filesParsed": 2, "importsFound": 1, "resolutions": 1, "filesClassified": 4, "patternPasses": 4, "ruleEvaluations": 1, "cycleFilesVisited": 0, "transitiveFilesVisited": 0, "structureEvaluations": 0, "folderEvaluations": 0, "exportEvaluations": 0, "examplesChecked": 0, "filesWritten": 0, "pluginRecordsSent": 0, "pluginChecks": 0, "packageModulesParsed": 0 }, "rowsWithNoFile": [] } } ``` * `schemaVersion` is the version of the document. * Each diagnostic has `id`, the identity of the problem: the rule, the file, the specifier and the occurrence. It does not hold the line, so it stays the same when the line moves. * `cycle`, `group` and `chain` hold the files of a cycle or of a chain of imports. * `fix` holds a fix that archangel checked, when it has one. * `severity` is `error`, or `warning` for a rule with `severity: 'warn'`. `summary.warnings` counts the warnings. * `summary.unfollowedImports` lists each import whose specifier is computed, by `file` and `line`. The text output lists them under the count. * `summary.work` holds the work counts. * `test --format json` lists each failed example and each unproven rule under `diagnostics` too. * When the config stops a command, the JSON document goes to standard output, as each other document does. `explain` with a line writes each rule and its verdict: Terminal ```ansi $ npx archangel explain src/shared/label.ts:1 --format json { "schemaVersion": 1, "file": "src/shared/label.ts", "line": 1, "imports": [ { "specifier": "../features/cart/total", "line": 1, "target": { "file": "src/features/cart/total.ts" }, "from": { "part": "shared", "instance": [], "kinds": [] }, "to": { "part": "feature", "instance": [ "cart" ], "kinds": [] }, "rules": [ { "rule": "app.feature", "verdict": "skips", "reason": "the file is not in feature", "permittedBy": null }, { "rule": "app.shared", "verdict": "refuses", "reason": "feature is not in its row (shared may import only itself)", "permittedBy": null }, { "rule": "app.every-file-has-a-kind", "verdict": "skips", "reason": "this rule checks files, not imports", "permittedBy": null } ] } ] } ``` `why` writes the chain: Terminal ```ansi $ npx archangel why src/shared/label.ts src/features/cart/total.ts --format json { "schemaVersion": 1, "from": "src/shared/label.ts", "to": "src/features/cart/total.ts", "chain": [ { "file": "src/shared/label.ts", "line": 1, "specifier": "../features/cart/total" } ] } ``` `watch --format json` writes one line of JSON for each check. Read [Commands](/archangel/reference/commands/#watch). ## `github` Writes each problem as a GitHub Actions annotation. GitHub shows it on its line in the pull request. Terminal ```ansi $ npx archangel check --format github ::error file=src/shared/label.ts,line=1,col=23,endColumn=47,title=archangel app.shared::shared may not import feature, through "../features/cart/total": shared may import only itself%0Ahelp: shared may import only itself%0Areason: Shared code never depends on a feature. checked 2 files and 1 imports: 1 problem ``` Use it in a workflow: ```yaml - run: npx archangel check --format github - run: npx archangel test --format github ``` Read [Use archangel in CI](/archangel/guides/ci/). ## `sarif` Writes a SARIF 2.1.0 log, for GitHub code scanning or another tool that reads SARIF. The log lists each diagnostic code as a rule, with the text of `archangel docs`. Each result has the identity of its problem, so an alert stays the same when its line moves. ```sh npx archangel check --format sarif > archangel.sarif ``` ## `--stats` Adds the work that archangel did, and the time of each stage. The work counts are: | Count | What it counts | | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | | files found, files read, bytes read, files parsed | the source files | | imports found, resolutions | the imports, and the imports that archangel resolved | | files classified, pattern passes | the work to find the parts and groups of each file | | rule evaluations | the times that archangel applied a rule | | cycle files visited, transitive files visited | the files that the search for cycles and chains visited | | structure evaluations, folder evaluations, export evaluations | the work of structure, folder and export rules | | examples checked, files written | the examples of `test`. `files written` is always 0. | | plugin records sent, plugin checks | the work of plugins | | package modules parsed | the package files that archangel read for the names that a namespace or dynamic import brings in | The JSON output holds the same counts in `summary.work`. The times are only in `--stats`, because a time changes from run to run. ## Exit codes | Code | Meaning | | ---- | ----------------------------------------------------------------------------------- | | `0` | The command succeeded, and found no problem, or only warnings. | | `1` | Archangel found a problem, or the answer is no. | | `2` | Archangel could not run: the config, the baseline or the command line has an error. | A warning does not change the exit code, unless `check --max-warnings ` is given and there are more warnings than the count: then the check exits with 1. A config error stops the check before archangel reads the code. It exits with 2: Terminal ```ansi $ npx archangel check error[rule-unknown-element]: rules[0].files: the config declares no part or group "shraed"; did you mean "shared"? the declared names are shared; a kind has a meaning only in its table, and a path pattern has a `/` or a `*` --> archangel.config.json:6:37 ``` # Plugin API > defineCondition, definePlugin, the view that a condition receives, and what it returns. A plugin adds conditions that you write in TypeScript. Use a plugin when the built-in conditions cannot say what you want. For a step-by-step guide, read [Write a plugin](/archangel/guides/plugins/). ## A plugin ```ts import { defineCondition, definePlugin } from '@enhance-eng/archangel'; export default definePlugin({ name: 'team', schemaVersion: 1, conditions: [maxImports] }); ``` | Field | Type | What it is | | --------------- | ------ | ------------------------------------------------------------------------------------------- | | `name` | string | The name of the plugin. By convention, the name of its package without `archangel-plugin-`. | | `schemaVersion` | `1` | The version of the view that the plugin was written for. | | `conditions` | list | The conditions of the plugin. | `definePlugin` and `defineCondition` change nothing at run time. They give the types. ## A condition | Field | Type | Required | What it is | | --------------- | ----------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------ | | `name` | string | yes | The name that a rule uses after the plugin’s name, as `team/max-imports`. | | `description` | string | yes | One sentence that says what the condition requires. `archangel rules` uses it. | | `reads` | list of `'imports'`, `'importers'`, `'exports'` | yes | The facts that `check` needs. Archangel sends only these facts. | | `optionsSchema` | JSON Schema | no | The schema of the options of a rule. Archangel refuses a rule whose options do not match it. Without it, a rule cannot have options. | | `docs` | string | no | Where to read more. | | `check` | function | yes | `check(view, options)`. It returns a list of problems, or a promise of a list. | If a condition has a field that archangel does not know, archangel stops before the check and names the field. ## Use a condition in a rule Load the plugin in `plugins`, with the name that rules use. Then write `plugin: '/'`: ```ts plugins: { team }, rules: ({ rule }) => [ rule('small-files', { files: 'feature', plugin: 'team/max-imports', options: { max: 2 }, examples: { ... } }), ], ``` | Option | What it is | | ------------------------------------- | --------------------------------------------------------------------------------- | | `files` | The files that the condition sees. | | `plugin` | The condition, as `/`. | | `options` | The options. Archangel checks them against `optionsSchema` when the config loads. | | `name`, `because`, `link`, `examples` | The same as for each rule. | A plugin rule needs a rejected example, written as an object of files. One-line examples are refused, because the condition can read the text of a file. ## The view `check(view, options)` gets the files of the rule’s `files`, sorted by path. This is a real call. The condition `show` prints its two arguments, and the rule has `options: { max: 2 }`: plugins/dump.ts ```ts import { defineCondition, definePlugin } from '@enhance-eng/archangel'; const show = defineCondition({ name: 'show', description: 'Prints what check receives, and reports nothing.', reads: ['imports', 'importers', 'exports'], optionsSchema: { type: 'object', properties: { max: { type: 'integer' } } }, check(view, options) { console.log(JSON.stringify({ options, view }, null, 2)); return []; }, }); export default definePlugin({ name: 'dump', schemaVersion: 1, conditions: [show] }); ``` Terminal ```ansi $ npx archangel check checked 4 files and 5 imports: no problems [dump] { [dump] "options": { [dump] "max": 2 [dump] }, [dump] "view": { [dump] "schemaVersion": 1, [dump] "files": [ [dump] { [dump] "path": "src/features/cart/cart.ts", [dump] "element": "feature", [dump] "captures": { [dump] "feature": "cart" [dump] }, [dump] "selectors": [], [dump] "imports": [ [dump] { [dump] "specifier": "react", [dump] "occurrence": 0, [dump] "line": 1, [dump] "column": 26, [dump] "kind": "runtime", [dump] "names": [ [dump] "useState" [dump] ], [dump] "file": null, [dump] "package": "react" [dump] }, [dump] { [dump] "specifier": "../login", [dump] "occurrence": 0, [dump] "line": 2, [dump] "column": 25, [dump] "kind": "runtime", [dump] "names": [ [dump] "session" [dump] ], [dump] "file": "src/features/login/index.ts", [dump] "package": null [dump] } [dump] ], [dump] "importers": [], [dump] "exports": { [dump] "names": [ [dump] "cart" [dump] ], [dump] "default": false, [dump] "star": false [dump] } [dump] }, [dump] { [dump] "path": "src/features/login/index.ts", [dump] "element": "feature", [dump] "captures": { [dump] "feature": "login" [dump] }, [dump] "selectors": [], [dump] "imports": [], [dump] "importers": [ [dump] "src/features/cart/cart.ts" [dump] ], [dump] "exports": { [dump] "names": [ [dump] "session" [dump] ], [dump] "default": false, [dump] "star": false [dump] } [dump] } [dump] ] [dump] } [dump] } ``` * `options` is the `options` object of the rule, without changes. * `view.files` has one record for each file in the rule’s `files`. * Each line that a plugin prints goes to standard error, or to the log of the editor, with the name of the plugin. ### A file | Field | Type | What it is | | ----------- | ------------------ | --------------------------------------------------------------------------------------------------------- | | `path` | `string` | The path from the config folder. | | `element` | `string` or `null` | The part of the file. `null` when the file is in no part. | | `captures` | object | The values of the captures of the part. For `src/features/{feature}/**`, this is `{ "feature": "cart" }`. | | `selectors` | `string[]` | The groups that hold the file. | | `imports` | list of imports | Only when the condition reads `'imports'`. | | `importers` | `string[]` | Only when the condition reads `'importers'`. The paths of the files that import this file. | | `exports` | object | Only when the condition reads `'exports'`. | ### An import | Field | Type | What it is | | ---------------- | ----------------------- | ----------------------------------------------------------------------------------------------------------- | | `specifier` | `string` | The text in the quotes, such as `'../login'`. | | `occurrence` | `number` | 0 for the first import of this specifier in the file, 1 for the second, and so on. | | `line`, `column` | `number` | The position of the specifier. Both start at 1. The column counts characters. | | `kind` | `'runtime'` or `'type'` | `'type'` for an import that the compiler removes, such as `import type`. | | `names` | `string[]` | The names that the import brings in: a name, `default` or `*`. | | `file` | `string` or `null` | The repository file that the import resolves to. `null` for a package, or when the import does not resolve. | | `package` | `string` or `null` | The package that the import resolves to. `null` for a repository file. | ### The exports of a file | Field | Type | What it is | | --------- | ---------- | ---------------------------------------------------- | | `names` | `string[]` | The names that the file exports, `default` included. | | `default` | `boolean` | `true` when the file has a default export. | | `star` | `boolean` | `true` when the file has an `export *`. | ## What `check` returns A list of problems. An empty list means no problem. ```ts { file: 'src/features/cart/cart.ts', message: 'cart.ts imports 4 files, and the limit is 2' } ``` | Field | Required | What it is | | --------- | -------- | ----------------------------------------------------------------------------------------------------------------------------- | | `file` | yes | A file of the view, or a folder that holds a file of the view. | | `message` | yes | What is wrong, for the reader. | | `import` | no | `{ specifier, occurrence }`. The import that the problem is about. An exception comment above that import can then permit it. | Each problem is a diagnostic with the code [`plugin`](/archangel/reference/diagnostics/plugin/), like each other problem. The baseline and `off` work the same way. ## When a plugin fails A plugin rule never passes because its code did not run. Archangel shows [`plugin-failed`](/archangel/reference/diagnostics/plugin-failed/), and the check fails, when: * a condition throws an error; * a condition reports a file that is not in its view; * the plugin process stops. ## Where a plugin runs Plugins run in the Node process that loads your config, not in archangel’s own process. In the editor, archangel shows its own problems at once, and the problems of a plugin when the plugin answers. ## The types `@enhance-eng/archangel` exports the types: `View`, `FileRecord`, `ImportRecord`, `ExportsRecord`, `Violation`, `Condition`, `Plugin` and `Fact`. With `defineCondition`, the type of each file in `view.files` has the facts of `reads`, so `file.imports` has no `undefined`. ## Versions A new field in the view does not change `schemaVersion`. A plugin reads only the fields that it knows, so a new release of archangel does not break it. `schemaVersion` changes only when the meaning of a field changes. ## Publish a plugin * Name the package `archangel-plugin-` or `@scope/archangel-plugin`. * Export the plugin as the default export, and a preset as a plain function next to it if you have one. # Presets > The fsd and nextjs presets, their options and rules, how to change a preset, and how to write one. A preset gives you a set of rules in one line. It is a function that returns part of a config: parts, groups, rules with their examples, and the plugins that its rules use. ```ts extends: [fsd({ root: 'src' }), nextjs()], ``` Import a preset from `@enhance-eng/archangel`. A JSON config cannot use a preset, because a preset is code. ## `fsd`: Feature-Sliced Design `fsd(options)` checks a front end that uses [Feature-Sliced Design](https://feature-sliced.design). It has the 21 rules of Steiger, the linter of Feature-Sliced Design. For a full guide, read [Feature-Sliced Design](/archangel/guides/feature-sliced-design/). | Option | Type | Default | What it does | | -------- | ------- | -------- | --------------------------------------------------------------------------- | | `root` | string | required | The folder that holds the layers, such as `src`. | | `strict` | boolean | `false` | Also turns on the four rules that Steiger has as options. | | `name` | string | `'fsd'` | The prefix of each name. A second use in one repository needs its own name. | ### The parts of `fsd` Each pattern starts at `root`. | Part | Pattern | | --------------- | --------------------- | | `fsd/shared` | `shared/**` | | `fsd/entities` | `entities/{slice}/**` | | `fsd/features` | `features/{slice}/**` | | `fsd/widgets` | `widgets/{slice}/**` | | `fsd/pages` | `pages/{slice}/**` | | `fsd/app` | `app/**` | | `fsd/processes` | `processes/**` | Each folder in a sliced layer is a slice. Steiger finds slices by the segments inside them, so the two tools can differ for a folder without segments. ### The rules of `fsd` Each rule has the name of the Steiger rule, with the prefix `fsd/`. Each report links to the Steiger page of its rule. | Rule | On by default | | ---------------------------------- | ------------------- | | `fsd/forbidden-imports` | yes | | `fsd/no-public-api-sidestep` | yes | | `fsd/public-api` | yes | | `fsd/no-layer-public-api` | yes | | `fsd/no-reserved-folder-names` | yes | | `fsd/no-segmentless-slices` | yes | | `fsd/no-segments-on-sliced-layers` | yes | | `fsd/no-ui-in-app` | yes | | `fsd/segments-by-purpose` | yes | | `fsd/no-processes` | yes | | `fsd/typo-in-layer-name` | yes | | `fsd/ambiguous-slice-names` | yes | | `fsd/inconsistent-naming` | yes | | `fsd/insignificant-slice` | yes | | `fsd/repetitive-naming` | yes | | `fsd/excessive-slicing` | yes | | `fsd/shared-lib-grouping` | yes | | `fsd/no-higher-level-imports` | with `strict: true` | | `fsd/no-cross-imports` | with `strict: true` | | `fsd/import-locality` | with `strict: true` | | `fsd/no-wildcard-exports` | with `strict: true` | Seven rules use the conditions of the preset’s plugin `fsd`: `typo-in-layer-name`, `ambiguous-slice-names`, `inconsistent-naming`, `insignificant-slice`, `repetitive-naming`, `excessive-slicing` and `shared-lib-grouping`. Your own rule can use them too, with `plugin: 'fsd/insignificant-slice'`. ## `nextjs`: Next.js `nextjs(options)` keeps server code out of client components. A file that starts with `'use client'`, and each file that it reaches through runtime imports, may not import `server-only` or a Node built-in module. A chain stops at a file that starts with `'use server'`, because Next.js makes an import of a Server Action into a network call. For a full guide, read [Next.js](/archangel/guides/nextjs/). | Option | Type | Default | What it does | | ------ | ------ | ----------------- | ------------------------------ | | `root` | string | the config folder | The folder of the Next.js app. | | `name` | string | `'nextjs'` | The prefix of each name. | It has one rule, `nextjs/client-stays-client`, and two groups: `nextjs/client` and `nextjs/server`. ## Change a preset archangel.config.ts ```ts import { defineConfig, fsd } from '@enhance-eng/archangel'; export default defineConfig({ extends: [fsd({ root: 'src', strict: true })], off: ['fsd/insignificant-slice'], }); ``` * **Names have a prefix.** Each name of a preset starts with the preset’s name, for example `fsd/features`. Your own part `shared` does not replace the part `fsd/shared`. * **A second use needs its own name.** To use a preset two times, for example for two apps, give the second use a `name`, such as `fsd({ root: 'apps/admin/src', name: 'fsd-admin' })`. * **Replace a rule.** Write a rule with the same name as a preset rule, such as `rule('fsd/public-api', { ... })`. Your rule replaces it. `archangel rules` shows “replaces the rule public-api of the preset fsd”. * **Remove a rule.** Put its name in `off`. `archangel rules` lists the rules that `off` removes. * **Typing errors stop the check.** A name in `off` must match a rule, and so must a replacement rule. When it does not, archangel stops and shows the nearest correct name. * **Your names cannot have a slash**, except to replace a preset rule. With `defineConfig`, the editor marks a rule name with a slash that does not start with a preset that you extend. ## Empty parts of a preset A preset declares each part of its architecture, and most repositories use only some parts. So a preset part that finds no file is not an error. The summary of `archangel check` lists it. Here, the repository has no widgets and no processes: Terminal ```ansi $ npx archangel check checked 12 files and 12 imports: no problems fsd/widgets matches nothing in this repository fsd/processes matches nothing in this repository ``` When all parts of a preset find no file, the `root` is probably wrong. Archangel then shows [`preset-empty`](/archangel/reference/diagnostics/preset-empty/). ## The examples of a preset `archangel test` checks the examples of each preset rule in your repository, under the preset’s `root`, with your tsconfig. So the examples prove that the rules work in your repository, not only in the repository of the preset. ## Write a preset A preset is a function that returns an object with these fields: | Field | Type | What it is | | ----------- | ---------------- | ----------------------------------------------------------------------------------------- | | `name` | string | The prefix of each name that the preset declares, such as `fsd`. | | `root` | string | The folder that the preset applies to, from the config folder. Each pattern starts there. | | `package` | string | The package that the preset comes from. A problem that its user cannot fix names it. | | `parts` | object | Parts, as in a config. | | `groups` | object | Groups, as in a config. | | `externals` | object | Externals sets, as in a config. | | `plugins` | object | The plugins that its rules use. | | `rules` | list or function | The rules, each with a name. | Each rule of a preset needs a name. The preset writes names without the prefix, and archangel adds it. Publish a preset as a plain function, in the package of your plugin if you have one. Name the package `archangel-plugin-` or `@scope/archangel-plugin`. # Rule options > The options that every rule, table and shorthand can have: name, because, link, severity, imports and examples, with their types and defaults. Each rule can have these options. A table and a shorthand can have them too, except `name` on a table, which is its first argument. | Option | Type | Default | What it does | | ---------- | ------------------------------------------- | ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- | | `name` | string | from the condition | The name in each report, in `off`, in exception comments and in the baseline. | | `because` | string | none | The reason. Each report shows it. | | `link` | string | none | A link. Each report shows it. | | `severity` | `'error'`, `'warn'` | `'error'` | `'warn'` reports each violation as a warning, and the check passes, as for a deprecation. `check --max-warnings ` fails past a count. | | `imports` | `'all'`, `'runtime'`, `'static'`, `'types'` | `'all'` | The imports that the rule counts. `'runtime'` for `noCycles` and a transitive rule. | | `examples` | `{ accepted, rejected }` | none | Code that the rule must accept, and code that it must reject. | ## `severity` A rule with `severity: 'warn'` reports each violation as a warning, and the check passes. Use it for a deprecation, while the code that uses the old module moves: ```ts rule('no-moment', { files: 'feature', mayNotImport: { packages: ['moment'] }, severity: 'warn' }) ``` `archangel check --max-warnings 0` fails on any warning, for a CI job that keeps the count at zero. ## `name` Write the name first, or as an option. These two rules are the same: ```ts rule('no-react-in-shared', { files: 'shared', mayNotImport: { packages: ['react'] }, examples: { rejected: ['src/shared/a.ts imports react'] } }) ``` ```ts rule({ name: 'no-react-in-shared', files: 'shared', mayNotImport: { packages: ['react'] }, examples: { rejected: ['src/shared/a.ts imports react'] } }) ``` * Without a name, archangel uses the rule itself, for example `isolate(feature)`. A rule with `sentences` must have a name. * Two rules cannot have one name: [`rule-name-duplicate`](/archangel/reference/diagnostics/rule-name-duplicate/). A rule and a table cannot have one name either. * Your own names cannot have a `/`. A `/` separates the name of a preset from its rules. To replace a preset rule, write a rule with the same name, such as `fsd/public-api`. ## `because` Tell the reader why the rule exists. A person or a coding agent reads it when the rule refuses their code. ```ts isolate('feature', { because: 'A feature changes alone. Code that two features need goes in shared.', examples: { rejected: ['src/features/login/a.ts imports src/features/cart/b.ts'] } }) ``` Each report shows it on a line `= note: because: ...`. A config that uses the older spelling `reason` shows `reason:`. ## `link` A link to more information, for example your architecture decision record. Each report shows it on a line `= note: see: ...`. ## `imports` Read [Import options](/archangel/reference/import-options/#imports). ## `examples` Read [Examples](/archangel/reference/examples/). # Sets of files > Each way to write a set of files, and the path patterns that archangel uses. A set of files says which files a rule is about. You write a set in a group, in a rule’s `files`, on the other side of an import condition, in `within`, in `stopAt`, and in a table’s `files`. ## The forms | You write | The set has | | ------------------------------------------------------- | ------------------------------------------------------------------------ | | `'feature'` | the files of the part or group with this name | | `'src/**/*.view.tsx'` | the files that the path pattern finds | | `['app', 'feature']` | the files in one or more of these sets | | `{ any: ['app', 'feature'] }` | the files in one or more of these sets | | `{ all: ['src/**', { not: 'test' }] }` | the files that are in each of these sets | | `{ not: 'test' }` | each file except these files | | `{ element: 'feature' }` | the files of the part `feature` | | `{ element: 'feature', capture: { feature: 'login' } }` | the files of one instance of a part | | `{ element: '*' }` | each file that is in a part | | `{ directive: 'use client' }` | the files that start with this directive | | `{ tag: '@side-effect-free' }` | the files whose comments before the first statement carry this JSDoc tag | On the other side of an import condition, you can also write packages: | You write | The other side has | | -------------------------------------- | ---------------------------------------------------------------------- | | `{ packages: ['react', 'react-dom'] }` | these packages and their subpaths | | `{ packages: ['@radix-ui/*'] }` | each package in the scope | | `{ packages: ['lodash-es/*'] }` | the subpaths of the package, such as `lodash-es/get`, and not its root | | `{ packages: ['*'] }` | each package | | `'driver'` | the packages of the externals set `driver` | ## A name or a path pattern Archangel reads a string in this order: 1. When it is the name of a part, a group or an externals set, it means that name. The names of a preset, such as `fsd/features`, count too. 2. When it has a `/` or a `*`, it is a path pattern. 3. Each other string is an error: [`rule-unknown-element`](/archangel/reference/diagnostics/rule-unknown-element/). The message shows the declared names nearest to it. So `mayNotImport: 'lodash'` is an error, not a package. Write `{ packages: ['lodash'] }`. A kind is not a name here. A kind has a meaning only in its table. ## Path patterns A pattern starts at the config folder. Archangel compares it with paths relative to the config folder, with `/` between folders on each operating system. | In a pattern | It matches | | ------------ | ----------------------------------------------------------------- | | `**` | zero or more folders. It must be a full folder name. | | `*` | zero or more characters in one folder name or file name | | `{a,b}` | `a` or `b` | | `{name}` | a capture: one or more characters in one folder name or file name | Braces with a comma are alternatives. Braces with one name are a capture. A capture records the text that it matched. Read [Captures and instances](/archangel/concepts/captures-and-instances/). Archangel refuses a pattern with an empty folder name (`src//x`), a `**` that is not a full folder name (`src/ui**/x`), a capture without a name, or one capture name two times. The error is [`pattern-invalid`](/archangel/reference/diagnostics/pattern-invalid/). ## Directives `{ directive: 'use client' }` finds each file whose first statement is that directive. Use it for React Server Components and Server Actions: ```ts rule('client-has-no-db', { files: { directive: 'use client' }, mayNotImport: 'db', examples: { rejected: [{ 'src/view/a.tsx': "'use client';\nimport '../db/a';" }], }, }) ``` ## Root files and `!` patterns A pattern that starts with `./` names a path from the config folder. Write `'./app.config.ts'` for a file at the root, because `'app.config.ts'` reads as the name of a part or a group. In a part’s list, a pattern that starts with `!` leaves those files out of the part. Write `pack: ['src/shared/{pack}/**', '!src/shared/widgets/**']` beside `widget: 'src/shared/widgets/{widget}/**'`, so that each widget is in the part `widget` only. ## Package subpaths A package name with a subpath names only those subpaths. This rule bans deep imports into `lodash-es` and permits its root: ```ts rule('no-deep-lodash', { files: 'shared', mayNotImport: { packages: ['lodash-es/*'] } }) ``` ## Tags `{ tag: '@side-effect-free' }` finds each file whose comments before its first statement carry that JSDoc tag, such as `/** @side-effect-free */`. A tag is the word that starts with `@` at the start of a comment line, after any `*`. A license comment above the docblock does not hide it, and a tag in a comment after the first statement does not count. Use it to check what a tag that a bundler acts on promises. This rule keeps tagged files from importing untagged ones: ```ts rule('pure-imports-pure', { files: { tag: '@side-effect-free' }, mayOnlyImport: { tag: '@side-effect-free' } }) ``` A rule that selects files by directive or by tag cannot use a [one-line example](/archangel/reference/examples/), because the text of the file decides the result. ## A set that finds no file A part or a group that finds no file is an error, because a rule about an empty set can never find a problem. The codes are [`element-empty`](/archangel/reference/diagnostics/element-empty/) and [`selector-empty`](/archangel/reference/diagnostics/selector-empty/). A preset part that finds no file is not an error, because most repositories use only some parts of a preset. Archangel lists it in the summary. Read [Presets](/archangel/reference/presets/). # Shorthands > The shorthands layers, isolate and entry: what each one writes for you, its options, its JSON form and the problems it reports. Three shorthands write common rules for you. Each one means one or more sentences, which `archangel rules` shows. | Shorthand | It means | | -------------------------------------- | ------------------------------------------------------------------------------ | | `layers(['app', 'feature', 'shared'])` | `feature` may not import `app`, and `shared` may not import `app` or `feature` | | `isolate('feature')` | an instance of `feature` may not import a different instance of `feature` | | `entry('feature', 'index.ts')` | code outside a feature may import it only through its `index.ts` | Each shorthand takes the [rule options](/archangel/reference/rule-options/) as its last argument. ## layers ```ts layers(['app', 'feature', 'shared'], { because: 'Code depends only on code below it.', examples: { rejected: ['src/shared/a.ts imports src/features/login/b.ts'] } }) ``` The list goes from the top layer to the bottom layer. An import from a file in a layer into a layer earlier in the list is a problem. A layer is a part, an externals set or a group. A group lets a file be in a layer and in a part of its own, such as a feature whose `entry` rule needs a capture: list groups such as `pagesLayer: 'src/pages/**'` and `featuresLayer: 'src/features/**'` in the order, by name, as you list parts. These imports pass: * imports inside one layer; * imports into a layer later in the list; * imports where one end is in no layer of the list. `layers(['app', 'feature', 'shared'])` means these two sentences: ```ts rule('feature-below-app', { files: 'feature', mayNotImport: ['app'], examples: { rejected: ['src/features/login/a.ts imports src/app/a.ts'] } }) ``` ```ts rule('shared-at-bottom', { files: 'shared', mayNotImport: ['app', 'feature'], examples: { rejected: ['src/shared/a.ts imports src/app/a.ts'] } }) ``` A layer can be an externals set. Then a layer above it may not import its packages. The diagnostic code is [`layers`](/archangel/reference/diagnostics/layers/). A table says more than `layers`, because it lists what each row may import, and it refuses each other import. Use `layers` when an order is all that you need. ## isolate ```ts isolate('feature', { examples: { rejected: ['src/features/login/a.ts imports src/features/cart/b.ts'] } }) ``` The part must have a capture, such as `src/features/{feature}/**`. `isolate('feature')` means: ```ts rule('features-apart', { files: 'feature', mayNotImport: 'feature', instance: 'different', examples: { rejected: ['src/features/login/a.ts imports src/features/cart/b.ts'] } }) ``` The diagnostic code is [`isolate`](/archangel/reference/diagnostics/isolate/). ## entry ```ts entry('feature', ['index.ts', 'index.tsx'], { examples: { rejected: ['src/app/a.ts imports src/features/login/form.ts'] } }) ``` Code outside an instance may import that instance only through its entry files. Imports inside one instance pass. * The second argument is a file pattern, or a list of patterns. * A pattern starts at the root of the instance. For `src/features/{feature}/**`, the root of `login` is `src/features/login`, so `index.ts` means `src/features/login/index.ts`. * For a part without a capture, the root is the folders before the first wildcard of its pattern. When an import goes past the entry, the report says which import to use instead. In this repository, `src/app/main.ts` imports a file inside the cart feature: Terminal ```ansi $ npx archangel check error[entry]: "../features/cart/cart-total" reaches inside "cart" of "feature" past its entry files --> src/app/main.ts:1:27 | 1 | import { cartTotal } from '../features/cart/cart-total'; | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ import it through "../features/cart" | = note: rule: entry-point error[may-import]: feature may not import another instance of feature, through "../cart": feature may import only shared, and itself in the same instance --> src/features/checkout/index.ts:1:27 | 1 | import { cartTotal } from '../cart'; | ^^^^^^^^^ feature may import only shared, and itself in the same instance | = help: remove the import, or move the code to a file whose row permits the import = note: rule: element-types.feature checked 6 files and 6 imports: 2 problems For what a code means, run `archangel docs entry` or `archangel docs may-import`. ``` The diagnostic code is [`entry`](/archangel/reference/diagnostics/entry/). ## In JSON In a JSON config, write a shorthand with its `kind`: ```json { "kind": "layers", "order": ["app", "feature", "shared"] } { "kind": "isolate", "element": "feature" } { "kind": "entry", "element": "feature", "files": ["index.ts"] } ``` The same objects can be sentences of a rule with `sentences`. # Tables > The mayImport table, its rows, cells and options, and the rules that it makes. A table says what each set of files may import. It is the main form of an architecture test. For the ideas behind it, read [Tables, rows and cells](/archangel/concepts/tables-rows-and-cells/). ```ts mayImport(name, rows, options) ``` | Argument | Type | What it is | | --------- | ------ | -------------------------------------------------------------- | | `name` | string | The name of the table. It has no `:` and no `/`. | | `rows` | object | One row for each set of files. The value is the list of cells. | | `options` | object | Optional, except `kinds` for a table of kinds. | This table has rows of parts: ```ts mayImport('backend', { routes: ['services'], services: ['domain', 'db'], db: ['domain', 'driver'], domain: [], }, { because: 'A route calls a service. Only db talks to the database.', }) ``` ## Rows A row name means, in this order: 1. the declared part or group with that name; 2. in a table with `kinds`, the kind with that name. A row cannot name an externals set. Archangel refuses a row name that means nothing, and shows the nearest declared name. Each row is a rule with the name `
.`, for example `backend.routes`. Use this name in `off`, in an exception comment and in the baseline. ## Cells A cell is one name in the list of a row. A cell can be: * the name of a row of the same table; * the name of a part, a group or an externals set; * a path pattern, such as `'src/config.ts'` or `'src/generated/**'`. Archangel refuses a cell that is none of these. ## What a row permits A row permits only: 1. the cells in its list; 2. its own name, in the same instance. A row of a part with a capture permits imports inside its own instance, and refuses imports into other instances of that part. Use `mayNotImportItself` to remove this; 3. the cells in `everyRowMayImport`. The row refuses each other import into the repository. An import of a file that has no row gives the message “no row for …”. **A table judges a package only when a cell names it.** A table judges an import of an outside package only when a cell, or `everyRowMayImport`, names an externals set with that package. So a row with an empty list can still import each package that no cell names. To refuse a package, put it in an externals set and name the set in the cells of the rows that may use it. ## Options | Option | Type | Default | What it does | | ---------------------------------------------- | ------------------------------------------- | -------------------- | --------------------------------------------------------------------------------------------------------------------------- | | `kinds` | path pattern with `{kind}` | none | The pattern that gives each file its kind. With it, a row name can be a kind. Read [Kinds](/archangel/concepts/kinds/). | | `files` | set of files | each file with a row | The importers that the table judges. With `files`, each file in the set must have a row. | | `within` | set of files | the repository | The area of the table. The rows judge only imports whose target is in the area. | | `everyRowMayImport` | list of cells | `[]` | Cells that each row has, for example a file that all code may use. | | `mayNotImportItself` | list of row names | `[]` | Rows that may not import their own name, in the same instance or in a different instance. | | `because` | string | none | The reason, which each problem shows. | | `link` | string | none | A link, which each problem shows. | | `examples` | object | none | Examples for the whole table. A row needs no written example. | | `imports` | `'all'`, `'runtime'`, `'static'`, `'types'` | `'all'` | The kinds of import that each row counts. | | `names`, `specifier`, `instance`, `dependency` | | none | The same as for an import condition. Each row gets the option. Read [Import options](/archangel/reference/import-options/). | A row that `everyRowMayImport` names cannot be in `mayNotImportItself`. ### `kinds` ```ts mayImport('model', { entity: ['value-object'], event: ['entity', 'value-object'], 'value-object': [], }, { files: 'domain', kinds: 'src/domain/*.{kind}.ts', }) ``` | Pattern | File | Kind | | -------------------------- | ---------------------------------- | -------- | | `**/*.{kind}.ts` | `src/order/order.entity.ts` | `entity` | | `src/features/*/{kind}/**` | `src/features/cart/views/page.tsx` | `views` | | `**/{kind}/**/*.{kind}.ts` | `src/entity/user.entity.ts` | `entity` | `{kind}` can be two times in a pattern. Then the two places must give the same name. A declaration file counts `.d.ts` as one extension, so `env.d.ts` has no kind `d`. A row name that is a declared part or group means that part or group, also when the kind pattern gives the same name. The `files` option does not change the kind of a file. It only chooses the importers that the table judges. A file outside `files` still has its kind when a file in `files` imports it. ### `files` Without `files`, the table judges each file that has a row. A file that has no row is not checked, and `archangel rules` lists it under the files that no rule judges. With `files`, each file in the set must have a row. A file without a row is a problem of the rule `
.every-file-has-a-kind`, with the code [`every-file-has-a-kind`](/archangel/reference/diagnostics/every-file-has-a-kind/). To let a file out, use `not` in `files`, or put `
.every-file-has-a-kind` in `off`. ### `within` ```ts mayImport('domain-model', { entity: ['value-object'], 'value-object': [], }, { kinds: 'src/domain/*.{kind}.ts', within: 'domain', }) ``` With `within`, the table ignores each import whose target is outside the area. Here, a domain file can import a package or a file in `shared`, and the table does not judge it. ## The rules that a table makes A table with the rows `routes` and `services` makes these rules: | Rule | It refuses | | ------------------------------- | -------------------------------------------------------------- | | `
.routes` | an import of a file in `routes` that its row does not permit | | `
.services` | an import of a file in `services` that its row does not permit | | `
.every-file-has-a-kind` | a file in the table’s `files` that has no row | `archangel rules` shows each one as a sentence: Terminal ```ansi $ 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. ``` ## Errors in a table | Code | When | | -------------------------------------------------------------------------------- | --------------------------------------------------------------------- | | [`table-empty`](/archangel/reference/diagnostics/table-empty/) | The table has no row, or its rows find no file, so it checks nothing. | | [`table-row-overlap`](/archangel/reference/diagnostics/table-row-overlap/) | Two rows find one file. | | [`rule-unknown-element`](/archangel/reference/diagnostics/rule-unknown-element/) | A row or a cell names nothing. | | [`config-invalid`](/archangel/reference/diagnostics/config-invalid/) | A name has a `:` or a `/`, or an option has a wrong value. | One row that finds no file is not an error, because you can write a row before its first file exists. `archangel check` and `archangel rules` list that row. A table in a preset that checks nothing gives one line in the summary, not an error. ## A table in JSON In a JSON config, a table is an object with `"kind": "mayImport"`, a `name` and its `rows`: archangel.config.json ```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." } ] } ``` The options have the same names. For a table of kinds, add `"kinds": "src/features/*/{kind}/**"`. # Getting started > Install archangel, write a first table, see it find a problem, and prove a rule with an example. This page takes you from an empty config to a table that finds a real problem. It takes about ten minutes. Each output on this page is what archangel 0.3.5 prints for the files on this page. ## Before you start * You need Node 22.18 or later. Node runs `archangel.config.ts` without a build step. * You need a TypeScript or JavaScript repository. The example repository is a small shop. It has two features and some shared code: * package.json * src/ * features/ * cart/ * cart.ts * index.ts * checkout/ * checkout.ts * shared/ * money.ts ## Install archangel and write a config 1. Install the package as a development dependency: ```sh npm install --save-dev @enhance-eng/archangel ``` 2. Write a starter config: Terminal ```ansi $ npx archangel init --typescript wrote archangel.config.ts with 2 parts: features, shared It imports `@enhance-eng/archangel`, so install the package first: npm install --save-dev @enhance-eng/archangel Write what each part may import in a table, then run `archangel check`; `archangel docs may-import` shows how. ``` `init` makes one part for each folder of source files. It also adds one rule against import cycles: archangel.config.ts ```ts import { defineConfig } from '@enhance-eng/archangel'; export default defineConfig({ parts: { features: 'src/features/**', shared: 'src/shared/**', }, rules: ({ rule }) => [ rule('no-cycles', { files: '**', noCycles: true, because: 'Files that import each other cannot change or load one at a time.', examples: { accepted: ['cycle/a.ts imports cycle/b.ts'], rejected: ['cycle/a.ts imports cycle/b.ts imports cycle/a.ts'], }, }), ], }); ``` 3. Check the repository: Terminal ```ansi $ npx archangel check checked 5 files and 4 imports: no problems ``` The count includes the config file. The starter config finds no problem, because it has no rule about your architecture yet. ## Write a table A table is the main form of an architecture test. Each row of the table is a set of files. The list on the row is what those files may import. The table refuses each other import into the repository. The shop has one decision: **a feature does not import another feature.** Code that two features need goes in `shared`. 1. Replace the config with this config: archangel.config.ts ```ts import { defineConfig } from '@enhance-eng/archangel'; export default defineConfig({ parts: { feature: 'src/features/{feature}/**', shared: 'src/shared/**', }, rules: ({ mayImport, rule }) => [ mayImport('app', { feature: ['shared'], shared: [], }, { because: 'A feature changes alone. Code that two features need goes in shared.', }), rule('no-react-in-shared', { files: 'shared', mayNotImport: { packages: ['react'] }, because: 'Shared code also runs on the server.', examples: { rejected: ['src/shared/money.ts imports react'], }, }), ], }); ``` * `parts` gives names to the parts of the code. `{feature}` is a **capture**. It makes each folder in `src/features` one **instance** of the part `feature`, so `cart` and `checkout` are two instances. * The table `app` has two rows. A file in `feature` may import `shared`, and files in its own instance. A file in `shared` may import only `shared`. * `because` is your reason. Archangel shows it with each problem. * The rule `no-react-in-shared` is a rule with one condition. The next section tells you about it. 2. Check the repository again: Terminal ```ansi $ npx archangel check error[may-import]: feature may not import another instance of feature, through "../cart/cart": feature may import only shared, and itself in the same instance --> src/features/checkout/checkout.ts:1:27 | 1 | import { cartTotal } from '../cart/cart'; | ^^^^^^^^^^^^^^ feature may import only shared, and itself in the same instance | = help: remove the import, or move the code to a file whose row permits the import = note: rule: app.feature = note: because: A feature changes alone. Code that two features need goes in shared. checked 5 files and 4 imports: 1 problem For what a code means, run `archangel docs may-import`. ``` `checkout.ts` imports a file inside the cart feature. The error shows the line, the rule `app.feature`, your reason, and what the row permits. 3. Fix the problem. `checkout.ts` needs only the formatted total, so import `formatMoney` from `shared`: src/features/checkout/checkout.ts ```ts import { formatMoney } from '../../shared/money'; export function checkoutSummary(prices: number[]): string { return `Total: ${formatMoney(prices.reduce((sum, price) => sum + price, 0))}`; } ``` 4. Check again: Terminal ```ansi $ npx archangel check checked 5 files and 4 imports: no problems ``` An import through index.ts is still an import If `checkout.ts` imports `'../cart'`, the import goes to `cart/index.ts`, which is still in the instance `cart`. The table refuses it too. To permit imports through an entry file only, use [`entry`](/archangel/reference/shorthands/#entry). ## Prove a rule with an example A rule can be wrong. For example, a typing error in a path can make a rule find no files, and then it never finds a problem. So each rule that is not a table row must have a **rejected example**: code that the rule must refuse. The rule `no-react-in-shared` has one example, on one line: ```ts rule('no-react-in-shared', { files: 'shared', mayNotImport: { packages: ['react'] }, because: 'Shared code also runs on the server.', examples: { rejected: ['src/shared/money.ts imports react'], }, }) ``` Run the examples: Terminal ```ansi $ npx archangel test pass no-react-in-shared rejected #1 src/shared/money.ts 1 examples, none failed; 0 rules without a rejected example 2 table rows need no written example; `archangel rules` shows what each covers ``` Archangel makes the files of each example in memory, and checks that the rule refuses them. It does not write files. A table row needs no example, because `archangel rules` shows what each row judged, and a table that finds no file is an error. ## See what your rules do `archangel rules` shows each rule as a sentence, with the number of files and imports that it judged: Terminal ```ansi $ npx archangel rules app 2 rows archangel.config.ts:9 because: A feature changes alone. Code that two features need goes in shared. app.feature: feature may import only shared, and itself in the same instance 3 files · 3 imports judged · 1 problem · 0 exceptions app.shared: shared may import only itself 1 file · 0 imports judged · 0 problems · 0 exceptions app.every-file-has-a-kind: a file in no row is not checked here; add `files` to check it 4 files · 0 problems no-react-in-shared archangel.config.ts:16 because: Shared code also runs on the server. files in shared may not import react 1 file · 0 imports judged · 0 problems · 0 exceptions Not judged by any rule: 0 files, 0 imports between repository files. Outside packages that no rule names: none. ``` The last two lines are important. They list the files and imports that no rule judges, and the outside packages that no rule names. A file in that list can break your architecture, and no test fails. ## Understand one import To see why archangel refused an import, give the file and the line: Terminal ```ansi $ npx archangel explain src/features/checkout/checkout.ts:1 src/features/checkout/checkout.ts:1 '../cart/cart' -> src/features/cart/cart.ts file: part feature (instance checkout) target: part feature (instance cart) judged by: app.feature refuses: another instance of feature is not in its row (feature may import only shared, and itself in the same instance) skipped by: app.shared the file is not in shared app.every-file-has-a-kind this rule checks files, not imports no-react-in-shared the file is not in shared ``` ## Next steps * [Add archangel to a repository that already breaks its rules](/archangel/guides/existing-repository/). * [Use archangel in CI](/archangel/guides/ci/) and [in your editor](/archangel/guides/editors-and-agents/). * Read [Tables, rows and cells](/archangel/concepts/tables-rows-and-cells/) to write larger tables. # How archangel works > The map of the code, how a rule asks a question about it, the loop of check, test and rules, and why it is fast. 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. ## 1. The map of the code 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. ## 2. Names on the map 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](/archangel/concepts/parts-groups-and-externals/) for each one. ## 3. Rules ask questions 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. ## 4. The loop 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 ```ansi $ 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 ```ansi $ 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 ```ansi $ 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. ## The facts and the rules are separate 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. ## Why it is fast * A Rust core reads and parses each file one time, with [oxc](https://oxc.rs). * 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](/archangel/reference/output-and-exit-codes/). ## Where config code runs 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. # Introduction > What archangel is, what it checks, what it does not check, and why architecture needs tests. Archangel tests the structure of your TypeScript code: which code imports which code, where files are, what files are called, and what they export. You write each architecture decision as a test. When the code breaks a test, archangel tells you, or your coding agent, where, why, and what to do. Unit tests check behavior. The type checker checks types. Archangel checks architecture. ## The problem Some decisions about your code are in the code itself: * Features do not import each other. * The UI does not import the database client. * Shared code does not know about the app. * Other code imports a feature only through its `index.ts`. These decisions make a codebase easy to change. When features do not depend on each other, you can change one feature and not break another. Usually, a decision like this is in a document, a diagram, or the memory of a senior engineer. None of these stop the next import. A reviewer reads the diff, not the graph of imports, and a break is often one line in a large change. Each break makes the next break easier. After some months, the code does not have the architecture that the diagram shows. Coding agents make this happen faster. An agent writes code faster than a person can review it, and it does what makes the task work. When the fastest fix is an import from another feature, it adds that import. ## What archangel does Archangel turns each decision into a test that a machine checks. A broken decision then fails like a broken test. The decision is in the repository, it has a version, and each change is checked against it. | It checks | For example | | --------------------- | --------------------------------------------------------------------------------------------------------------------- | | **Imports** | A view may import models and shared code, and nothing else. A package that `package.json` does not list is a problem. | | **Chains of imports** | A client component may not reach `server-only` through any chain of imports. | | **Cycles** | Files may not import each other in a cycle. | | **Structure** | Each feature folder has an `index.ts`. No file is outside a part. | | **Names** | Component files are in `PascalCase`. Files in `ui/` end in `.view` or `.container`. | | **Exports** | Each component file exports the name of its file. Index files only re-export. | It works where you work: * **In your editor**, through a language server. A problem shows as you type. * **In CI**, with annotations on the lines of a pull request. * **For coding agents**, with watch mode and JSON output. An agent sees the problem while it writes the code. It is fast. A Rust core reads each file one time. It checks 400 rules on a 9,800-file monorepo in 1.9 seconds on a laptop. Read [How archangel works](/archangel/start/how-archangel-works/). ## What archangel believes **A rule that cannot fail is a defect.** A rule that finds no files shows “no problems”. It looks the same as a rule that works, so the team believes that it is protected. So archangel does not let a rule pass in silence. Each rule needs an example of code that it must reject. A part, a group or a table that finds no file is an error. An import that archangel cannot resolve is an error. **There are no warnings.** A rule is on or off. Teams learn to ignore warnings, and a coding agent cannot know which warnings are important. When a rule is not important enough to fail the check, turn it off. **Each problem says what to do.** A message names the rule and your reason, and it shows what the rule permits. **Exceptions are visible and have a reason.** An exception is a comment above the one import that it permits, with a reason. For an old codebase, a baseline records the problems of today, and it can only get smaller. **The facts and the rules are separate.** Which file imports which file is a fact about the code. Which imports are permitted is a decision in the config. So you can test the rules with small examples. ## What archangel is not * **Not a linter for code style.** Use ESLint or Biome. * **Not a type checker.** A rule that needs types, such as “only the repository calls `save`”, stays in ESLint with type information. * **Not a check of the whole architecture.** Archangel checks the decisions that are in the source code. How services run, where data lives and how the system is deployed need other tools. * **Not a diagram tool.** Archangel can explain a file and show a chain of imports. It does not draw your architecture. It checks it. ## Where the ideas come from Archangel adds speed, examples and coding agents to old ideas: | Idea | Where it comes from | | ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | | Architecture has several views, and one of them is the structure of the code | Philippe Kruchten, “The 4+1 View Model of Architecture” (1995) | | A module hides a decision, and other modules depend only on what it shows | David Parnas, “On the Criteria To Be Used in Decomposing Systems into Modules” (1972) | | Layers, and no cycles between parts of the code | John Lakos, *Large-Scale C++ Software Design* (1996); Robert C. Martin’s Acyclic Dependencies Principle | | Dependencies point inward, toward the core | Robert C. Martin, *Clean Architecture* (2017); Alistair Cockburn, ports and adapters (2005) | | Architecture rules as tests that run with the build | ArchUnit, for Java | | An automated check that keeps an architecture from decay | “Fitness functions”, in *Building Evolutionary Architectures* (2017) | | Import rules for JavaScript and TypeScript | dependency-cruiser, eslint-plugin-boundaries, Nx module boundaries, Sheriff | | Layers, slices and segments for front ends | Feature-Sliced Design, and its linter Steiger | | A rule is proved by code that it must accept and reject | ESLint’s `RuleTester`, and mutation testing | | A baseline of old problems that only gets smaller | The baselines of Psalm and detekt | | Fast tools for JavaScript, written in Rust | oxc, which archangel uses to read and resolve code; Ruff and Biome | ## Next [Getting started](/archangel/start/getting-started/)Install archangel and write a first table. [How archangel works](/archangel/start/how-archangel-works/)The map of the code, the rules, and the loop of check, test and rules.