@jpeakai/mermaidjs-lint 0.2.0 → 0.4.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 (57) hide show
  1. package/README.md +12 -9
  2. package/dist/bin.js +214 -64
  3. package/dist/index.js +217 -64
  4. package/dist/testing.js +99 -32
  5. package/dist/types/bin.d.ts +2 -0
  6. package/dist/types/check.d.ts +10 -0
  7. package/dist/types/cli.d.ts +9 -0
  8. package/dist/types/config.d.ts +20 -0
  9. package/dist/types/errors.d.ts +88 -0
  10. package/dist/types/files.d.ts +8 -0
  11. package/dist/types/fix/engine.d.ts +10 -0
  12. package/dist/types/fix/verify.d.ts +12 -0
  13. package/dist/types/index.d.ts +18 -0
  14. package/dist/types/model.d.ts +27 -0
  15. package/dist/types/parse/color.d.ts +28 -0
  16. package/dist/types/parse/contrast.d.ts +35 -0
  17. package/dist/types/parse/metrics.d.ts +48 -0
  18. package/dist/types/parse/stats.d.ts +25 -0
  19. package/dist/types/plugins/contract.d.ts +24 -0
  20. package/dist/types/plugins/discover.d.ts +20 -0
  21. package/dist/types/plugins/index.d.ts +19 -0
  22. package/dist/types/plugins/load.d.ts +14 -0
  23. package/dist/types/plugins/runtime.d.ts +6 -0
  24. package/dist/types/project.d.ts +18 -0
  25. package/dist/types/rules/complexity-node-count-acceptable-exceeded.d.ts +1 -0
  26. package/dist/types/rules/complexity-node-count-cognitive-limit.d.ts +1 -0
  27. package/dist/types/rules/complexity-node-count-hard-limit.d.ts +1 -0
  28. package/dist/types/rules/complexity-subgraph-nesting-too-deep.d.ts +2 -0
  29. package/dist/types/rules/complexity-visual-score-acceptable-exceeded.d.ts +1 -0
  30. package/dist/types/rules/complexity-visual-score-critical.d.ts +1 -0
  31. package/dist/types/rules/complexity.d.ts +12 -0
  32. package/dist/types/rules/contrast-border-below-ui-minimum.d.ts +2 -0
  33. package/dist/types/rules/contrast-directive-unauditable.d.ts +2 -0
  34. package/dist/types/rules/contrast-text-below-aa.d.ts +2 -0
  35. package/dist/types/rules/index.d.ts +3 -0
  36. package/dist/types/rules/parse-diagram-unparseable.d.ts +2 -0
  37. package/dist/types/rules/parse-fence-unterminated.d.ts +2 -0
  38. package/dist/types/rules/registry.d.ts +29 -0
  39. package/dist/types/rules/shared.d.ts +18 -0
  40. package/dist/types/rules/types.d.ts +67 -0
  41. package/dist/types/testing.d.ts +15 -0
  42. package/package.json +5 -4
  43. package/src/cli.ts +47 -15
  44. package/src/errors.ts +46 -0
  45. package/src/files.ts +21 -2
  46. package/src/fix/engine.ts +22 -10
  47. package/src/fix/verify.ts +31 -3
  48. package/src/index.ts +5 -3
  49. package/src/parse/metrics.ts +8 -1
  50. package/src/parse/stats.ts +72 -16
  51. package/src/plugins/contract.ts +4 -2
  52. package/src/plugins/discover.ts +38 -15
  53. package/src/plugins/index.ts +5 -3
  54. package/src/plugins/load.ts +32 -1
  55. package/src/project.ts +3 -1
  56. package/src/rules/complexity-subgraph-nesting-too-deep.ts +5 -0
  57. package/src/rules/parse-fence-unterminated.ts +1 -1
package/src/fix/engine.ts CHANGED
@@ -4,12 +4,14 @@
4
4
  // computed from, so every accepted edit restarts the pass.
5
5
 
6
6
  import { optionsFor, type RunFlags } from "../check.ts";
7
+ import { FixNotConvergingError } from "../errors.ts";
7
8
  import { buildDocModel, documentKind } from "../model.ts";
8
9
  import { BUILTIN, HELPERS, type Registry } from "../rules/registry.ts";
9
10
  import type { Edit } from "../rules/types.ts";
10
11
  import { verify } from "./verify.ts";
11
12
 
12
13
  const MAX_PASSES = 1_000;
14
+ const RECENT_PASSES = 50;
13
15
 
