Skip to content

How a run works

A diagram linter has two hard problems. Text patterns disagree with the renderer about what a diagram contains, and an automatic fix can quietly break a document. This page explains how a run avoids both.

One setup, then one model per file

A run sets up once, then reads each file into a document model that every rule shares.

flowchart LR
    R["Find project<br/>root"] --> C["Load<br/>config"] --> P["Load<br/>plugins"] --> G["Build<br/>registry"]
    G --> F["Collect<br/>files"] --> M["Build document<br/>model"]
    M --> Q{"--fix?"}
    Q -->|yes| X["Fix to fixpoint,<br/>write file"]
    X --> K
    Q -->|no| K["Check with<br/>all rules"]
    K --> O["Report findings,<br/>exit 0 or 1"]

    classDef setupTint fill:#1d4ed836,stroke:#3b82f6,stroke-width:2px
    classDef docTint fill:#15803d36,stroke:#22c55e,stroke-width:2px
    classDef fixTint fill:#6d28d936,stroke:#8b5cf6,stroke-width:2px
    classDef reportTint fill:#b4530936,stroke:#d97706,stroke-width:2px
    classDef gateTint fill:#52525b36,stroke:#71717a,stroke-width:1px
    class R,C,P,G setupTint
    class F,M,K docTint
    class X fixTint
    class O reportTint
    class Q gateTint
Detailed: setup, the fixpoint loop and the check

Every accepted edit restarts the pass on a fresh model, because offsets only hold for the source they came from.

flowchart TB
    subgraph Setup["Setup, once per run"]
        R["findProjectRoot: nearest package.json,<br/>config or .mermaidjs-lint"]
        C["Load config, or none"]
        P["loadPlugins in the run's mode"]
        G["buildRegistry with config applied"]
    end
    F["Collect files, each processed concurrently"]
    subgraph Fix["Fix, only with --fix"]
        B["Build model from current source"]
        FX["Next fixer in fixOrder<br/>proposes edits"]
        W["Drop malformed, no-op<br/>or already refused edits"]
        SP["Splice the batch, re-parse"]
        V{"Verify each edit's<br/>expectation"}
        RF["Refuse: retry one edit at<br/>a time, remember refusal"]
        DN["No fixer progressed:<br/>write the file"]
    end
    subgraph Check["Check"]
        CM["checkModel: every rule's<br/>check started at once"]
        SO["Sort by line, then rule id"]
    end
    O["Print findings or JSON"]
    E{"Any finding?"}

    R --> C --> P --> G --> F
    F -->|"--fix"| B
    F -->|"no --fix"| CM
    B --> FX --> W --> SP --> V
    V -->|"accept: restart pass"| B
    V -->|refuse| RF --> FX
    FX -->|"no edits left"| DN --> CM
    CM --> SO --> O --> E
    E -->|yes| X1["Exit 1"]
    E -->|no| X0["Exit 0"]

    classDef setupTint fill:#1d4ed836,stroke:#3b82f6,stroke-width:2px
    classDef docTint fill:#15803d36,stroke:#22c55e,stroke-width:2px
    classDef fixTint fill:#6d28d936,stroke:#8b5cf6,stroke-width:2px
    classDef refuseTint fill:#dc262636,stroke:#ef4444,stroke-width:2px
    classDef reportTint fill:#b4530936,stroke:#d97706,stroke-width:2px
    classDef gateTint fill:#52525b36,stroke:#71717a,stroke-width:1px
    class R,C,P,G setupTint
    class F,CM,SO docTint
    class B,FX,W,SP,DN fixTint
    class RF refuseTint
    class O,X1,X0 reportTint
    class V,E gateTint

Setup happens before any file is read. The run first finds the project root. That is the nearest ancestor of the working directory holding a package.json, a config file or a .mermaidjs-lint folder. It reads the config there, loads the plugins, and builds one registry of active rules. A bad config or a broken plugin therefore stops the run before it touches a document.

Setup turns the flags and the config into one registry, and a category flag has the last word over the config.

sequenceDiagram
    participant CLI as Command
    participant S as setUp
    participant P as Plugin loader
    participant G as buildRegistry

    rect rgba(29, 78, 216, 0.21)
    CLI->>S: Plugin flags, category flags, --config
    S->>S: Find the project root, read the config
    S->>P: Load plugins in the plugin mode
    P-->>S: Plugin rules and categories
    S->>G: Built-ins, plugin rules, config, category flags
    G->>G: Config turns rules off, then category flags decide
    G-->>S: Registry of active rules
    S-->>CLI: Project root and registry
    end
Detailed: config, plugin mode and registry

The config turns rules off and sets their options, then --category and --disable-category switch whole categories for this run.

