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:
- Local rule files in
.mermaidjs-lint/rulesat the project root. Each file is one rule, and all of them load together as one plugin under the reserved namespacelocal. - Declared dependencies whose name matches
mermaidjs-lint-pluginormermaidjs-lint-plugin-*, scoped or not. Only the project's owndependencies,devDependenciesandoptionalDependenciesare read. - Paths or names listed under
pluginsin 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¶
- Write a local rule to try this in five minutes.
- Publish a plugin package to share rules between projects.
- Plugin API for every field of the contract.