Skip to content

Write a plugin

Markdown for agents

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 cannot say what you want.

  • Read Getting started.
  • Your config is archangel.config.ts. A JSON config cannot load a plugin, because a plugin is code.
  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.

  1. Write the conditions in plugins/team.ts:

    plugins/team.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
    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.
  1. Run the examples:

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

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

A problem names a file of the view, or a folder that holds a file of the view:

{ 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:

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

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.

  • Name the package archangel-plugin-<name> 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.