sequenceDiagram
    participant CLI as Command
    participant S as setUp
    participant C as Config file
    participant P as loadPlugins
    participant G as buildRegistry

    CLI->>CLI: Parse flags, refuse a contradiction with error[flag]
    CLI->>S: cwd, --config, plugin flags, category flags
    S->>S: findProjectRoot: nearest package.json, config or .mermaidjs-lint

    rect rgba(29, 78, 216, 0.21)
    alt --config given
        S->>C: Resolve from cwd, or error[config-not-found]
    else no --config
        S->>C: Find at the project root, or error[config-ambiguous] for two
    end
    opt --no-plugins with a .mjs config
        S-->>CLI: error[config-code], nothing is imported
    end
    C-->>S: Plugins list, pluginLoading and rule settings, or no config
    S->>S: Plugin mode: off, then collect, then lenient, else strict
    S->>P: Project root, config, plugin mode
    P-->>S: Plugin rules, categories, sources, skipped, failures
    end

    rect rgba(180, 83, 9, 0.21)
    S->>G: Built-ins plus plugin rules, config, category flags
    G->>G: Unknown category, or one named by both flags, is error[flag]
    loop Each rule setting in the config
        alt Rule id not loaded
            G->>G: Plugin id outside strict mode is set aside, else error[unknown-rule]
        else Rule id loaded
            G->>G: Type-check options or error[rule-option], keep severity off
        end
    end
    G->>G: --category turns its rules on, --disable-category turns them off
    G->>G: No rule left to run is error[flag]
    G-->>S: Active rules, fix order, options, sources, set-aside settings
    end

    S-->>CLI: Project root, registry, skipped plugins, failures
    CLI->>CLI: Print the loaded or skipped plugins line on standard error

Structure comes from mermaid's own parsers

Each file is parsed to a markdown tree once. The mermaid fences in that tree become the diagrams every rule reads. A check and a fix therefore never disagree about which text is a diagram. A .mmd or .mermaid file is one diagram with no markdown around it.

A rule that needs structure asks the diagram for its stats. For the newer diagram types, the parse engine reads them from mermaid's Langium grammars. For the rest, it reads them from mermaid's own core, under a DOM shim. A regular expression would miscount subgraphs, ports and multi-line labels, and the counts would differ from what a reader sees. The parse happens once per diagram, on first use, and every rule shares the result.

A diagram whose parser reports an error is reported as parse-diagram-unparseable, on one line or many. The finding carries the parser's reason, and its line numbers count from the diagram's first line. Having no nodes is not an error on its own, so a valid pie with only a title line, or info, passes. A zenuml diagram is always reported, because it is drawn by a mermaid add-on this package does not include. A diagram that opens with the flowchart-elk keyword is a flowchart drawn with the ELK layout, so it is read and measured as a flowchart.

The diagram type comes from mermaid's own detection, which looks past frontmatter, directives and comments, and it picks which parser runs. A diagram whose type mermaid does not detect is reported as parse-diagram-unparseable. Whether a fence is closed, and the line that would close it, come from micromark's reading of the markdown.

Style directives are read from the parser's result: the classes, node styles and link styles mermaid's DB holds, with the classes each node carries. Each statement is placed by the lexer token that opens it, so a fixer can rewrite a value in place. A value the parser cannot place is never rewritten. Text inside a label is never read as a directive, and a style joined to other statements by a semicolon is read like any other.

The document model reference lists every field.

Checking runs every rule at once

No rule reads another rule's result, so a check starts every rule together and waits for all of them. A rule may return its findings directly or as a promise. The findings are then sorted by line and rule id, so output is stable from run to run.

Every file is read, modelled and checked at the same time, and any finding left makes the run exit 1.

sequenceDiagram
    participant CLI as Command
    participant F as File walk
    participant M as Document model
    participant R as Active rules

    CLI->>F: Paths from the command line
    F-->>CLI: Markdown and raw diagram files
    rect rgba(21, 128, 61, 0.21)
    loop Every file, all at once
        CLI->>M: Read the file, build the model
        M-->>CLI: Markdown tree and its diagrams
        CLI->>R: Start every rule's check together
        R-->>CLI: Findings, sorted by line, then rule id
    end
    end
    rect rgba(180, 83, 9, 0.21)
    CLI->>CLI: Print findings, exit 1 if any remain, else 0
    end
Detailed: file walk, shared stats and the report

A rule asks the parse engine for stats on first use, and every other rule reads the same result.

sequenceDiagram
    participant CLI as Command
    participant F as collectFiles
    participant M as buildDocModel
    participant R as Each active rule
    participant E as Parse engine

    CLI->>F: Every path, resolved from cwd
    F->>F: Walk folders for .md, .markdown, .mmd, .mermaid
    F-->>CLI: Files, unreadable paths, skipped symbolic links
    CLI->>CLI: Unreadable path or no files is error[flag], exit 2
    CLI->>CLI: Name each skipped link on standard error

    rect rgba(21, 128, 61, 0.21)
    loop Every file, all at once
        CLI->>CLI: Read the source, and fix it first under --fix
        CLI->>M: Source, and markdown or diagram by extension
        M-->>CLI: Markdown tree once, mermaid fences as diagrams
        CLI->>R: Model, file and options, all rules started together
        R->>E: Stats, on first use only
        E-->>R: Counts from mermaid's own parsers, shared by every rule
        R-->>CLI: Findings, directly or as a promise
        CLI->>CLI: Sort by line, then rule id
    end
    end

    rect rgba(180, 83, 9, 0.21)
    alt --json
        CLI->>CLI: One JSON object with findings, files and plugins
    else text
        CLI->>CLI: One line per finding, then the count
    end
    CLI->>CLI: Exit 1 if any finding remains, else 0
    end

