@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.
Files changed (40) hide show
  1. package/README.md +82 -53
  2. package/dist/bin.js +202 -44
  3. package/dist/index.js +206 -45
  4. package/dist/testing.js +86 -23
  5. package/examples/architecture_beta_iconify_logos.mmd +27 -0
  6. package/examples/flowchart_fontawesome_icons.mmd +31 -0
  7. package/examples/layout_cosebilkent_mindmap.mmd +19 -0
  8. package/examples/layout_dagre_classic.mmd +22 -0
  9. package/examples/layout_dagre_handdrawn.mmd +23 -0
  10. package/examples/layout_dagre_neo.mmd +26 -0
  11. package/examples/layout_elk_default.mmd +22 -0
  12. package/examples/layout_elk_tuned.mmd +25 -0
  13. package/examples/layout_tidytree_mindmap.mmd +19 -0
  14. package/package.json +2 -2
  15. package/src/check.ts +2 -3
  16. package/src/cli.ts +45 -23
  17. package/src/config.ts +3 -2
  18. package/src/errors.ts +18 -3
  19. package/src/files.ts +3 -1
  20. package/src/fix/engine.ts +4 -4
  21. package/src/fix/verify.ts +2 -2
  22. package/src/index.ts +1 -1
  23. package/src/model.ts +10 -0
  24. package/src/parse/color.ts +2 -2
  25. package/src/parse/contrast.ts +59 -5
  26. package/src/parse/metrics.ts +16 -8
  27. package/src/parse/stats.ts +45 -19
  28. package/src/plugins/contract.ts +1 -1
  29. package/src/plugins/discover.ts +5 -5
  30. package/src/plugins/load.ts +5 -3
  31. package/src/project.ts +11 -4
  32. package/src/rules/complexity-node-count-hard-limit.ts +1 -1
  33. package/src/rules/complexity-visual-score-critical.ts +2 -2
  34. package/src/rules/contrast-text-below-aa.ts +6 -2
  35. package/src/rules/index.ts +2 -1
  36. package/src/rules/parse-diagram-unparseable.ts +29 -14
  37. package/src/rules/registry.ts +7 -4
  38. package/src/rules/shared.ts +1 -1
  39. package/src/rules/types.ts +2 -2
  40. 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 or returned something that is not a list of valid edits.
182
- // Unlike a check, this stops the run, because carrying on after a corrupt edit
183
- // is worse than stopping.
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
- export const DIAGRAM_FILE_EXTS = new Set([".mmd", ".md", ".markdown"]);
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 edit
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.toLowerCase().endsWith(".mmd") ? "diagram" : "markdown";
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 one edit, re-parses, and
2
- // checks that the document changed exactly as the edit declared. An edit that
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") {
@@ -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, and a value never contains
92
- // either in well-formed source, so a plain split is safe.
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,
@@ -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). Only pairs
4
- // whose both sides are declared are scored, because an unset property falls
5
- // back to a theme default that cannot be resolved without rendering.
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
- // Reported by the original auditor but never gating: a border on a faint fill
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 means the diagram is read through Material.
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++) {
@@ -167,14 +167,22 @@ export const calculateComplexity = (stats: DiagramStats, config: ThresholdConfig
167
167
  };
168
168
  };
169
169
 
170
- // A multi-line diagram that yielded no nodes is a silent parser failure, so its
171
- // metrics cannot be trusted. A diagram of only a keyword line has no structure
172
- // to extract and is fine.
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
- const lines = content.split("\n").filter((l) => {
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;
@@ -39,9 +39,12 @@ export interface DiagramStats {
39
39
  max_subgraph_depth: number;
40
40
  node_ids: string[];
41
41
  subgraph_names: string[];
42
- parser_used: "langium" | "mermaid-core" | "custom";
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 the last parser gave when no parser produced structure.
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 ParserFailure.
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
- if (keyword in LANGIUM_TYPES) {
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
- /* fall through to mermaid-core */
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 error: string | null = null;
126
+ let coreError: string;
119
127
  try {
120
128
  return await extractCoreStats(content, keyword);
121
129
  } catch (err) {
122
- error = (err instanceof Error ? err.message : String(err)).split("\n")[0] ?? null;
130
+ coreError = parserReason(err);
123
131
  }
124
132
 
125
- // 3. Neither canonical parser produced stats. Return a tagged failure
126
- // so assessParseQuality flags ParserFailure.
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, treeView, mindmap, ishikawa, kanban — every diagram
282
- // type where parent-child is encoded via indentation rather than explicit
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 (flowchart's getVertices/getEdges, kanban's getSections,
306
- // block's getBlocksFlat). Others expose it via direct properties (er.entities,
307
- // state.nodes, requirement.requirements). We dispatch on diagram.type and
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
 
@@ -26,7 +26,7 @@ export interface PluginMeta {
26
26
  name: string;
27
27
  namespace: string;
28
28
  apiVersion: number;
29
- // A new category the plugin adds, as the word and a one-line description.
29
+ // New categories the plugin adds, each word mapped to a one-line description.
30
30
  categories?: Record<string, string>;
31
31
  }
32
32
 
@@ -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 source that cannot be resolved is a failure the caller decides how to
8
- // treat, not an exception, so a lenient run can skip it and a validating run
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
- // The file Node would import for a package, read from its package.json the
73
- // way a resolver does: the exports entry, then main, then index.js.
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)) {
@@ -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 or throw, and the caller decides whether a
5
- // failed source stops the run (strict) or is reported and left out (lenient).
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, so a fixer that throws or returns nonsense stops the run loudly.
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 as skipped.
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
- ignoreUnknownPluginIds: mode === "lenient" || mode === "collect",
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 any diagram can hold, above the hard limit",
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 critical threshold",
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} critical threshold`,
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
- : "Darken or lighten the fill, or change color:, until the ratio reaches 4.5:1 in every theme.",
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
  }
@@ -1,4 +1,5 @@
1
- // The rule catalogue, in id order. To add a rule: write one module exporting a
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
- // A multi-line diagram whose parser yields no nodes is a silent parse failure.
6
- // It short-circuits the complexity rules, because thresholds are meaningless
7
- // against structure the parser never read.
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 multi-line diagram that mermaid's parser reads as zero nodes",
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 why = stats.error ? `: ${stats.error}` : "";
18
- found.push(
19
- finding(
20
- ctx,
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
  },