14
16
  export interface FixReport {
15
17
  output: string;
@@ -51,6 +53,10 @@ export const fixSourceReport = async (
51
53
  const refusedKeys = new Set<string>();
52
54
  let current = src;
53
55
 
56
+ // The rule whose edit each pass kept, so a run that never converges can name
57
+ // the rules still proposing edits at the end.
58
+ const keptBy: string[] = [];
59
+
54
60
  for (let pass = 0; pass < MAX_PASSES; pass++) {
55
61
  const doc = buildDocModel(current, kind);
56
62
  const keyOf = (e: Edit): string => `${e.rule}\u0000${current.slice(e.start, e.end)}\u0000${e.text}`;
@@ -85,21 +91,27 @@ export const fixSourceReport = async (
85
91
  // the rest.
86
92
  if (edits.length > 1 && !overlaps(edits) && (await attempt(edits))) {
87
93
  progressed = true;
88
- break;
89
- }
90
- for (const edit of edits) {
91
- if (await attempt([edit])) {
92
- progressed = true;
93
- break;
94
+ } else {
95
+ for (const edit of edits) {
96
+ if (await attempt([edit])) {
97
+ progressed = true;
98
+ break;
99
+ }
100
+ refusedKeys.add(keyOf(edit));
101
+ refused.push(edit);
94
102
  }
95
- refusedKeys.add(keyOf(edit));
96
- refused.push(edit);
97
103
  }
98
- if (progressed) break;
104
+ if (progressed) {
105
+ keptBy.push(rule.id);
106
+ break;
107
+ }
99
108
  }
100
109
  if (!progressed) return { output: current, applied, refused };
101
110
  }
102
- throw new Error(`fix did not converge after ${MAX_PASSES} passes; a fixer keeps proposing edits`);
111
+ // The last passes show who was still editing: a fixer that never converges
112
+ // keeps one in every pass.
113
+ const lingering = [...new Set(keptBy.slice(-RECENT_PASSES))].sort();
114
+ throw new FixNotConvergingError(file, MAX_PASSES, lingering);
103
115
  };
104
116
 
105
117
  export const fixSource = async (
package/src/fix/verify.ts CHANGED
@@ -4,6 +4,7 @@
4
4
 
5
5
  import type { Node, Root } from "mdast";
6
6
  import type { DocModel } from "../model.ts";
7
+ import { parseFailed } from "../parse/metrics.ts";
7
8
  import type { Edit } from "../rules/types.ts";
8
9
 
9
10
  type Plain = { [k: string]: unknown };
@@ -43,23 +44,50 @@ const sameDiagrams = async (before: DocModel, after: DocModel): Promise<boolean>
43
44
  const now = after.diagrams[i];
44
45
  if (!now || was.keyword !== now.keyword) return false;
45
46
  const [a, b] = await Promise.all([was.stats(), now.stats()]);
46
- const shape = (s: typeof a): unknown => [
47
+ const shape = (s: typeof a, source: string): unknown => [
48
+ parseFailed(source, s),
47
49
  s.diagram_type,
48
50
  s.nodes,
49
51
  s.edges,
50
52
  s.subgraphs,
51
53
  s.max_subgraph_depth,
54
+ s.max_tree_depth,
52
55
  s.node_ids,
53
56
  s.subgraph_names,
54
57
  ];
55
- if (!sameJson(shape(a), shape(b))) return false;
58
+ if (!sameJson(shape(a, was.source), shape(b, now.source))) return false;
56
59
  }
57
60
  return true;
58
61
  };
59
62
 
63
+ // Whether a diagram that parsed before the edit fails to parse after it. The
64
+ // diagrams are matched by position, which same-tree's identical tree already
65
+ // pins. Asked of same-tree edits as a belt to the tree comparison's braces: a
66
+ // diagram's text sits in the tree, so an unchanged tree cannot break it today,
67
+ // but no edit may ever trade a parseable diagram for an unparseable one.
68
+ const breaksAParse = async (before: DocModel, after: DocModel): Promise<boolean> => {
69
+ for (const [i, was] of before.diagrams.entries()) {
70
+ const now = after.diagrams[i];
71
+ if (!now) continue;
72
+ const [a, b] = await Promise.all([was.stats(), now.stats()]);
73
+ if (!parseFailed(was.source, a) && parseFailed(now.source, b)) return true;
74
+ }
75
+ return false;
76
+ };
77
+
78
+ // A raw diagram file (.mmd, .mermaid) has no markdown around its diagram, so
79
+ // its tree is an empty root and comparing trees would prove nothing. There,
80
+ // same-tree also demands what same-diagrams does: the diagram is still there
81
+ // and parses to the same structure. same-diagrams compares parse success too
82
+ // (parseFailed, the test parse-diagram-unparseable applies), so an edit can
83
+ // neither break a diagram that parsed nor claim to have kept the diagrams the
84
+ // same while making an unparseable one parse.
60
85
  export const verify = async (edit: Edit, before: DocModel, after: DocModel): Promise<Verdict> => {
61
86
  if (edit.expect.kind === "same-tree") {
62
- return sameTree(before.tree, after.tree, {}) ? "accept" : "refuse";
87
+ if (!sameTree(before.tree, after.tree, {})) return "refuse";
88
+ if (before.kind === "markdown" && after.kind === "markdown") {
89
+ return (await breaksAParse(before, after)) ? "refuse" : "accept";
90
+ }
63
91
  }
64
92
  return (await sameDiagrams(before, after)) ? "accept" : "refuse";
65
93
  };
package/src/index.ts CHANGED
@@ -4,14 +4,16 @@ export { checkMarkdown, checkModel, type RunFlags } from "./check.ts";
4
4
  export { main } from "./cli.ts";
5
5
  export { type Config, loadConfig, type PluginLoading, parseConfig, type RuleSetting, type Severity } from "./config.ts";
6
6
  export * from "./errors.ts";
7
- export { collectFiles } from "./files.ts";
7
+ export { type Collected, collectFiles } from "./files.ts";
8
8
  export { type FixReport, fixSource, fixSourceReport } from "./fix/engine.ts";
9
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
- export { PRESETS, thresholdsFrom } from "./parse/metrics.ts";
11
+ export { PRESETS, type Preset, type ThresholdConfig, thresholdsFrom } from "./parse/metrics.ts";
12
12
  export { type DiagramStats, detectKeyword, extractStats } from "./parse/stats.ts";
13
13
  export type { Plugin, PluginEdit, PluginFinding, PluginMeta, PluginRule } from "./plugins/contract.ts";
14
- export { findProjectRoot, setUp } from "./project.ts";
14
+ export type { SourceKind } from "./plugins/discover.ts";
15
+ export type { PluginFailure } from "./plugins/index.ts";
16
+ export { findProjectRoot, type Setup, type SetupOptions, setUp } from "./project.ts";
15
17
  export { FIX_ORDER, RULES } from "./rules/index.ts";
16
18
  export {
17
19
  BUILTIN,
@@ -152,11 +152,18 @@ const round4 = (x: number): number => Math.round(x * 10000) / 10000;
152
152
  // (nodes + edges*w_e + subgraphs*w_s) * (1 + depth*w_d). Edge density is
153
153
  // reported as 0 below two nodes rather than dividing by a fallback, which gave
154
154
  // nonsense when a parser returned edges but no nodes.
155
+ //
156
+ // The depth is subgraph nesting or, for a tree-shaped type (mindmap, treemap,
157
+ // treeView, ishikawa), tree depth; a diagram has only one of the two. A deep
158
+ // tree is as hard to follow as deep nesting, so its depth still weighs on the
159
+ // score, and the score of those types is unchanged from when tree depth was
160
+ // counted as nesting. Only complexity-subgraph-nesting-too-deep ignores it,
161
+ // because its remedy (flatten subgraphs) cannot apply to a tree.
155
162
  export const calculateComplexity = (stats: DiagramStats, config: ThresholdConfig): ComplexityMetrics => {
156
163
  const n = stats.nodes;
157
164
  const e = stats.edges;
158
165
  const s = stats.subgraphs;
159
- const d = stats.max_subgraph_depth;
166
+ const d = Math.max(stats.max_subgraph_depth, stats.max_tree_depth);
160
167
  const base = n + e * config.edge_weight + s * config.subgraph_weight;
161
168
  const vcs = base * (1 + d * config.depth_weight);
162
169
  const maxEdges = n > 1 ? n * (n - 1) : 0;
@@ -36,7 +36,14 @@ export interface DiagramStats {
36
36
  nodes: number;
37
37
  edges: number;
38
38
  subgraphs: number;
39
+ // How deep real containers nest: flowchart subgraphs, composite states,
40
+ // architecture groups and the like.
39
41
  max_subgraph_depth: number;
42
+ // How far a tree-shaped diagram (mindmap, treemap, treeView, ishikawa)
43
+ // reaches below its root. Those types draw no subgraphs, so their depth is
44
+ // kept apart from max_subgraph_depth and the nesting rule never reads it.
45
+ // It is 0 for every other type.
46
+ max_tree_depth: number;
40
47
  node_ids: string[];
41
48
  subgraph_names: string[];
42
49
  // The parser that read the diagram, or, on a failure, the one whose reason
@@ -137,6 +144,7 @@ export async function extractStats(content: string): Promise<MermaidStats> {
137
144
  edges: 0,
138
145
  subgraphs: 0,
139
146
  max_subgraph_depth: 0,
147
+ max_tree_depth: 0,
140
148
  node_ids: [],
141
149
  subgraph_names: [],
142
150
  parser_used: langium ? "langium" : "mermaid-core",
@@ -200,6 +208,7 @@ async function extractLangiumStats(content: string, keyword: string): Promise<Me
200
208
  const subgraphNames: string[] = [];
201
209
  let edges = 0;
202
210
  let maxDepth = 0;
211
+ let treeDepth = 0;
203
212
 
204
213
  switch (mapped) {
205
214
  case "architecture": {
@@ -246,7 +255,7 @@ async function extractLangiumStats(content: string, keyword: string): Promise<Me
246
255
  const stats = indentTreeStats(nodes.map((n, i) => ({ label: n.name ?? `node_${i}`, indent: n.indent ?? 0 })));
247
256
  nodeIds.push(...stats.labels);
248
257
  edges = stats.edges;
249
- maxDepth = stats.depth;
258
+ treeDepth = stats.depth;
250
259
  break;
251
260
  }
252
261
  case "treemap": {
@@ -255,7 +264,7 @@ async function extractLangiumStats(content: string, keyword: string): Promise<Me
255
264
  const stats = indentTreeStats(rows.map((r, i) => ({ label: r.item?.name ?? `row_${i}`, indent: r.indent ?? 0 })));
256
265
  nodeIds.push(...stats.labels);
257
266
  edges = stats.edges;
258
- maxDepth = stats.depth;
267
+ treeDepth = stats.depth;
259
268
  break;
260
269
  }
261
270
  case "wardley": {
@@ -277,6 +286,7 @@ async function extractLangiumStats(content: string, keyword: string): Promise<Me
277
286
  edges,
278
287
  subgraphs: subgraphNames.length,
279
288
  max_subgraph_depth: maxDepth,
289
+ max_tree_depth: treeDepth,
280
290
  node_ids: [...new Set(nodeIds.filter(Boolean))].sort(),
281
291
  subgraph_names: subgraphNames,
282
292
  parser_used: "langium",
@@ -340,6 +350,8 @@ interface Structure {
340
350
  edges: number;
341
351
  subgraphNames: string[];
342
352
  maxDepth: number;
353
+ // Set only by the tree-shaped types: see max_tree_depth.
354
+ treeDepth?: number;
343
355
  }
344
356
  type CoreAdapter = (db: AnyDb) => Structure;
345
357
 
@@ -362,13 +374,14 @@ async function extractCoreStats(content: string, keyword: string): Promise<Merma
362
374
  if (!adapter) {
363
375
  throw new Error(`no core adapter for diagram.type="${diagramType}" (keyword="${keyword}")`);
364
376
  }
365
- const { nodeIds, edges, subgraphNames, maxDepth } = adapter(diagram.db);
377
+ const { nodeIds, edges, subgraphNames, maxDepth, treeDepth = 0 } = adapter(diagram.db);
366
378
 
367
379
  return {
368
380
  nodes: nodeIds.length,
369
381
  edges,
370
382
  subgraphs: subgraphNames.length,
371
383
  max_subgraph_depth: maxDepth,
384
+ max_tree_depth: treeDepth,
372
385
  node_ids: [...nodeIds].sort(),
373
386
  subgraph_names: subgraphNames,
374
387
  parser_used: "mermaid-core",
@@ -386,6 +399,9 @@ async function extractCoreStats(content: string, keyword: string): Promise<Merma
386
399
  const CORE_ADAPTERS: Record<string, CoreAdapter> = {
387
400
  "flowchart-v2": adaptFlowchart,
388
401
  flowchart: adaptFlowchart,
402
+ // `flowchart-elk` is a flowchart drawn with the ELK layout. Mermaid parses it
403
+ // with the flowchart grammar into the same DB, and only the layout differs.
404
+ "flowchart-elk": adaptFlowchart,
389
405
  graph: adaptFlowchart,
390
406
  classDiagram: adaptClass,
391
407
  "classDiagram-v2": adaptClass,
@@ -557,7 +573,13 @@ function adaptIshikawa(db: AnyDb): Structure {
557
573
  for (const c of node.children ?? []) walk(c, depth + 1);
558
574
  };
559
575
  walk(root, 0);
560
- return { nodeIds: labels, edges: Math.max(0, labels.length - 1), subgraphNames: [], maxDepth };
576
+ return {
577
+ nodeIds: labels,
578
+ edges: Math.max(0, labels.length - 1),
579
+ subgraphNames: [],
580
+ maxDepth: 0,
581
+ treeDepth: maxDepth,
582
+ };
561
583
  }
562
584
 
563
585
  interface KanbanNode {
@@ -596,20 +618,29 @@ function adaptMindmap(db: AnyDb): Structure {
596
618
  for (const c of node.children ?? []) walk(c, depth + 1);
597
619
  };
598
620
  walk(root, 0);
599
- return { nodeIds: labels, edges: Math.max(0, labels.length - 1), subgraphNames: [], maxDepth };
621
+ return {
622
+ nodeIds: labels,
623
+ edges: Math.max(0, labels.length - 1),
624
+ subgraphNames: [],
625
+ maxDepth: 0,
626
+ treeDepth: maxDepth,
627
+ };
600
628
  }
601
629
 
630
+ type QuadrantText = { text?: string } | string | undefined;
602
631
  interface QuadrantData {
603
- quadrants?: Array<{ text?: { text?: string } | string }>;
632
+ quadrants?: Array<{ text?: QuadrantText }>;
633
+ points?: Array<{ text?: QuadrantText }>;
604
634
  }
635
+ // A node is every plotted point, plus every quadrant that carries a label. The
636
+ // four quadrant boxes are always drawn, so an unlabelled one adds nothing to
637
+ // read, while every point is a mark the reader must place.
605
638
  function adaptQuadrantChart(db: AnyDb): Structure {
606
639
  const data = callGetter<QuadrantData | null>(db, "getQuadrantData");
607
- const quadrants = data?.quadrants ?? [];
608
- const labels = quadrants.map((q) => {
609
- const t = q.text;
610
- return typeof t === "string" ? t : (t?.text ?? "");
611
- });
612
- return { nodeIds: labels, edges: 0, subgraphNames: [], maxDepth: 0 };
640
+ const textOf = (t: QuadrantText): string => (typeof t === "string" ? t : (t?.text ?? ""));
641
+ const quadrants = (data?.quadrants ?? []).map((q) => textOf(q.text)).filter((t) => t.trim() !== "");
642
+ const points = (data?.points ?? []).map((p, i) => textOf(p.text) || `point_${i}`);
643
+ return { nodeIds: [...quadrants, ...points], edges: 0, subgraphNames: [], maxDepth: 0 };
613
644
  }
614
645
 
615
646
  function adaptRequirement(db: AnyDb): Structure {
@@ -639,36 +670,56 @@ function adaptSankey(db: AnyDb): Structure {
639
670
  interface StateNode {
640
671
  id?: string;
641
672
  type?: string;
673
+ isGroup?: boolean;
674
+ parentId?: string;
642
675
  }
643
676
  interface StateEdge {
644
677
  id1?: string;
645
678
  id2?: string;
646
679
  }
680
+ // A composite state (`state A { ... }`) is a group that holds other states,
681
+ // so it counts as a subgraph too, and its nesting depth is read from each
682
+ // state's parentId chain. It stays a node as well, because it is drawn as a
683
+ // labelled state.
647
684
  function adaptState(db: AnyDb): Structure {
648
685
  // stateDiagram db exposes data on direct properties.
649
686
  const nodes = (db.nodes as StateNode[] | undefined) ?? [];
650
687
  const edges = (db.edges as StateEdge[] | undefined) ?? [];
688
+ const groups = nodes.filter((n) => n.isGroup === true);
689
+ const parentOf = new Map(nodes.map((n) => [n.id ?? "", n.parentId]));
690
+ let maxDepth = 0;
691
+ for (const g of groups) {
692
+ let depth = 1;
693
+ const seen = new Set<string>();
694
+ for (let cur = g.parentId; cur && !seen.has(cur); cur = parentOf.get(cur)) {
695
+ seen.add(cur);
696
+ depth++;
697
+ }
698
+ maxDepth = Math.max(maxDepth, depth);
699
+ }
651
700
  return {
652
701
  nodeIds: nodes.map((n) => n.id ?? ""),
653
702
  edges: edges.length,
654
- subgraphNames: [],
655
- maxDepth: 0,
703
+ subgraphNames: groups.map((g) => g.id ?? ""),
704
+ maxDepth,
656
705
  };
657
706
  }
658
707
 
659
708
  interface TimelineTask {
660
709
  task?: string;
661
710
  section?: string;
711
+ events?: string[];
662
712
  }
663
713
  interface TimelineSection {
664
714
  sectionName?: string;
665
715
  section?: string;
666
716
  }
667
717
  function adaptTimeline(db: AnyDb): Structure {
718
+ // Every period and every event under it is a box on the page.
668
719
  const tasks = callGetter<TimelineTask[]>(db, "getTasks") ?? [];
669
720
  const sections = callGetter<TimelineSection[]>(db, "getSections") ?? [];
670
721
  return {
671
- nodeIds: tasks.map((t) => t.task ?? ""),
722
+ nodeIds: tasks.flatMap((t) => [t.task ?? "", ...(t.events ?? []).map((e) => `${t.task ?? ""}: ${e}`)]),
672
723
  edges: 0,
673
724
  subgraphNames: sections.map((s) => s.sectionName ?? s.section ?? ""),
674
725
  maxDepth: sections.length > 0 ? 1 : 0,
@@ -714,11 +765,16 @@ function adaptVenn(db: AnyDb): Structure {
714
765
 
715
766
  interface XYChartData {
716
767
  xAxis?: { categories?: string[] };
768
+ plots?: Array<{ data?: unknown[] }>;
717
769
  }
770
+ // A node is an x position: a category, or, on a numeric x-axis, a point of the
771
+ // longest series.
718
772
  function adaptXychart(db: AnyDb): Structure {
719
773
  const data = callGetter<XYChartData | null>(db, "getXYChartData");
720
774
  const categories = data?.xAxis?.categories ?? [];
721
- return { nodeIds: categories, edges: 0, subgraphNames: [], maxDepth: 0 };
775
+ if (categories.length > 0) return { nodeIds: categories, edges: 0, subgraphNames: [], maxDepth: 0 };
776
+ const longest = Math.max(0, ...(data?.plots ?? []).map((p) => p.data?.length ?? 0));
777
+ return { nodeIds: Array.from({ length: longest }, (_, i) => `x_${i}`), edges: 0, subgraphNames: [], maxDepth: 0 };
722
778
  }
723
779
 
724
780
  function subgraphNestingDepth(subGraphs: Array<{ id?: string; nodes?: string[] }>): number {
@@ -37,5 +37,7 @@ export interface Plugin {
37
37
 
38
38
  // A one-word category and a namespace or rule key, as they appear in an id.
39
39
  export const CATEGORY_WORD = /^[a-z]+$/;
40
- export const NAMESPACE = /^[a-z][a-z0-9-]*$/;
41
- export const RULE_KEY = /^[a-z][a-z0-9-]*$/;
40
+ // Lower-case words joined by single hyphens: no leading, trailing or doubled
41
+ // hyphen, so `house-` and `a-` are refused.
42
+ export const NAMESPACE = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
43
+ export const RULE_KEY = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
@@ -4,14 +4,16 @@
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 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.
7
+ // A declared package that is not installed, or whose package.json is not a
8
+ // JSON object, is a failure the caller decides how to treat, not an exception,
9
+ // so a lenient run can skip it and a validating run can report every one. A
10
+ // malformed project package.json throws PackageJsonError, except in a run with
11
+ // plugins off, which loads no package it names.
10
12
 
11
13
  import { readdir, readFile } from "node:fs/promises";
12
14
  import { dirname, extname, isAbsolute, join, resolve } from "node:path";
13
15
  import type { Config } from "../config.ts";
14
- import { PluginNotInstalledError } from "../errors.ts";
16
+ import { PackageJsonError, PluginLoadError, PluginNotInstalledError, PluginSourceError } from "../errors.ts";
15
17
 
16
18
  export type SourceKind = "local" | "package" | "config";
17
19
 
@@ -23,10 +25,16 @@ export interface PluginSource {
23
25
  path: string;
24
26
  }
25
27
 
28
+ export interface UnresolvedSource {
29
+ kind: SourceKind;
30
+ error: PluginLoadError;
31
+ }
32
+
26
33
  export interface Discovery {
27
34
  sources: PluginSource[];
28
- // Sources found but not resolvable, such as a declared package that is not installed.
29
- failures: PluginNotInstalledError[];
35
+ // Sources found but not resolvable, such as a declared package that is not
36
+ // installed, each with the kind of source that named it.
37
+ failures: UnresolvedSource[];
30
38
  }
31
39
 
32
40
  export const LOCAL_RULES = join(".mermaidjs-lint", "rules");
@@ -49,7 +57,7 @@ const readJson = async (path: string): Promise<Record<string, unknown> | null> =
49
57
  } catch {
50
58
  // Reported below with the path.
51
59
  }
52
- throw new Error(`${path} is not a JSON object`);
60
+ throw new PackageJsonError(path);
53
61
  };
54
62
 
55
63
  // Every extension a local rule may have. Whether the runtime can import a
@@ -101,15 +109,27 @@ const entryOf = (pkg: Record<string, unknown>): string => {
101
109
  export const resolvePackage = async (root: string, name: string): Promise<string> => {
102
110
  for (let dir = resolve(root); ; dir = dirname(dir)) {
103
111
  const folder = join(dir, "node_modules", name);
104
- const pkg = await readJson(join(folder, "package.json"));
112
+ let pkg: Record<string, unknown> | null;
113
+ try {
114
+ pkg = await readJson(join(folder, "package.json"));
115
+ } catch (err) {
116
+ if (!(err instanceof PackageJsonError)) throw err;
117
+ throw new PluginSourceError(name, `its package.json at ${err.file} is not a JSON object`);
118
+ }
105
119
  if (pkg) return join(folder, entryOf(pkg));
106
120
  if (dirname(dir) === dir) break;
107
121
  }
108
122
  throw new PluginNotInstalledError(name, root);
109
123
  };
110
124
 
111
- const declaredPlugins = async (root: string): Promise<string[]> => {
112
- const pkg = await readJson(join(root, "package.json"));
125
+ const declaredPlugins = async (root: string, tolerateBadManifest: boolean): Promise<string[]> => {
126
+ let pkg: Record<string, unknown> | null;
127
+ try {
128
+ pkg = await readJson(join(root, "package.json"));
129
+ } catch (err) {
130
+ if (tolerateBadManifest && err instanceof PackageJsonError) return [];
131
+ throw err;
132
+ }
113
133
  if (!pkg) return [];
114
134
  const names = ["dependencies", "devDependencies", "optionalDependencies"].flatMap((field) => {
115
135
  const deps = pkg[field];
@@ -118,18 +138,21 @@ const declaredPlugins = async (root: string): Promise<string[]> => {
118
138
  return [...new Set(names)].filter((name) => PACKAGE_PATTERN.test(name)).sort();
119
139
  };
120
140
 
121
- export const discoverSources = async (root: string, config: Config): Promise<Discovery> => {
141
+ // A run with plugins off loads nothing, so a malformed project package.json,
142
+ // which could only name packages that run will not load, does not stop it.
143
+ export const discoverSources = async (root: string, config: Config, pluginsOff = false): Promise<Discovery> => {
122
144
  const sources = await localSources(root);
123
- const failures: PluginNotInstalledError[] = [];
145
+ const failures: UnresolvedSource[] = [];
124
146
  const add = async (kind: SourceKind, spec: string, resolveIt: () => Promise<string>): Promise<void> => {
125
147
  try {
126
148
  sources.push({ kind, spec, path: await resolveIt() });
127
149
  } catch (err) {
128
- if (!(err instanceof PluginNotInstalledError)) throw err;
129
- failures.push(err);
150
+ if (!(err instanceof PluginLoadError)) throw err;
151
+ failures.push({ kind, error: err });
130
152
  }
131
153
  };
132
- for (const name of await declaredPlugins(root)) await add("package", name, () => resolvePackage(root, name));
154
+ for (const name of await declaredPlugins(root, pluginsOff))
155
+ await add("package", name, () => resolvePackage(root, name));
133
156
  for (const spec of config.plugins || []) {
134
157
  const path = spec.startsWith(".") || isAbsolute(spec);
135
158
  await add("config", spec, () => (path ? Promise.resolve(resolve(root, spec)) : resolvePackage(root, spec)));
@@ -34,7 +34,7 @@ export interface PluginSet {
34
34
  }
35
35
 
36
36
  export const loadPlugins = async (root: string, config: Config, mode: PluginMode): Promise<PluginSet> => {
37
- const { sources: found, failures: unresolved } = await discoverSources(root, config);
37
+ const { sources: found, failures: unresolved } = await discoverSources(root, config, mode === "off");
38
38
  const set: PluginSet = {
39
39
  rules: [],
40
40
  categories: new Map(BUILTIN_CATEGORIES),
@@ -42,14 +42,16 @@ export const loadPlugins = async (root: string, config: Config, mode: PluginMode
42
42
  skipped: [],
43
43
  failures: [],
44
44
  };
45
- if (mode === "off") return { ...set, skipped: [...found.map((s) => s.spec), ...unresolved.map((e) => e.source)] };
45
+ if (mode === "off") {
46
+ return { ...set, skipped: [...found.map((s) => s.spec), ...unresolved.map((u) => u.error.source)] };
47
+ }
46
48
 
47
49
  // Strict stops at the first failure. The others record it and carry on.
48
50
  const settle = (error: PluginLoadError, kind: SourceKind): void => {
49
51
  if (mode === "strict") throw error;
50
52
  if (!set.failures.some((f) => f.source === error.source)) set.failures.push({ source: error.source, kind, error });
51
53
  };
52
- for (const error of unresolved) settle(error, "package");
54
+ for (const { error, kind } of unresolved) settle(error, kind);
53
55
 
54
56
  const loaded: { plugin: Plugin; kind: SourceKind; spec: string }[] = [];
55
57
  const locals = found.filter((s) => s.kind === "local");
@@ -49,6 +49,9 @@ const importModule = async (source: PluginSource, runtime?: RuntimeInfo): Promis
49
49
 
50
50
  const OPTION_TYPES = new Set<OptionType>(["number", "string", "boolean", "string[]"]);
51
51
 
52
+ // Option names every rule shares with the built-ins. See checkRule.
53
+ const RESERVED_OPTIONS = ["preset", "profile"] as const;
54
+
52
55
  // Checks one rule's declaration and returns it, so a bad rule fails at load
53
56
  // rather than the first time it runs.
54
57
  const checkRule = (spec: string, key: string, raw: unknown, own: ReadonlySet<string>): PluginRule => {
@@ -80,6 +83,18 @@ const checkRule = (spec: string, key: string, raw: unknown, own: ReadonlySet<str
80
83
  if (!valid) {
81
84
  throw new PluginContractError(spec, `${where} options must map names to number, string, boolean or string[]`);
82
85
  }
86
+ // preset and profile keep their built-in meaning for every rule: the
87
+ // config checks the value against the built-in names, and --preset and
88
+ // --profile overwrite it. A rule may declare one to follow the run's
89
+ // setting, as a string, but cannot give the name another meaning.
90
+ for (const name of RESERVED_OPTIONS) {
91
+ if (name in options && (options as Record<string, unknown>)[name] !== "string") {
92
+ throw new PluginContractError(
93
+ spec,
94
+ `${where} declares option "${name}" as ${String((options as Record<string, unknown>)[name])}, but "${name}" is reserved for the built-in ${name}, which is a string set by --${name}; declare it as "string" to follow the run's ${name}, or pick another name`,
95
+ );
96
+ }
97
+ }
83
98
  }
84
99
  return raw as unknown as PluginRule;
85
100
  };
@@ -148,7 +163,23 @@ export const loadLocal = async (sources: PluginSource[], runtime?: RuntimeInfo):
148
163
  const categories: Record<string, string> = {};
149
164
  const owner: Record<string, string> = {};
150
165
  const loaded: { key: string; source: PluginSource; raw: unknown }[] = [];
166
+ // A rule's key is its file's base name, so two files with one base name
167
+ // (house-x.js and house-x.mjs) would claim one rule. Neither is imported,
168
+ // and the failure names both, rather than one silently replacing the other.
169
+ const keyOf = (source: PluginSource): string => basename(source.path, extname(source.path));
170
+ const byKey = new Map<string, PluginSource[]>();
171
+ for (const source of sources) byKey.set(keyOf(source), [...(byKey.get(keyOf(source)) ?? []), source]);
172
+ for (const [key, group] of byKey) {
173
+ if (group.length < 2) continue;
174
+ fail(
175
+ new PluginContractError(
176
+ (group[0] as PluginSource).spec,
177
+ `rule "${key}" is defined by more than one file: ${group.map((s) => s.spec).join(", ")}; each rule comes from exactly one file, so rename or remove all but one`,
178
+ ),
179
+ );
180
+ }
151
181
  for (const source of sources) {
182
+ if ((byKey.get(keyOf(source))?.length ?? 0) > 1) continue;
152
183
  try {
153
184
  const mod = await importModule(source, runtime);
154
185
  if (mod.default === undefined) {
@@ -166,7 +197,7 @@ export const loadLocal = async (sources: PluginSource[], runtime?: RuntimeInfo):
166
197
  categories[word] = description;
167
198
  owner[word] = source.spec;
168
199
  }
169
- loaded.push({ key: basename(source.path, extname(source.path)), source, raw: mod.default });
200
+ loaded.push({ key: keyOf(source), source, raw: mod.default });
170
201
  } catch (err) {
171
202
  fail(err);
172
203
  }
package/src/project.ts CHANGED
@@ -43,6 +43,8 @@ export interface SetupOptions {
43
43
  }
44
44
 
45
45
  export interface Setup {
46
+ // The project root the config and plugins were found from.
47
+ root: string;
46
48
  registry: Registry;
47
49
  skipped: string[];
48
50
  // Plugins left out because they failed. Empty unless the run was lenient or collecting.
@@ -84,5 +86,5 @@ export const setUp = async ({
84
86
  // rather than failing the run. A built-in id never is.
85
87
  ignoreUnknownPluginIds: mode !== "strict",
86
88
  });
87
- return { registry, skipped: plugins.skipped, failures: plugins.failures };
89
+ return { root, registry, skipped: plugins.skipped, failures: plugins.failures };
88
90
  };
@@ -3,6 +3,11 @@ import { RULE } from "./types.ts";
3
3
 
4
4
  export const MAX_NESTING = 3;
5
5
 
6
+ // Reads max_subgraph_depth only. A mindmap, treemap, treeView or ishikawa
7
+ // diagram reports its depth as max_tree_depth instead, because it draws no
8
+ // subgraphs and flattening them is no remedy for a deep tree. Its depth still
9
+ // weighs on the visual complexity score (calculateComplexity in metrics.ts).
10
+
6
11
  export const complexitySubgraphNestingTooDeep = complexityRule({
7
12
  id: RULE.NESTING,
8
13
  summary: "subgraphs nested three or more levels deep",
@@ -20,7 +20,7 @@ export const parseFenceUnterminated: Rule = {
20
20
  d,
21
21
  RULE.FENCE_UNTERMINATED,
22
22
  `mermaid fence opened at line ${d.startLine} is never closed`,
23
- "Add a closing fence after the last line of the diagram.",
23
+ "Add a closing fence directly after the last line of the diagram, with the opening line's prefix: `> ` in a quote, the item's indent in a list.",
24
24
  ),
25
25
  ),
26
26
  fix: (ctx) => {