Skip to content

The plugin model

Every team has diagram rules of its own: a house palette, a banned word, a required title. Plugins let you add those rules without forking the tool. This page explains where plugin rules come from, how they are checked, and how they join the built-in rules.

A plugin rule is a built-in with a namespace

A plugin rule receives exactly the context a built-in rule receives, and returns the same findings and edits. When it loads, it becomes an ordinary rule whose id is namespace/key, such as acme/acme-direction. From then on the config, the fix engine and the output treat it like a built-in.

erDiagram
    PluginSource }|--|| Plugin : "loads as"
    Plugin ||--|| PluginMeta : "meta"
    Plugin ||--o{ PluginRule : "rules"
    PluginRule ||--|| Rule : "becomes namespace/key"
    Registry ||--|{ Rule : "rules"
    Config ||--o{ Rule : "configures"

    classDef pluginTint fill:#6d28d936,stroke:#8b5cf6,stroke-width:2px
    classDef ruleTint fill:#1d4ed836,stroke:#3b82f6,stroke-width:2px
    classDef configTint fill:#b4530936,stroke:#d97706,stroke-width:2px
    class PluginSource,Plugin,PluginMeta,PluginRule pluginTint
    class Rule,Registry ruleTint
    class Config configTint
Detailed: plugin, config and registry fields

A plugin may declare new category words, the config sets a severity and options per rule id, and the registry keeps only the rules the config leaves on.

erDiagram
    PluginSource }|--|| Plugin : "loads as"
    Plugin ||--|| PluginMeta : "meta"
    Plugin ||--o{ PluginRule : "rules"
    PluginMeta ||--o{ Category : "declares"
    PluginRule }o--|| Category : "category"
    PluginRule ||--|| Rule : "becomes namespace/key"
    Registry ||--|{ Rule : "rules"
    Registry ||--|{ Category : "categories"
    Registry ||--o{ LoadedSource : "sources"
    Config ||--o{ RuleSetting : "rules"
    RuleSetting }o--|| Rule : "applies to id"
    Config ||--o{ PluginSource : "plugins"

    PluginSource {
        enum kind "local package config"
        string spec "package name or path"
        string path "file to import"
    }
    Plugin {
        PluginMeta meta
        record rules "key to PluginRule"
    }
    PluginMeta {
        string name
        string namespace "lower-case, not local"
        number apiVersion
        record categories "optional, word to description"
    }
    PluginRule {
        string category
        string summary
        OptionSpec options "optional"
        function check "ctx to PluginFinding[]"
        function fix "optional, ctx to PluginEdit[]"
    }
    Category {
        string word "parse complexity contrast or declared"
        string description
    }
    Rule {
        RuleId id "namespace/key"
        string category
        string summary
        OptionSpec options "optional"
        function check
        function fix "optional"
    }
    Registry {
        Rule[] rules "active only"
        Rule[] fixOrder
        set enabled "RuleId"
        map options "RuleId to RuleOptions"
        map categories "word to description"
        LoadedSource[] sources
        string[] ignoredSettings
    }
    LoadedSource {
        enum kind "local package config"
        string spec
        string namespace
        RuleId[] rules
    }
    Config {
        list plugins "string[] or false"
        enum pluginLoading "strict or lenient"
        map rules "id to RuleSetting"
        string file "nullable"
    }
    RuleSetting {
        enum severity "error or off"
        RuleOptions options
    }

    classDef pluginTint fill:#6d28d936,stroke:#8b5cf6,stroke-width:2px
    classDef ruleTint fill:#1d4ed836,stroke:#3b82f6,stroke-width:2px
    classDef configTint fill:#b4530936,stroke:#d97706,stroke-width:2px
    classDef wordTint fill:#15803d36,stroke:#22c55e,stroke-width:2px
    class PluginSource,Plugin,PluginMeta,PluginRule pluginTint
    class Rule,Registry,LoadedSource ruleTint
    class Config,RuleSetting configTint
    class Category wordTint

The engine adds the rule id and the file to every finding, and the rule id to every edit. A plugin therefore cannot report under another rule's name.

Three places a rule can come from

Discovery reads three places, in this order:

  1. Local rule files in .mermaidjs-lint/rules at the project root. Each file is one rule, and all of them load together as one plugin under the reserved namespace local.
  2. Declared dependencies whose name matches mermaidjs-lint-plugin or mermaidjs-lint-plugin-*, scoped or not. Only the project's own dependencies, devDependencies and optionalDependencies are read.
  3. Paths or names listed under plugins in the config file.

node_modules is never scanned for plugins. Only declared names and local rule files load, and a declared name resolves from the nearest node_modules at or above the project root. That matters because a plugin is code that runs on your machine.

Discovery runs in every plugin mode, but only a mode other than off imports what it found.

sequenceDiagram
    participant S as setUp
    participant P as Plugin loader
    participant D as Discovery
    participant M as Plugin code

    S->>P: Project root, config, plugin mode
    rect rgba(21, 128, 61, 0.21)
    P->>D: Find every source
    D-->>P: Local rule files, declared packages, config entries
    end
    alt Plugin mode off
        P-->>S: Nothing imported, every source reported as skipped
    else strict, lenient or collect
        rect rgba(29, 78, 216, 0.21)
        P->>M: Import each source
        M-->>P: Its default export
        P->>P: Check the contract, then namespaces and category words
        end
        P-->>S: Rules named namespace/key, categories, failures
    end
Detailed: each source kind from discovery to rule id

Local rule files load together as one plugin, while each package or config entry loads and fails on its own.

sequenceDiagram
    participant S as setUp
    participant P as loadPlugins
    participant D as discoverSources
    participant N as node_modules
    participant L as loadLocal, loadPackage

    S->>P: Project root, config, plugin mode
    P->>D: Project root, config, whether plugins are off
    rect rgba(21, 128, 61, 0.21)
    D->>D: 1. Files in .mermaidjs-lint/rules, sorted, .d.ts left out
    D->>D: 2. dependencies, devDependencies, optionalDependencies of package.json
    D->>N: Each name matching mermaidjs-lint-plugin, at or above the project root
    N-->>D: Entry from exports, main or index.js, or plugin-not-installed
    D->>D: 3. Config plugins: a path resolves from the root, a name as in 2
    D-->>P: Sources deduplicated by path, and unresolved names
    end
    alt Plugin mode off
        P-->>S: No import, every source and unresolved name skipped
    else strict, lenient or collect
        rect rgba(29, 78, 216, 0.21)
        P->>L: Every local rule file, as one group
        L->>L: Import each file alone, its default export is one rule
        L-->>P: One plugin in namespace local, and each file's failure
        P->>L: Each package or config source
        L->>L: Import, then check apiVersion, meta, categories and each rule
        L-->>P: A plugin, or plugin-import, plugin-source or plugin-contract
        end
        rect rgba(109, 40, 217, 0.21)
        P->>P: First claim to a namespace or category word keeps it
        P->>P: A later claimant is plugin-conflict, naming both
        P->>P: Each accepted rule becomes namespace/key
        end
        P-->>S: Rules, categories, loaded sources, failures
    end

A plugin is not the only code a run can execute. A mermaidjs-lint.config.mjs is a module, so reading it runs it, and each plugins entry in the config names more code to load. --no-plugins loads no plugin and never imports a .mjs config, so a run uses the built-in rules and at most a .json config. Faced with a .mjs config, it stops with error[config-code], and --config with a .json config of your own lets it run. It does not make a repository you do not trust safe.

Installing its dependencies has already run their code. A package runner started inside it reads its bunfig.toml or .npmrc, which can run code before the linter starts. SECURITY.md states the full trust model, and how to lint such a repository from a folder you control.

Loading checks the contract before anything runs

Each source is imported, and its default export is checked against the plugin contract.

flowchart LR
    D["Discover<br/>sources"] --> L["Load and<br/>contract-check"]
    L --> C["Namespace and<br/>category check"]
    C --> R["Build registry<br/>with config"]
    L -. failure .-> M{"Plugin<br/>mode"}
    C -. conflict .-> M
    M -->|strict| X["Throw and<br/>stop the run"]
    M -->|"lenient or collect"| S["Leave source out,<br/>record failure"]
    S --> R
    D -->|"mode off:<br/>report as skipped"| R

    classDef discoverTint fill:#15803d36,stroke:#22c55e,stroke-width:2px
    classDef loadTint fill:#1d4ed836,stroke:#3b82f6,stroke-width:2px
    classDef registryTint fill:#b4530936,stroke:#d97706,stroke-width:2px
    classDef failTint fill:#dc262636,stroke:#ef4444,stroke-width:2px
    classDef modeTint fill:#52525b36,stroke:#71717a,stroke-width:1px
    class D discoverTint
    class L,C loadTint
    class R registryTint
    class X,S failTint
    class M modeTint
Detailed: discovery order, checks and the four modes

Discovery reads three places in a fixed order, and the mode decides what a failure does.

flowchart TB
    PM{"Pick mode:<br/>off, collect,<br/>lenient, strict"}
    subgraph Discover
        D1["1. Local rule files in<br/>.mermaidjs-lint/rules"]
        D2["2. Declared dependencies<br/>matching mermaidjs-lint-plugin"]
        D3["3. Paths or names in<br/>config plugins"]
        DD["Dedupe by path"]
    end
    subgraph Load
        LL["Local files load as<br/>one plugin, namespace local"]
        LP["Import package<br/>default export"]
        CC["Contract check: apiVersion,<br/>meta, rule keys, categories"]
    end
    subgraph Conflicts
        NS["Namespace already<br/>claimed?"]
        CW["Category word already<br/>declared?"]
    end
    subgraph Registry
        TR["toRule: id is<br/>namespace/key"]
        BR["buildRegistry: built-ins<br/>plus plugin rules"]
        AC["Apply config: off drops a rule,<br/>options type-checked"]
    end
    F{"Failure"}
    X["Throw the first failure"]
    S["Leave the source out,<br/>record the failure"]
    K["Report every spec<br/>as skipped"]

    D1 --> D2 --> D3 --> DD --> PM
    PM -->|off| K
    K --> BR
    PM -->|"strict, lenient, collect"| LL --> CC
    PM -->|"strict, lenient, collect"| LP --> CC
    CC --> NS --> CW --> TR --> BR --> AC
    PM -->|"strict, lenient, collect"| NI["A declared plugin<br/>that is not installed"]
    NI -. "not installed" .-> F
    CC -. "import or contract error" .-> F
    NS -. "yes" .-> F
    CW -. "yes" .-> F
    F -->|strict| X
    F -->|"lenient or collect"| S
    S --> BR

    classDef discoverTint fill:#15803d36,stroke:#22c55e,stroke-width:2px
    classDef loadTint fill:#1d4ed836,stroke:#3b82f6,stroke-width:2px
    classDef conflictTint fill:#6d28d936,stroke:#8b5cf6,stroke-width:2px
    classDef registryTint fill:#b4530936,stroke:#d97706,stroke-width:2px
    classDef failTint fill:#dc262636,stroke:#ef4444,stroke-width:2px
    classDef modeTint fill:#52525b36,stroke:#71717a,stroke-width:1px
    class D1,D2,D3,DD discoverTint
    class LL,LP,CC,NI loadTint
    class NS,CW conflictTint
    class TR,BR,AC registryTint
    class F,X,S failTint
    class PM,K modeTint

The contract is versioned by apiVersion, so a plugin built for another version fails at load with both numbers, not halfway through a run. Checking every field up front means a typo in a plugin stops the run with its reason, before any file is read. Plugin API lists every field the check covers.

Namespaces and categories are global

Two plugins cannot share a namespace, and two plugins cannot declare the same new category word. The local rule files count as one plugin here, so a package cannot declare a category word a local rule already declares. The first plugin to claim one keeps it. A later claimant fails to load, and the error names both. Without that rule, two plugins could produce identical rule ids, and a config line could switch off the wrong one.

The mode decides what a failure does

Mode Chosen by A failed plugin
strict The default Stops the run with error[code]
lenient --lenient-plugins, or "pluginLoading": "lenient" Is left out, and the run says so
collect --validate-plugins Is left out, and every failure is reported at the end. No file is checked.
off --no-plugins, or "plugins": false Nothing is loaded. Discovery still runs, and every plugin it found is reported as skipped.

Strict is the default because a gate that silently drops a rule would pass files it never checked.

A failed source stops a strict run, and the other modes leave it out and say so.

sequenceDiagram
    participant P as Plugin loader
    participant CLI as Command

    Note over P: Mode off imports nothing, so no plugin can fail
    P->>P: A source fails to resolve, import, pass the contract or claim
    rect rgba(220, 38, 38, 0.21)
    alt strict, the default
        P-->>CLI: Throw the failure, the run stops with exit 1
    else lenient
        P->>P: Leave the source out, record the failure
        P-->>CLI: Every other plugin, and the failure
        CLI->>CLI: Say which plugin was skipped, then check files
    else collect, from --validate-plugins
        P->>P: Leave the source out, keep loading the rest
        P-->>CLI: Every failure
        CLI->>CLI: Report every failure, check no file, exit 1
    end
    end
Detailed: how each mode is chosen and what the run prints

Off wins over collect and collect over lenient, and only a lenient run goes on to check files after a failure.

sequenceDiagram
    participant S as setUp
    participant P as loadPlugins
    participant G as buildRegistry
    participant CLI as Command

    S->>S: --no-plugins or plugins false is off, --validate-plugins is collect
    S->>S: --lenient-plugins or pluginLoading lenient is lenient, else strict
    S->>P: Project root, config, plugin mode
    alt off
        P-->>S: Nothing imported, every discovered spec skipped
        S-->>CLI: Registry of built-ins
        CLI->>CLI: Name any skipped spec on standard error, then check files
    else strict, lenient or collect
        P->>P: Fails: not installed, import, source, contract or conflict
        rect rgba(220, 38, 38, 0.21)
        alt strict
            P-->>CLI: Throw the first failure, nothing more loads
            CLI->>CLI: Print error[code], exit 1 before any file is read
        else lenient or collect
            P->>P: Leave the source out, record it once, carry on
            P-->>S: Plugins that loaded, and every failure
            S->>G: Rules, config, set aside unknown plugin ids
            G->>G: A setting for a built-in id that does not exist is error[unknown-rule]
            G-->>S: Registry, with the set-aside settings
        end
        end
        rect rgba(180, 83, 9, 0.21)
        alt lenient
            S-->>CLI: Registry and failures
            CLI->>CLI: One skipped plugin line per failure, one per set-aside setting
            CLI->>CLI: Check files, and a skipped plugin alone does not exit 1
        else collect
            S-->>CLI: Registry and failures
            CLI->>CLI: Report every failure, or that plugins are valid, as text or JSON
            CLI->>CLI: Check no file, exit 1 if any plugin failed, else 0
        end
        end
    end

Outside strict mode, a config setting for a plugin rule that did not load is set aside. A run that checks files or lists rules says so on standard error. A setting for a built-in id that does not exist still fails with error[unknown-rule].

A broken check is a finding, a broken fixer stops the run

A plugin check cannot take the run down. Suppose it throws, or returns something other than a list of { line, message } findings. That becomes one finding for that rule at line 1, and the other rules carry on. The gate still fails, so the broken rule is noticed.

A plugin fixer gets no special trust. It stops the run if it throws, returns something other than a list, or returns an edit with no valid expectation. An edit with offsets outside the source is refused like any other edit that fails verification, and the finding stays.

A plugin rule's check and fix pass through a wrapper, and its edits then meet the same verifier as a built-in's.

sequenceDiagram
    participant E as Check or fix engine
    participant W as Rule wrapper
    participant R as Plugin rule
    participant V as Verifier

    rect rgba(21, 128, 61, 0.21)
    E->>W: The context a built-in rule gets
    W->>R: check
    alt A list of line and message findings
        W-->>E: Findings under the rule id
    else Throws or returns anything else
        W-->>E: One finding at line 1, other rules carry on
    end
    end
    rect rgba(109, 40, 217, 0.21)
    E->>W: The same context, under --fix
    W->>R: fix
    alt A list of edits, each with an expectation
        W-->>E: Edits under the rule id
        E->>V: The same verification as a built-in edit
    else Throws, or a bad list or expectation
        W-->>E: error[plugin-fixer], the run stops
    end
    end
Detailed: every failure path of a plugin check and fix

The wrapper turns a broken check into a finding, and turns a broken fixer into an error that stops the run.

sequenceDiagram
    participant E as checkModel, fixSourceReport
    participant W as toRule wrapper
    participant R as Plugin rule
    participant V as verify

    rect rgba(21, 128, 61, 0.21)
    E->>W: Model, file, options, helpers
    W->>R: check, awaited if it returns a promise
    alt Throws or rejects
        W-->>E: One finding at line 1, plugin rule threw
    else Not a list of findings with a line of 1 or more and a message
        W-->>E: One finding at line 1, plugin rule returned something else
    else Valid findings
        W-->>E: Each finding with this file and this rule id
    end
    end

    rect rgba(109, 40, 217, 0.21)
    E->>W: Same context, in fix order after the built-ins
    W->>R: fix, awaited if it returns a promise
    alt Throws, or returns something other than a list
        W-->>E: error[plugin-fixer], the run stops with exit 1
    else An edit without same-diagrams or same-tree
        W-->>E: error[plugin-fixer], the run stops with exit 1
    else Every edit has an expectation
        W-->>E: Edits, each given this rule id
        E->>E: Offsets outside the source are refused as malformed
        E->>V: Splice, parse again, check each expectation
        alt Accept
            V-->>E: Kept, the pass restarts
        else Refuse
            V-->>E: Text untouched, the finding stays
        end
    end
    end

Next