Skip to content

Rules and conditions

Markdown for agents

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:

rule('no-react-in-shared', { files: 'shared', mayNotImport: { packages: ['react'] } })

“Files in shared may not import react.”

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
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
$ 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.

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, with an example.

An import condition can apply to some imports only. Add an option such as names, specifier, instance, dependency or within:

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 lists each option.

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.

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.

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.