Fixing is a loop with a check

--fix does not trust a fixer. A fixer proposes edits: a span of the source, its replacement, and an expectation of what must stay the same.

Expectation What must hold after the edit
same-diagrams The markdown is unchanged outside the mermaid fences, once whitespace in text is collapsed, and the document holds the same number of diagrams. Each keeps its keyword, whether it parses, its type, counts, rows, deepest nesting and tree depth, node ids and subgraph names.
same-tree The markdown tree is identical, except for whitespace in prose text. Mermaid fence bodies must match exactly, and no diagram that parsed may stop parsing. A raw diagram file has an empty tree, so there the diagram must also pass the same-diagrams check.

The engine splices the edits, parses the whole document again, and keeps the change only when every expectation holds. Neither expectation lets an edit turn a diagram that parses into one that does not. same-diagrams is a structural check, and it does not show that nothing else changed. It does not compare edges, labels, node text or styles, so a fixer that declares it must keep those intact itself. Edits lists exactly what each expectation compares.

A rule's edits are first tried as one batch. When the batch fails, each edit is tried alone, so one bad edit never blocks the others. An edit that fails is refused, remembered, and never proposed again for the same text.

After any accepted change the engine starts a new pass on the new source, because every offset a fixer computed belongs to the old one. The loop ends when no fixer makes progress. The file is then written, and checked like any other. A fixer that never stops proposing edits stops the run after 1000 passes with error[fix-not-converging], and that file is not written.

Each pass keeps an edit only when the verifier accepts it, and a refused edit leaves the text alone.

sequenceDiagram
    participant E as Fix engine
    participant X as Fixer
    participant V as Verifier

    rect rgba(109, 40, 217, 0.21)
    loop One pass, up to 1000
        E->>E: Build the model from the current source
        E->>X: Propose edits, in fix order
        X-->>E: Edits, each with an expectation
        E->>E: Splice the edits, parse the document again
        E->>V: Model before and model after
        alt Every expectation holds
            V-->>E: Accept, restart the pass on the new source
        else An expectation fails
            V-->>E: Refuse, the text stays and so does the finding
        end
    end
    end
    E->>E: No fixer made progress, write the file
Detailed: batches, both expectations and what is not compared

The verifier compares only what each expectation lists, so edge endpoints, edge labels and node text are the fixer's own responsibility.

sequenceDiagram
    participant CLI as Command
    participant E as fixSourceReport
    participant X as Next fixer in fix order
    participant V as verify

    CLI->>E: Source, file, flags, registry
    rect rgba(109, 40, 217, 0.21)
    loop One pass, up to 1000
        E->>E: buildDocModel from the current source
        E->>X: Model, file and the rule's options
        X-->>E: Proposed edits: span, replacement, expectation
        E->>E: Refuse malformed offsets once, drop no-ops and known refusals
        alt Several edits that do not overlap
            E->>E: Splice the whole batch, build the model again
        else One edit, or the batch failed
            E->>E: Splice one edit at a time, build the model again
        end
        E->>V: Each edit, model before, model after
        alt same-tree
            V->>V: Tree identical but for prose whitespace, fence bodies exact
            V->>V: No diagram that parsed stops parsing
        else same-diagrams, or same-tree on a raw diagram file
            V->>V: Tree outside fences unchanged but for prose whitespace
            V->>V: Same number of diagrams, each with the same keyword
            V->>V: Same parse result, type, counts and depths
            V->>V: Same node ids and subgraph names
        end
        Note over V: Not compared: edge endpoints, edge labels, node text, styles
        alt Accept
            V-->>E: Keep the new source, restart the pass
        else Refuse
            V-->>E: Remember the refusal for this text, try the next edit
        end
    end
    end
    rect rgba(220, 38, 38, 0.21)
    opt Still editing after 1000 passes
        E-->>CLI: error[fix-not-converging], the file is not written
    end
    end
    E-->>CLI: No fixer progressed: fixed source, applied and refused edits
    CLI->>CLI: Write the file if it changed, then check it

A refused edit leaves the finding in place

When a fixer is unsure, or its edit fails the check, the text is left alone and the finding is still reported. That is deliberate: a fix that might change what a diagram says needs a human.

What a run does not do

  • It never renders a diagram, so layout problems a renderer would show are out of reach.
  • It never splits or restructures a diagram, because that changes what the diagram says.
  • It never picks a brand colour. The only colour fix swaps a declared label colour for black or white.