@jpeakai/mermaidjs-lint 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +82 -53
- package/dist/bin.js +202 -44
- package/dist/index.js +206 -45
- package/dist/testing.js +86 -23
- package/examples/architecture_beta_iconify_logos.mmd +27 -0
- package/examples/flowchart_fontawesome_icons.mmd +31 -0
- package/examples/layout_cosebilkent_mindmap.mmd +19 -0
- package/examples/layout_dagre_classic.mmd +22 -0
- package/examples/layout_dagre_handdrawn.mmd +23 -0
- package/examples/layout_dagre_neo.mmd +26 -0
- package/examples/layout_elk_default.mmd +22 -0
- package/examples/layout_elk_tuned.mmd +25 -0
- package/examples/layout_tidytree_mindmap.mmd +19 -0
- package/package.json +2 -2
- package/src/check.ts +2 -3
- package/src/cli.ts +45 -23
- package/src/config.ts +3 -2
- package/src/errors.ts +18 -3
- package/src/files.ts +3 -1
- package/src/fix/engine.ts +4 -4
- package/src/fix/verify.ts +2 -2
- package/src/index.ts +1 -1
- package/src/model.ts +10 -0
- package/src/parse/color.ts +2 -2
- package/src/parse/contrast.ts +59 -5
- package/src/parse/metrics.ts +16 -8
- package/src/parse/stats.ts +45 -19
- package/src/plugins/contract.ts +1 -1
- package/src/plugins/discover.ts +5 -5
- package/src/plugins/load.ts +5 -3
- package/src/project.ts +11 -4
- package/src/rules/complexity-node-count-hard-limit.ts +1 -1
- package/src/rules/complexity-visual-score-critical.ts +2 -2
- package/src/rules/contrast-text-below-aa.ts +6 -2
- package/src/rules/index.ts +2 -1
- package/src/rules/parse-diagram-unparseable.ts +29 -14
- package/src/rules/registry.ts +7 -4
- package/src/rules/shared.ts +1 -1
- package/src/rules/types.ts +2 -2
- package/src/testing.ts +4 -3
package/src/errors.ts
CHANGED
|
@@ -51,6 +51,20 @@ export class ConfigNotFoundError extends UsageError {
|
|
|
51
51
|
}
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
// --no-plugins runs no project code, and a .mjs config is code, so the run
|
|
55
|
+
// stops before importing it. A .json config is data and is read as usual.
|
|
56
|
+
export class ConfigCodeError extends UsageError {
|
|
57
|
+
readonly file: string;
|
|
58
|
+
|
|
59
|
+
constructor(file: string) {
|
|
60
|
+
super(
|
|
61
|
+
`${file}: a .mjs config is code, and --no-plugins runs no project code; pass --config with a .json config, or drop --no-plugins`,
|
|
62
|
+
"config-code",
|
|
63
|
+
);
|
|
64
|
+
this.file = file;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
54
68
|
// Two config files at one root, so neither can be picked.
|
|
55
69
|
export class AmbiguousConfigError extends UsageError {
|
|
56
70
|
readonly files: string[];
|
|
@@ -178,9 +192,10 @@ export class PluginSourceError extends PluginLoadError {
|
|
|
178
192
|
|
|
179
193
|
// --- plugins: a loaded plugin misbehaved while running --------------------
|
|
180
194
|
|
|
181
|
-
// A fixer threw
|
|
182
|
-
//
|
|
183
|
-
//
|
|
195
|
+
// A fixer threw, returned a non-list, or returned an edit with no valid expect.
|
|
196
|
+
// An edit with bad offsets is not this error: the engine refuses it and the
|
|
197
|
+
// finding stays. Unlike a check, this stops the run, because carrying on after
|
|
198
|
+
// a corrupt edit is worse than stopping.
|
|
184
199
|
export class PluginFixerError extends MermaidLintError {
|
|
185
200
|
readonly rule: string;
|
|
186
201
|
|
package/src/files.ts
CHANGED
|
@@ -5,8 +5,10 @@
|
|
|
5
5
|
|
|
6
6
|
import { type Dirent, readdirSync, statSync } from "node:fs";
|
|
7
7
|
import { extname, join, resolve } from "node:path";
|
|
8
|
+
import { DIAGRAM_EXTS } from "./model.ts";
|
|
8
9
|
|
|
9
|
-
|
|
10
|
+
// What a directory walk picks up: raw diagram files and markdown files.
|
|
11
|
+
export const DIAGRAM_FILE_EXTS = new Set<string>([...DIAGRAM_EXTS, ".md", ".markdown"]);
|
|
10
12
|
|
|
11
13
|
// Directories never worth walking. Vendored trees dwarf real content, and
|
|
12
14
|
// dot-directories hold VCS and tool state, not diagrams. A denylist keeps the
|
package/src/fix/engine.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
// The fix engine: a fixpoint loop over splice edits. Each pass rebuilds the
|
|
2
|
-
// model, asks the fixers for edits in priority order, and keeps the first
|
|
3
|
-
// that survives verification. Offsets are only valid for the source they were
|
|
2
|
+
// model, asks the fixers for edits in priority order, and keeps the first batch
|
|
3
|
+
// or single edit that survives verification. Offsets are only valid for the source they were
|
|
4
4
|
// computed from, so every accepted edit restarts the pass.
|
|
5
5
|
|
|
6
6
|
import { optionsFor, type RunFlags } from "../check.ts";
|
|
7
|
-
import { buildDocModel } from "../model.ts";
|
|
7
|
+
import { buildDocModel, documentKind } from "../model.ts";
|
|
8
8
|
import { BUILTIN, HELPERS, type Registry } from "../rules/registry.ts";
|
|
9
9
|
import type { Edit } from "../rules/types.ts";
|
|
10
10
|
import { verify } from "./verify.ts";
|
|
@@ -44,7 +44,7 @@ export const fixSourceReport = async (
|
|
|
44
44
|
flags: RunFlags = {},
|
|
45
45
|
registry: Registry = BUILTIN,
|
|
46
46
|
): Promise<FixReport> => {
|
|
47
|
-
const kind = file
|
|
47
|
+
const kind = documentKind(file);
|
|
48
48
|
const applied: Edit[] = [];
|
|
49
49
|
const refused: Edit[] = [];
|
|
50
50
|
// An edit refused once stays refused while its source slice is unchanged.
|
package/src/fix/verify.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
// Proof obligations for a fix. The engine applies
|
|
2
|
-
//
|
|
1
|
+
// Proof obligations for a fix. The engine applies edits, re-parses, and checks
|
|
2
|
+
// that the document still satisfies each edit's declared expectation. An edit that
|
|
3
3
|
// cannot be proven is refused, the source is left alone, and the finding stays.
|
|
4
4
|
|
|
5
5
|
import type { Node, Root } from "mdast";
|
package/src/index.ts
CHANGED
|
@@ -6,7 +6,7 @@ export { type Config, loadConfig, type PluginLoading, parseConfig, type RuleSett
|
|
|
6
6
|
export * from "./errors.ts";
|
|
7
7
|
export { collectFiles } from "./files.ts";
|
|
8
8
|
export { type FixReport, fixSource, fixSourceReport } from "./fix/engine.ts";
|
|
9
|
-
export { buildDocModel, type Diagram, type DocModel } from "./model.ts";
|
|
9
|
+
export { buildDocModel, DIAGRAM_EXTS, type Diagram, type DocModel, documentKind } from "./model.ts";
|
|
10
10
|
export type { ContrastAssessment, StyleDirective, ValueSpan } from "./parse/color.ts";
|
|
11
11
|
export { PRESETS, thresholdsFrom } from "./parse/metrics.ts";
|
|
12
12
|
export { type DiagramStats, detectKeyword, extractStats } from "./parse/stats.ts";
|
package/src/model.ts
CHANGED
|
@@ -107,6 +107,16 @@ const collectMermaid = (node: Node, out: Code[]): void => {
|
|
|
107
107
|
for (const child of (node as Parent).children ?? []) collectMermaid(child, out);
|
|
108
108
|
};
|
|
109
109
|
|
|
110
|
+
// The extensions of a raw diagram file. Any other file is read as markdown.
|
|
111
|
+
export const DIAGRAM_EXTS = [".mmd", ".mermaid"] as const;
|
|
112
|
+
|
|
113
|
+
// How a file is read, from its name alone: a raw diagram, or markdown whose
|
|
114
|
+
// mermaid fences are the diagrams.
|
|
115
|
+
export const documentKind = (file: string): "markdown" | "diagram" => {
|
|
116
|
+
const lower = file.toLowerCase();
|
|
117
|
+
return DIAGRAM_EXTS.some((ext) => lower.endsWith(ext)) ? "diagram" : "markdown";
|
|
118
|
+
};
|
|
119
|
+
|
|
110
120
|
export const buildDocModel = (src: string, kind: "markdown" | "diagram" = "markdown"): DocModel => {
|
|
111
121
|
const lineStarts = lineStartsOf(src);
|
|
112
122
|
if (kind === "diagram") {
|
package/src/parse/color.ts
CHANGED
|
@@ -88,8 +88,8 @@ const STYLE_RE = /^[ \t]*style\s+(\S+)\s+(.+?)\s*;?\s*$/di;
|
|
|
88
88
|
const LINKSTYLE_RE = /^[ \t]*linkStyle\s+(\S+)\s+(.+?)\s*;?\s*$/di;
|
|
89
89
|
const INLINE_CLASS_RE = /([A-Za-z_][\w-]*)(?:\[[^\]]*\]|\([^)]*\)|\{[^}]*\}|>[^<]*)?:::([A-Za-z_][\w-]*)/g;
|
|
90
90
|
|
|
91
|
-
// Mermaid allows `,` and `;` between properties
|
|
92
|
-
//
|
|
91
|
+
// Mermaid allows `,` and `;` between properties. A plain split assumes no
|
|
92
|
+
// value holds an escaped `\,`, which holds for well-formed source.
|
|
93
93
|
const parseProperties = (
|
|
94
94
|
raw: string,
|
|
95
95
|
rawStart: number,
|
package/src/parse/contrast.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
// Contrast scoring of a diagram's style directives, by the context the diagram
|
|
2
2
|
// is read in. Two kinds of pair are scored: text (fill x color, at least 4.5)
|
|
3
|
-
// and border (fill x stroke, at least 3, the UI-component rule).
|
|
4
|
-
//
|
|
5
|
-
// back to a theme default that cannot be resolved without
|
|
3
|
+
// and border (fill x stroke, at least 3, the UI-component rule). Under github a
|
|
4
|
+
// flowchart pair is scored only when both sides are declared, because an unset
|
|
5
|
+
// property falls back to a theme default that cannot be resolved without
|
|
6
|
+
// rendering. mkdocs-material scores the host text colour instead, and
|
|
7
|
+
// erDiagram substitutes known theme text when no color is set. A linkStyle
|
|
8
|
+
// color: is edge label text, scored on Mermaid's own label box under github.
|
|
6
9
|
//
|
|
7
10
|
// Profiles, by who controls the label text:
|
|
8
11
|
// github the author does, so the declared pairs are scored as written
|
|
@@ -34,6 +37,30 @@ const GITHUB = { light: { bg: "#ffffff" }, dark: { bg: "#0d1117" } } as const;
|
|
|
34
37
|
|
|
35
38
|
const THEMES: Theme[] = ["light", "dark"];
|
|
36
39
|
|
|
40
|
+
// The box an edge label is drawn on, read off Mermaid 11's theme sources. A
|
|
41
|
+
// linkStyle color: becomes an inline `color: ... !important` on the label's
|
|
42
|
+
// div and span, and the text sits in a <p> inside them. Three layers paint the
|
|
43
|
+
// box behind it: the div (.labelBkg, edgeLabelBackground faded to alpha 0.5),
|
|
44
|
+
// the span and the p (both .edgeLabel, edgeLabelBackground as is). The theme's
|
|
45
|
+
// edgeLabelBackground is rgba(232,232,232,0.8) in default (labelBackground)
|
|
46
|
+
// and lighten(#181818, 25) = #585858 in dark, opaque. GitHub draws the default
|
|
47
|
+
// theme in light mode and the dark theme in dark mode, the same assumption the
|
|
48
|
+
// erDiagram rows make, so the stack is composited over the matching canvas:
|
|
49
|
+
// #e8e8e8 in light and #585858 in dark. No one colour clears 4.5:1 on both, so
|
|
50
|
+
// a fixed edge label colour always fails one theme. Under mkdocs-material the
|
|
51
|
+
// host sets `.edgeLabel p { color: var(--md-mermaid-edge-color) }` on the p
|
|
52
|
+
// itself, which beats the colour the p would inherit, so color: is ignored
|
|
53
|
+
// there and nothing new is scored.
|
|
54
|
+
const MERMAID_EDGE_LABEL = {
|
|
55
|
+
light: { box: "rgba(232,232,232,0.8)", backing: "rgba(232,232,232,0.5)" },
|
|
56
|
+
dark: { box: "#585858", backing: "rgba(88,88,88,0.5)" },
|
|
57
|
+
} as const;
|
|
58
|
+
|
|
59
|
+
export const edgeLabelBackground = (theme: Theme): string => {
|
|
60
|
+
const { box, backing } = MERMAID_EDGE_LABEL[theme];
|
|
61
|
+
return compositeOver(box, compositeOver(box, compositeOver(backing, GITHUB[theme].bg)));
|
|
62
|
+
};
|
|
63
|
+
|
|
37
64
|
// Mermaid's own erDiagram row surfaces under its default and dark themes. A
|
|
38
65
|
// fill lands on the even attribute rows only, and a declared color lands on all
|
|
39
66
|
// rows, so it has to read on the fill and on a theme surface that flips with
|
|
@@ -57,7 +84,8 @@ export interface ContrastPair {
|
|
|
57
84
|
passes: boolean;
|
|
58
85
|
theme?: Theme;
|
|
59
86
|
row?: ErRow;
|
|
60
|
-
//
|
|
87
|
+
// Set on border pairs under mkdocs-material and erDiagram. The pair is still
|
|
88
|
+
// scored but never gates: a border on a faint fill
|
|
61
89
|
// cannot clear 3:1 in both themes for a saturated hue. There is no warning
|
|
62
90
|
// level, so an advisory pair is not a finding.
|
|
63
91
|
advisory?: boolean;
|
|
@@ -93,10 +121,28 @@ const scorePair = (kind: PairKind, d: StyleDirective, fg: string, bg: string): C
|
|
|
93
121
|
|
|
94
122
|
const message = (err: unknown): string => (err instanceof Error ? err.message : String(err));
|
|
95
123
|
|
|
124
|
+
// A linkStyle color: is edge label text, scored on the label box in both themes.
|
|
125
|
+
// A linkStyle without one styles only the line, which has no label pair.
|
|
126
|
+
const scoreEdgeLabel = (d: StyleDirective, pairs: ContrastPair[], skipped: SkippedDirective[]): void => {
|
|
127
|
+
const { color } = d.properties;
|
|
128
|
+
if (!color) return;
|
|
129
|
+
try {
|
|
130
|
+
pairs.push(...THEMES.map((t) => scoreHostPair("text", d, t, color, edgeLabelBackground(t))));
|
|
131
|
+
} catch (err) {
|
|
132
|
+
skipped.push({
|
|
133
|
+
selector: d.selector,
|
|
134
|
+
line: d.line,
|
|
135
|
+
reason: `edge label text unparseable: ${message(err)}`,
|
|
136
|
+
blocking: true,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
|
|
96
141
|
export const scoreGithub = (directives: StyleDirective[]): ContrastResult => {
|
|
97
142
|
const pairs: ContrastPair[] = [];
|
|
98
143
|
const skipped: SkippedDirective[] = [];
|
|
99
144
|
for (const d of directives) {
|
|
145
|
+
if (d.kind === "linkStyle") scoreEdgeLabel(d, pairs, skipped);
|
|
100
146
|
if (d.kind !== "classDef" && d.kind !== "style") continue;
|
|
101
147
|
const { fill, color, stroke } = d.properties;
|
|
102
148
|
const note = (reason: string, blocking?: boolean): void => {
|
|
@@ -172,6 +218,13 @@ export const scoreMkdocs = (directives: StyleDirective[]): ContrastResult => {
|
|
|
172
218
|
const pairs: ContrastPair[] = [];
|
|
173
219
|
const skipped: SkippedDirective[] = [];
|
|
174
220
|
for (const d of directives) {
|
|
221
|
+
if (d.kind === "linkStyle" && d.properties.color) {
|
|
222
|
+
skipped.push({
|
|
223
|
+
selector: d.selector,
|
|
224
|
+
line: d.line,
|
|
225
|
+
reason: "color: ignored, because the host theme forces edge label text",
|
|
226
|
+
});
|
|
227
|
+
}
|
|
175
228
|
if (d.kind !== "classDef" && d.kind !== "style") continue;
|
|
176
229
|
const { fill, stroke, color } = d.properties;
|
|
177
230
|
const note = (reason: string): void => {
|
|
@@ -267,7 +320,8 @@ export const scoreForProfile = (
|
|
|
267
320
|
return keyword === "erDiagram" ? scoreEr(directives) : scoreGithub(directives);
|
|
268
321
|
};
|
|
269
322
|
|
|
270
|
-
// An ancestor mkdocs.yml
|
|
323
|
+
// An ancestor mkdocs.yml or mkdocs.yaml, searched up to 20 levels, means the
|
|
324
|
+
// diagram is read through Material.
|
|
271
325
|
export const detectProfile = (filePath: string): Profile => {
|
|
272
326
|
let dir = dirname(resolve(filePath));
|
|
273
327
|
for (let i = 0; i < 20; i++) {
|
package/src/parse/metrics.ts
CHANGED
|
@@ -167,14 +167,22 @@ export const calculateComplexity = (stats: DiagramStats, config: ThresholdConfig
|
|
|
167
167
|
};
|
|
168
168
|
};
|
|
169
169
|
|
|
170
|
-
//
|
|
171
|
-
//
|
|
172
|
-
//
|
|
170
|
+
// Whether a diagram failed to parse, so its metrics cannot be trusted. A
|
|
171
|
+
// parser's own error always counts: that is what catches `graph TD; A-->; B[[[`.
|
|
172
|
+
// A Langium grammar that read the diagram without an error parsed it, even with
|
|
173
|
+
// no nodes, as `pie` then `title Pets`, or `info` then `showInfo`, legitimately
|
|
174
|
+
// have none. Mermaid core can read a multi-line diagram into an empty
|
|
175
|
+
// structure without complaint, so there a multi-line diagram with no nodes is
|
|
176
|
+
// taken as a silent failure. A one-line diagram may have none, as `info` does.
|
|
173
177
|
export const parseFailed = (content: string, stats: DiagramStats): boolean => {
|
|
174
|
-
|
|
178
|
+
if (stats.error !== null) return true;
|
|
179
|
+
if (stats.parser_used === "langium" || stats.nodes > 0) return false;
|
|
180
|
+
return sourceLines(content) > 1;
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
// The lines that carry diagram source: not blank, a comment or a frontmatter fence.
|
|
184
|
+
export const sourceLines = (content: string): number =>
|
|
185
|
+
content.split("\n").filter((l) => {
|
|
175
186
|
const t = l.trim();
|
|
176
187
|
return t && !t.startsWith("%%") && t !== "---";
|
|
177
|
-
});
|
|
178
|
-
if (lines.length <= 1) return false;
|
|
179
|
-
return stats.nodes === 0;
|
|
180
|
-
};
|
|
188
|
+
}).length;
|
package/src/parse/stats.ts
CHANGED
|
@@ -39,9 +39,12 @@ export interface DiagramStats {
|
|
|
39
39
|
max_subgraph_depth: number;
|
|
40
40
|
node_ids: string[];
|
|
41
41
|
subgraph_names: string[];
|
|
42
|
-
|
|
42
|
+
// The parser that read the diagram, or, on a failure, the one whose reason
|
|
43
|
+
// `error` carries: langium for the types its grammars own, else mermaid-core.
|
|
44
|
+
parser_used: "langium" | "mermaid-core";
|
|
43
45
|
diagram_type: string;
|
|
44
|
-
// The reason
|
|
46
|
+
// The parser's reason, on one line, when no parser produced structure. Line
|
|
47
|
+
// numbers in it count from the diagram's own first line.
|
|
45
48
|
error: string | null;
|
|
46
49
|
}
|
|
47
50
|
|
|
@@ -72,7 +75,8 @@ export function detectKeyword(content: string): string | null {
|
|
|
72
75
|
// A leading "---" opens a YAML frontmatter block that runs to the next "---".
|
|
73
76
|
// Everything between is config, not diagram source: returning its first key
|
|
74
77
|
// (typically "config:") would miss the Langium dispatch table and mis-report a
|
|
75
|
-
// valid frontmatter-prefixed diagram (e.g. architecture-beta) as
|
|
78
|
+
// valid frontmatter-prefixed diagram (e.g. architecture-beta) as unparseable
|
|
79
|
+
// (parseFailed in metrics.ts, reported as parse-diagram-unparseable).
|
|
76
80
|
let seenContent = false;
|
|
77
81
|
let inFrontmatter = false;
|
|
78
82
|
|
|
@@ -103,11 +107,15 @@ export async function extractStats(content: string): Promise<MermaidStats> {
|
|
|
103
107
|
const keyword = detectKeyword(content) ?? "unknown";
|
|
104
108
|
|
|
105
109
|
// 1. Langium grammar (@mermaid-js/parser) — typed AST, no DOM needed.
|
|
106
|
-
|
|
110
|
+
// Its error is kept, because for these types it is the parser that owns the
|
|
111
|
+
// grammar, and mermaid core's attempt below is only a second chance.
|
|
112
|
+
const langium = keyword in LANGIUM_TYPES;
|
|
113
|
+
let langiumError: string | null = null;
|
|
114
|
+
if (langium) {
|
|
107
115
|
try {
|
|
108
116
|
return await extractLangiumStats(content, keyword);
|
|
109
|
-
} catch {
|
|
110
|
-
|
|
117
|
+
} catch (err) {
|
|
118
|
+
langiumError = parserReason(err);
|
|
111
119
|
}
|
|
112
120
|
}
|
|
113
121
|
|
|
@@ -115,15 +123,15 @@ export async function extractStats(content: string): Promise<MermaidStats> {
|
|
|
115
123
|
// DOMPurify; the parsers populate each type's DB (vertices, edges,
|
|
116
124
|
// blocks, sections, etc.). A per-type adapter in extractCoreStats reads
|
|
117
125
|
// the canonical DB shape.
|
|
118
|
-
let
|
|
126
|
+
let coreError: string;
|
|
119
127
|
try {
|
|
120
128
|
return await extractCoreStats(content, keyword);
|
|
121
129
|
} catch (err) {
|
|
122
|
-
|
|
130
|
+
coreError = parserReason(err);
|
|
123
131
|
}
|
|
124
132
|
|
|
125
|
-
// 3. Neither canonical parser produced stats. Return a tagged failure
|
|
126
|
-
//
|
|
133
|
+
// 3. Neither canonical parser produced stats. Return a tagged failure so
|
|
134
|
+
// parseFailed (metrics.ts) flags it, reported as parse-diagram-unparseable.
|
|
127
135
|
return {
|
|
128
136
|
nodes: 0,
|
|
129
137
|
edges: 0,
|
|
@@ -131,12 +139,31 @@ export async function extractStats(content: string): Promise<MermaidStats> {
|
|
|
131
139
|
max_subgraph_depth: 0,
|
|
132
140
|
node_ids: [],
|
|
133
141
|
subgraph_names: [],
|
|
134
|
-
parser_used: "mermaid-core",
|
|
142
|
+
parser_used: langium ? "langium" : "mermaid-core",
|
|
135
143
|
diagram_type: keyword,
|
|
136
|
-
error,
|
|
144
|
+
error: langiumError ?? coreError,
|
|
137
145
|
};
|
|
138
146
|
}
|
|
139
147
|
|
|
148
|
+
// A pointer line under an echoed source line, as the JISON parsers print it.
|
|
149
|
+
const POINTER = /^-*\^$/;
|
|
150
|
+
|
|
151
|
+
// A parser's whole reason on one line, so a finding stays one line. The JISON
|
|
152
|
+
// parsers echo the source line and point at the column; both are dropped, and
|
|
153
|
+
// the line number and what the parser expected are kept. "No diagram type
|
|
154
|
+
// detected" echoes the whole diagram after its first line, so only that line
|
|
155
|
+
// is kept.
|
|
156
|
+
export const parserReason = (err: unknown): string => {
|
|
157
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
158
|
+
const lines = message.split("\n");
|
|
159
|
+
if (message.startsWith("No diagram type detected")) return (lines[0] ?? "").trim();
|
|
160
|
+
return lines
|
|
161
|
+
.filter((line, i) => !POINTER.test(line.trim()) && !POINTER.test((lines[i + 1] ?? "").trim()))
|
|
162
|
+
.join(" ")
|
|
163
|
+
.replace(/\s+/g, " ")
|
|
164
|
+
.trim();
|
|
165
|
+
};
|
|
166
|
+
|
|
140
167
|
// ── Langium extraction (@mermaid-js/parser) ─────────────────────────────────
|
|
141
168
|
|
|
142
169
|
async function langiumParseAny(type: string, content: string): Promise<unknown> {
|
|
@@ -233,7 +260,6 @@ async function extractLangiumStats(content: string, keyword: string): Promise<Me
|
|
|
233
260
|
}
|
|
234
261
|
case "wardley": {
|
|
235
262
|
// AST shape: ast.anchors[], ast.components[], ast.links[] — NOT ast.edges.
|
|
236
|
-
// The old code looked for `ast.edges` and returned 0.
|
|
237
263
|
const components = (ast.components as Array<{ name?: string }> | undefined) ?? [];
|
|
238
264
|
const anchors = (ast.anchors as Array<{ name?: string }> | undefined) ?? [];
|
|
239
265
|
const links = (ast.links as Array<unknown> | undefined) ?? [];
|
|
@@ -278,9 +304,8 @@ function architectureDepth(groups: Array<{ id?: string; in?: string }>): number
|
|
|
278
304
|
}
|
|
279
305
|
|
|
280
306
|
// Compute tree structure from a flat, indent-ordered node list.
|
|
281
|
-
// Used for treemap
|
|
282
|
-
//
|
|
283
|
-
// edges. `labels` preserves the node names for VCS bookkeeping; `edges` is
|
|
307
|
+
// Used for treemap and treeView, where parent-child is encoded via
|
|
308
|
+
// indentation rather than explicit edges. `labels` preserves the node names for VCS bookkeeping; `edges` is
|
|
284
309
|
// the number of parent-child links (nodes - number_of_roots); `depth` is
|
|
285
310
|
// the maximum number of indent-steps past the root level.
|
|
286
311
|
export function indentTreeStats(items: Array<{ label: string; indent: number }>): {
|
|
@@ -302,9 +327,10 @@ export function indentTreeStats(items: Array<{ label: string; indent: number }>)
|
|
|
302
327
|
// ── mermaid-core extraction via .db after parse ─────────────────────────────
|
|
303
328
|
//
|
|
304
329
|
// Every diagram type's canonical DB has a different shape. Some expose data
|
|
305
|
-
// via getter methods (
|
|
306
|
-
//
|
|
307
|
-
//
|
|
330
|
+
// via getter methods (kanban's getData, block's getBlocks, sankey's
|
|
331
|
+
// getNodes/getLinks). Others expose it via direct properties (flowchart's
|
|
332
|
+
// vertices/edges/subGraphs, er.entities, state.nodes,
|
|
333
|
+
// requirement.requirements). We dispatch on diagram.type and
|
|
308
334
|
// each adapter reads ONLY that type's canonical surface — no text parsing,
|
|
309
335
|
// no regex. The parser did the work; we just read the result.
|
|
310
336
|
|
package/src/plugins/contract.ts
CHANGED
|
@@ -26,7 +26,7 @@ export interface PluginMeta {
|
|
|
26
26
|
name: string;
|
|
27
27
|
namespace: string;
|
|
28
28
|
apiVersion: number;
|
|
29
|
-
//
|
|
29
|
+
// New categories the plugin adds, each word mapped to a one-line description.
|
|
30
30
|
categories?: Record<string, string>;
|
|
31
31
|
}
|
|
32
32
|
|
package/src/plugins/discover.ts
CHANGED
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
// names a config lists. A directory of node_modules is never scanned, so only
|
|
5
5
|
// what the project chose to depend on can run.
|
|
6
6
|
//
|
|
7
|
-
// A
|
|
8
|
-
// treat, not an exception, so a lenient run can skip it and a
|
|
9
|
-
// can report every one.
|
|
7
|
+
// A declared package that is not installed is a failure the caller decides
|
|
8
|
+
// how to treat, not an exception, so a lenient run can skip it and a
|
|
9
|
+
// validating run can report every one. A malformed package.json throws.
|
|
10
10
|
|
|
11
11
|
import { readdir, readFile } from "node:fs/promises";
|
|
12
12
|
import { dirname, extname, isAbsolute, join, resolve } from "node:path";
|
|
@@ -69,8 +69,8 @@ const localSources = async (root: string): Promise<PluginSource[]> => {
|
|
|
69
69
|
.map((name) => ({ kind: "local", spec: join(LOCAL_RULES, name), path: join(dir, name) }));
|
|
70
70
|
};
|
|
71
71
|
|
|
72
|
-
//
|
|
73
|
-
//
|
|
72
|
+
// An approximation of Node resolution, read from a package's package.json:
|
|
73
|
+
// exports "." by node, import, default priority, then main, then index.js.
|
|
74
74
|
const pick = (target: unknown): string | null => {
|
|
75
75
|
if (typeof target === "string") return target;
|
|
76
76
|
if (Array.isArray(target)) {
|
package/src/plugins/load.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// Loading: import a plugin module, check it has the shape the contract
|
|
2
2
|
// promises, and turn its rules into ordinary Rules. Every failure is a named
|
|
3
3
|
// PluginLoadError that says which source and why. These functions never skip
|
|
4
|
-
// anything themselves: they return
|
|
5
|
-
//
|
|
4
|
+
// anything themselves: they return, throw, or (loadLocal) return failures. The
|
|
5
|
+
// caller's mode (strict, lenient, collect) decides what a failure does.
|
|
6
6
|
|
|
7
7
|
import { basename, extname } from "node:path";
|
|
8
8
|
import { pathToFileURL } from "node:url";
|
|
@@ -201,7 +201,9 @@ const EXPECTATIONS = new Set(["same-diagrams", "same-tree"]);
|
|
|
201
201
|
// be synchronous or return a promise, and the wrapper awaits it. Its check cannot
|
|
202
202
|
// take the run down: a throw, a rejection or a malformed finding becomes a finding for that
|
|
203
203
|
// rule, and the other rules carry on. Its fixer is held to the same proof as a
|
|
204
|
-
// built-in
|
|
204
|
+
// built-in. A fixer that throws, returns a non-list, or returns an edit with no
|
|
205
|
+
// valid expect stops the run. An edit with bad offsets is refused by the engine
|
|
206
|
+
// and the finding stays.
|
|
205
207
|
export const toRule = (namespace: string, key: string, rule: PluginRule): Rule => {
|
|
206
208
|
const id = `${namespace}/${key}` as RuleId;
|
|
207
209
|
const check: Rule["check"] = async (ctx) => {
|
package/src/project.ts
CHANGED
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
// before it touches a document.
|
|
5
5
|
|
|
6
6
|
import { access } from "node:fs/promises";
|
|
7
|
-
import { dirname, join, resolve } from "node:path";
|
|
7
|
+
import { dirname, extname, join, resolve } from "node:path";
|
|
8
8
|
import { CONFIG_FILES, findConfig, loadConfig, NO_CONFIG } from "./config.ts";
|
|
9
|
-
import { ConfigNotFoundError } from "./errors.ts";
|
|
9
|
+
import { ConfigCodeError, ConfigNotFoundError } from "./errors.ts";
|
|
10
10
|
import { loadPlugins, type PluginFailure, type PluginMode } from "./plugins/index.ts";
|
|
11
11
|
import { FIX_ORDER, RULES } from "./rules/index.ts";
|
|
12
12
|
import { buildRegistry, type Registry } from "./rules/registry.ts";
|
|
@@ -33,7 +33,8 @@ export const findProjectRoot = async (cwd: string): Promise<string> => {
|
|
|
33
33
|
export interface SetupOptions {
|
|
34
34
|
cwd: string;
|
|
35
35
|
configPath?: string;
|
|
36
|
-
// Built-ins only. Anything discovered is reported
|
|
36
|
+
// Built-ins only, and no project code runs. Anything discovered is reported
|
|
37
|
+
// as skipped, and a .mjs config is refused with ConfigCodeError.
|
|
37
38
|
noPlugins?: boolean;
|
|
38
39
|
// Skip a plugin that fails to load and say so, instead of stopping the run.
|
|
39
40
|
lenient?: boolean;
|
|
@@ -63,6 +64,9 @@ export const setUp = async ({
|
|
|
63
64
|
} else {
|
|
64
65
|
file = await findConfig(root);
|
|
65
66
|
}
|
|
67
|
+
// A .mjs config is code, and --no-plugins promises that no project code
|
|
68
|
+
// runs, so the run stops before importing it rather than drop its settings.
|
|
69
|
+
if (noPlugins && file !== null && extname(file) === ".mjs") throw new ConfigCodeError(file);
|
|
66
70
|
const config = file ? await loadConfig(file) : NO_CONFIG;
|
|
67
71
|
let mode: PluginMode = "strict";
|
|
68
72
|
if (noPlugins || config.plugins === false) mode = "off";
|
|
@@ -75,7 +79,10 @@ export const setUp = async ({
|
|
|
75
79
|
categories: plugins.categories,
|
|
76
80
|
sources: plugins.sources,
|
|
77
81
|
config,
|
|
78
|
-
|
|
82
|
+
// A plugin rule's setting names a rule that is not loaded in these modes,
|
|
83
|
+
// by choice (off) or by failure (lenient, collect), so it is set aside
|
|
84
|
+
// rather than failing the run. A built-in id never is.
|
|
85
|
+
ignoreUnknownPluginIds: mode !== "strict",
|
|
79
86
|
});
|
|
80
87
|
return { registry, skipped: plugins.skipped, failures: plugins.failures };
|
|
81
88
|
};
|
|
@@ -4,7 +4,7 @@ import { RULE } from "./types.ts";
|
|
|
4
4
|
|
|
5
5
|
export const complexityNodeCountHardLimit = complexityRule({
|
|
6
6
|
id: RULE.NODES_HARD,
|
|
7
|
-
summary: "more nodes than
|
|
7
|
+
summary: "more nodes than the preset's hard limit",
|
|
8
8
|
hit: ({ stats }, c) =>
|
|
9
9
|
stats.nodes > c.node_hard_limit
|
|
10
10
|
? {
|
|
@@ -4,11 +4,11 @@ import { RULE } from "./types.ts";
|
|
|
4
4
|
|
|
5
5
|
export const complexityVisualScoreCritical = complexityRule({
|
|
6
6
|
id: RULE.VCS_CRITICAL,
|
|
7
|
-
summary: "a visual complexity score above the
|
|
7
|
+
summary: "a visual complexity score above the preset's complex threshold",
|
|
8
8
|
hit: ({ stats, metrics }, c) =>
|
|
9
9
|
metrics.visual_complexity_score > c.vcs_complex
|
|
10
10
|
? {
|
|
11
|
-
message: `VCS ${metrics.visual_complexity_score.toFixed(1)} > ${c.vcs_complex}
|
|
11
|
+
message: `VCS ${metrics.visual_complexity_score.toFixed(1)} > ${c.vcs_complex} complex threshold`,
|
|
12
12
|
remediation:
|
|
13
13
|
`Reduce diagram complexity. Split ${boundaryHint(stats.subgraph_names)}, remove redundant edges, ` +
|
|
14
14
|
`or collapse leaf nodes into labelled groups. ${splitTarget(c)}`,
|
|
@@ -11,7 +11,9 @@ const ratioOn = (fg: string, bg: string): number => wcagRatio(compositeOver(fg,
|
|
|
11
11
|
// scored in. The fix swaps a declared color: for black or white, whichever
|
|
12
12
|
// reads better, and only when that clears 4.5:1 on every background the
|
|
13
13
|
// directive is scored against. Choosing a brand colour is a design decision, so
|
|
14
|
-
// anything else stays reported.
|
|
14
|
+
// anything else stays reported. A linkStyle color: takes the same path, and it
|
|
15
|
+
// never clears: its label box is #e8e8e8 in light and #585858 in dark, where
|
|
16
|
+
// black and white each fail one theme, so edge label findings stay reported.
|
|
15
17
|
export const contrastTextBelowAa: Rule = {
|
|
16
18
|
id: RULE.TEXT,
|
|
17
19
|
category: "contrast",
|
|
@@ -36,7 +38,9 @@ export const contrastTextBelowAa: Rule = {
|
|
|
36
38
|
remediation:
|
|
37
39
|
profile === "mkdocs-material"
|
|
38
40
|
? "The host theme forces the label text, so change the fill until the host text reads on it in both themes."
|
|
39
|
-
:
|
|
41
|
+
: p.directive.kind === "linkStyle"
|
|
42
|
+
? "An edge label sits on Mermaid's own label box, light in one theme and grey in the other, and no one colour reads on both, so drop color: from the linkStyle and let the theme set the label text."
|
|
43
|
+
: "Darken or lighten the fill, or change color:, until the ratio reaches 4.5:1 in every theme.",
|
|
40
44
|
});
|
|
41
45
|
}
|
|
42
46
|
}
|
package/src/rules/index.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
// The rule catalogue, in
|
|
1
|
+
// The rule catalogue, in category order (parse, complexity, contrast), most
|
|
2
|
+
// severe first within a ladder. To add a rule: write one module exporting a
|
|
2
3
|
// Rule with its category, register it here, and add its section and examples to
|
|
3
4
|
// RULES.md, which tests/rules-doc.test.ts holds to the code.
|
|
4
5
|
|
|
@@ -1,29 +1,44 @@
|
|
|
1
1
|
import { parseFailed } from "../parse/metrics.ts";
|
|
2
|
+
import type { DiagramStats } from "../parse/stats.ts";
|
|
2
3
|
import { finding } from "./shared.ts";
|
|
3
4
|
import { RULE, type Rule } from "./types.ts";
|
|
4
5
|
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
|
|
6
|
+
// Diagram types that mermaid draws through an external add-on. Mermaid core
|
|
7
|
+
// does not register them, so no parser here can read them, valid or not.
|
|
8
|
+
const EXTERNAL_TYPES = new Set(["zenuml"]);
|
|
9
|
+
|
|
10
|
+
// What the finding says: the parser's own reason when it gave one, and
|
|
11
|
+
// otherwise that it read the diagram as zero nodes without complaint.
|
|
12
|
+
const messageOf = (stats: DiagramStats): string => {
|
|
13
|
+
const type = stats.diagram_type;
|
|
14
|
+
if (EXTERNAL_TYPES.has(type)) {
|
|
15
|
+
return `${type} is drawn by an external mermaid add-on that mermaidjs-lint does not include, so it cannot be parsed or checked`;
|
|
16
|
+
}
|
|
17
|
+
if (stats.error !== null) {
|
|
18
|
+
const lines = /\bline \d/.test(stats.error) ? " (its line numbers count within the diagram, not the file)" : "";
|
|
19
|
+
return `${type} did not parse (parser: ${stats.parser_used})${lines}: ${stats.error}`;
|
|
20
|
+
}
|
|
21
|
+
return `${type} parsed to 0 nodes from multi-line source with no error (parser: ${stats.parser_used}), a silent parse failure`;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
// A diagram that mermaid's parser rejects, or that mermaid core reads as zero
|
|
25
|
+
// nodes from more than one line, which is a silent parse failure. Rules never
|
|
26
|
+
// read each other: the complexity rules skip the same diagrams themselves
|
|
27
|
+
// through measure() in shared.ts, because thresholds are meaningless against
|
|
28
|
+
// structure the parser never read.
|
|
8
29
|
export const parseDiagramUnparseable: Rule = {
|
|
9
30
|
id: RULE.UNPARSEABLE,
|
|
10
31
|
category: "parse",
|
|
11
|
-
summary: "a
|
|
32
|
+
summary: "a diagram mermaid's parser rejects, or reads as zero nodes from several lines",
|
|
12
33
|
check: async (ctx) => {
|
|
13
34
|
const found = [];
|
|
14
35
|
for (const diagram of ctx.doc.diagrams) {
|
|
15
36
|
const stats = await diagram.stats();
|
|
16
37
|
if (!parseFailed(diagram.source, stats)) continue;
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
diagram,
|
|
22
|
-
RULE.UNPARSEABLE,
|
|
23
|
-
`${stats.diagram_type} yielded 0 nodes from multi-line source (parser: ${stats.parser_used})${why}`,
|
|
24
|
-
"Verify the diagram renders in mermaid.js and fix its syntax. Complexity rules are skipped for this diagram while it cannot be parsed.",
|
|
25
|
-
),
|
|
26
|
-
);
|
|
38
|
+
const remediation = EXTERNAL_TYPES.has(stats.diagram_type)
|
|
39
|
+
? `No fixer can help: mermaidjs-lint has no ${stats.diagram_type} parser. Redraw it as a type mermaid core includes, such as sequenceDiagram, or turn this rule off in the config.`
|
|
40
|
+
: "Verify the diagram renders in mermaid.js and fix its syntax. Complexity rules are skipped for this diagram while it cannot be parsed.";
|
|
41
|
+
found.push(finding(ctx, diagram, RULE.UNPARSEABLE, messageOf(stats), remediation));
|
|
27
42
|
}
|
|
28
43
|
return found;
|
|
29
44
|
},
|