@jpeakai/mermaidjs-lint 0.3.0 → 0.4.1

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/dist/testing.js CHANGED
@@ -1,6 +1,82 @@
1
1
  import { createRequire } from "node:module";
2
2
  var __require = /* @__PURE__ */ createRequire(import.meta.url);
3
3
 
4
+ // src/errors.ts
5
+ class MermaidLintError extends Error {
6
+ code;
7
+ constructor(code, message) {
8
+ super(message);
9
+ this.code = code;
10
+ this.name = new.target.name;
11
+ }
12
+ }
13
+
14
+ class UsageError extends MermaidLintError {
15
+ constructor(message, code = "usage") {
16
+ super(code, message);
17
+ }
18
+ }
19
+
20
+ class FlagError extends UsageError {
21
+ flag;
22
+ constructor(flag, message) {
23
+ super(message, "flag");
24
+ this.flag = flag;
25
+ }
26
+ }
27
+ class UnknownRuleError extends UsageError {
28
+ file;
29
+ rule;
30
+ known;
31
+ constructor(file, rule, known) {
32
+ super(`${file}: unknown rule "${rule}"; a retired or misspelt id cannot be switched off. Known rules: ${known.join(", ")}`, "unknown-rule");
33
+ this.file = file;
34
+ this.rule = rule;
35
+ this.known = known;
36
+ }
37
+ }
38
+
39
+ class RuleOptionError extends UsageError {
40
+ file;
41
+ rule;
42
+ option;
43
+ constructor(file, rule, option, message) {
44
+ super(`${file}: ${message}`, "rule-option");
45
+ this.file = file;
46
+ this.rule = rule;
47
+ this.option = option;
48
+ }
49
+ }
50
+ class FixNotConvergingError extends MermaidLintError {
51
+ file;
52
+ rules;
53
+ constructor(file, passes, rules) {
54
+ super("fix-not-converging", `${file}: --fix did not converge after ${passes} passes; ${rules.join(", ")} kept proposing edits. A fixer must propose nothing once its edit is applied`);
55
+ this.file = file;
56
+ this.rules = rules;
57
+ }
58
+ }
59
+
60
+ class PluginLoadError extends MermaidLintError {
61
+ source;
62
+ constructor(source, reason, code = "plugin") {
63
+ super(code, `plugin ${source}: ${reason}`);
64
+ this.source = source;
65
+ }
66
+ }
67
+ class PluginContractError extends PluginLoadError {
68
+ constructor(source, reason) {
69
+ super(source, reason, "plugin-contract");
70
+ }
71
+ }
72
+ class PluginFixerError extends MermaidLintError {
73
+ rule;
74
+ constructor(rule, reason) {
75
+ super("plugin-fixer", `plugin rule ${rule} ${reason}`);
76
+ this.rule = rule;
77
+ }
78
+ }
79
+
4
80
  // src/model.ts
5
81
  import { fromMarkdown } from "mdast-util-from-markdown";
6
82
  import { frontmatterFromMarkdown } from "mdast-util-frontmatter";
@@ -203,6 +279,7 @@ async function extractStats(content) {
203
279
  edges: 0,
204
280
  subgraphs: 0,
205
281
  max_subgraph_depth: 0,
282
+ max_tree_depth: 0,
206
283
  node_ids: [],
207
284
  subgraph_names: [],
208
285
  parser_used: langium ? "langium" : "mermaid-core",
@@ -252,6 +329,7 @@ async function extractLangiumStats(content, keyword) {
252
329
  const subgraphNames = [];
253
330
  let edges = 0;
254
331
  let maxDepth = 0;
332
+ let treeDepth = 0;
255
333
  switch (mapped) {
256
334
  case "architecture": {
257
335
  const services = ast.services ?? [];
@@ -298,7 +376,7 @@ async function extractLangiumStats(content, keyword) {
298
376
  const stats = indentTreeStats(nodes.map((n, i) => ({ label: n.name ?? `node_${i}`, indent: n.indent ?? 0 })));
299
377
  nodeIds.push(...stats.labels);
300
378
  edges = stats.edges;
301
- maxDepth = stats.depth;
379
+ treeDepth = stats.depth;
302
380
  break;
303
381
  }
304
382
  case "treemap": {
@@ -306,7 +384,7 @@ async function extractLangiumStats(content, keyword) {
306
384
  const stats = indentTreeStats(rows.map((r, i) => ({ label: r.item?.name ?? `row_${i}`, indent: r.indent ?? 0 })));
307
385
  nodeIds.push(...stats.labels);
308
386
  edges = stats.edges;
309
- maxDepth = stats.depth;
387
+ treeDepth = stats.depth;
310
388
  break;
311
389
  }
312
390
  case "wardley": {
@@ -326,6 +404,7 @@ async function extractLangiumStats(content, keyword) {
326
404
  edges,
327
405
  subgraphs: subgraphNames.length,
328
406
  max_subgraph_depth: maxDepth,
407
+ max_tree_depth: treeDepth,
329
408
  node_ids: [...new Set(nodeIds.filter(Boolean))].sort(),
330
409
  subgraph_names: subgraphNames,
331
410
  parser_used: "langium",
@@ -376,12 +455,13 @@ async function extractCoreStats(content, keyword) {
376
455
  if (!adapter) {
377
456
  throw new Error(`no core adapter for diagram.type="${diagramType}" (keyword="${keyword}")`);
378
457
  }
379
- const { nodeIds, edges, subgraphNames, maxDepth } = adapter(diagram.db);
458
+ const { nodeIds, edges, subgraphNames, maxDepth, treeDepth = 0 } = adapter(diagram.db);
380
459
  return {
381
460
  nodes: nodeIds.length,
382
461
  edges,
383
462
  subgraphs: subgraphNames.length,
384
463
  max_subgraph_depth: maxDepth,
464
+ max_tree_depth: treeDepth,
385
465
  node_ids: [...nodeIds].sort(),
386
466
  subgraph_names: subgraphNames,
387
467
  parser_used: "mermaid-core",
@@ -392,6 +472,7 @@ async function extractCoreStats(content, keyword) {
392
472
  var CORE_ADAPTERS = {
393
473
  "flowchart-v2": adaptFlowchart,
394
474
  flowchart: adaptFlowchart,
475
+ "flowchart-elk": adaptFlowchart,
395
476
  graph: adaptFlowchart,
396
477
  classDiagram: adaptClass,
397
478
  "classDiagram-v2": adaptClass,
@@ -515,7 +596,13 @@ function adaptIshikawa(db) {
515
596
  walk(c, depth + 1);
516
597
  };
517
598
  walk(root, 0);
518
- return { nodeIds: labels, edges: Math.max(0, labels.length - 1), subgraphNames: [], maxDepth };
599
+ return {
600
+ nodeIds: labels,
601
+ edges: Math.max(0, labels.length - 1),
602
+ subgraphNames: [],
603
+ maxDepth: 0,
604
+ treeDepth: maxDepth
605
+ };
519
606
  }
520
607
  function adaptKanban(db) {
521
608
  const data = callGetter(db, "getData");
@@ -543,7 +630,13 @@ function adaptMindmap(db) {
543
630
  walk(c, depth + 1);
544
631
  };
545
632
  walk(root, 0);
546
- return { nodeIds: labels, edges: Math.max(0, labels.length - 1), subgraphNames: [], maxDepth };
633
+ return {
634
+ nodeIds: labels,
635
+ edges: Math.max(0, labels.length - 1),
636
+ subgraphNames: [],
637
+ maxDepth: 0,
638
+ treeDepth: maxDepth
639
+ };
547
640
  }
548
641
  function adaptQuadrantChart(db) {
549
642
  const data = callGetter(db, "getQuadrantData");
@@ -759,74 +852,6 @@ var buildDocModel = (src, kind = "markdown") => {
759
852
  return { src, kind, tree, diagrams };
760
853
  };
761
854
 
762
- // src/errors.ts
763
- class MermaidLintError extends Error {
764
- code;
765
- constructor(code, message) {
766
- super(message);
767
- this.code = code;
768
- this.name = new.target.name;
769
- }
770
- }
771
-
772
- class UsageError extends MermaidLintError {
773
- constructor(message, code = "usage") {
774
- super(code, message);
775
- }
776
- }
777
- class UnknownRuleError extends UsageError {
778
- file;
779
- rule;
780
- known;
781
- constructor(file, rule, known) {
782
- super(`${file}: unknown rule "${rule}"; a retired or misspelt id cannot be switched off. Known rules: ${known.join(", ")}`, "unknown-rule");
783
- this.file = file;
784
- this.rule = rule;
785
- this.known = known;
786
- }
787
- }
788
-
789
- class RuleOptionError extends UsageError {
790
- file;
791
- rule;
792
- option;
793
- constructor(file, rule, option, message) {
794
- super(`${file}: ${message}`, "rule-option");
795
- this.file = file;
796
- this.rule = rule;
797
- this.option = option;
798
- }
799
- }
800
- class FixNotConvergingError extends MermaidLintError {
801
- file;
802
- rules;
803
- constructor(file, passes, rules) {
804
- super("fix-not-converging", `${file}: --fix did not converge after ${passes} passes; ${rules.join(", ")} kept proposing edits. A fixer must propose nothing once its edit is applied`);
805
- this.file = file;
806
- this.rules = rules;
807
- }
808
- }
809
-
810
- class PluginLoadError extends MermaidLintError {
811
- source;
812
- constructor(source, reason, code = "plugin") {
813
- super(code, `plugin ${source}: ${reason}`);
814
- this.source = source;
815
- }
816
- }
817
- class PluginContractError extends PluginLoadError {
818
- constructor(source, reason) {
819
- super(source, reason, "plugin-contract");
820
- }
821
- }
822
- class PluginFixerError extends MermaidLintError {
823
- rule;
824
- constructor(rule, reason) {
825
- super("plugin-fixer", `plugin rule ${rule} ${reason}`);
826
- this.rule = rule;
827
- }
828
- }
829
-
830
855
  // src/parse/contrast.ts
831
856
  import { existsSync } from "node:fs";
832
857
  import { dirname, join, resolve } from "node:path";
@@ -1168,7 +1193,7 @@ var calculateComplexity = (stats, config) => {
1168
1193
  const n = stats.nodes;
1169
1194
  const e = stats.edges;
1170
1195
  const s = stats.subgraphs;
1171
- const d = stats.max_subgraph_depth;
1196
+ const d = Math.max(stats.max_subgraph_depth, stats.max_tree_depth);
1172
1197
  const base = n + e * config.edge_weight + s * config.subgraph_weight;
1173
1198
  const vcs = base * (1 + d * config.depth_weight);
1174
1199
  const maxEdges = n > 1 ? n * (n - 1) : 0;
@@ -1505,7 +1530,7 @@ var parseFenceUnterminated = {
1505
1530
  id: RULE.FENCE_UNTERMINATED,
1506
1531
  category: "parse",
1507
1532
  summary: "a mermaid fence that is never closed",
1508
- check: (ctx) => ctx.doc.diagrams.filter((d) => !d.terminated).map((d) => finding(ctx, d, RULE.FENCE_UNTERMINATED, `mermaid fence opened at line ${d.startLine} is never closed`, "Add a closing fence after the last line of the diagram.")),
1533
+ check: (ctx) => ctx.doc.diagrams.filter((d) => !d.terminated).map((d) => finding(ctx, d, RULE.FENCE_UNTERMINATED, `mermaid fence opened at line ${d.startLine} is never closed`, "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.")),
1509
1534
  fix: (ctx) => {
1510
1535
  const edits = [];
1511
1536
  const { src } = ctx.doc;
@@ -1621,6 +1646,15 @@ var buildRegistry = (input) => {
1621
1646
  var BUILTIN = buildRegistry({ rules: RULES, fixOrder: FIX_ORDER, categories: BUILTIN_CATEGORIES });
1622
1647
 
1623
1648
  // src/check.ts
1649
+ var checkRunFlags = (flags, prefix = "") => {
1650
+ const { preset, profile } = flags;
1651
+ if (preset !== undefined && canonicalPreset(preset) === null) {
1652
+ throw new FlagError(`${prefix}preset`, `${prefix}preset must be one of ${PRESET_NAMES.join(", ")}, not "${preset}"`);
1653
+ }
1654
+ if (profile !== undefined && !PROFILES.includes(profile)) {
1655
+ throw new FlagError(`${prefix}profile`, `${prefix}profile must be one of ${PROFILES.join(", ")}, not "${profile}"`);
1656
+ }
1657
+ };
1624
1658
  var optionsFor = (rule, configured, flags) => {
1625
1659
  const options = { ...configured };
1626
1660
  for (const name of ["preset", "profile"]) {
@@ -1631,6 +1665,7 @@ var optionsFor = (rule, configured, flags) => {
1631
1665
  return options;
1632
1666
  };
1633
1667
  var checkModel = async (doc, file, flags = {}, registry = BUILTIN) => {
1668
+ checkRunFlags(flags);
1634
1669
  const perRule = registry.rules.map((rule) => rule.check({ doc, file, options: optionsFor(rule, registry.options.get(rule.id), flags), helpers: HELPERS }));
1635
1670
  const findings = (await Promise.all(perRule)).flat();
1636
1671
  return findings.sort((a, b) => a.line - b.line || a.rule.localeCompare(b.rule));
@@ -1669,26 +1704,40 @@ var sameDiagrams = async (before, after) => {
1669
1704
  if (!now || was.keyword !== now.keyword)
1670
1705
  return false;
1671
1706
  const [a, b] = await Promise.all([was.stats(), now.stats()]);
1672
- const shape = (s) => [
1707
+ const shape = (s, source) => [
1708
+ parseFailed(source, s),
1673
1709
  s.diagram_type,
1674
1710
  s.nodes,
1675
1711
  s.edges,
1676
1712
  s.subgraphs,
1677
1713
  s.max_subgraph_depth,
1714
+ s.max_tree_depth,
1678
1715
  s.node_ids,
1679
1716
  s.subgraph_names
1680
1717
  ];
1681
- if (!sameJson(shape(a), shape(b)))
1718
+ if (!sameJson(shape(a, was.source), shape(b, now.source)))
1682
1719
  return false;
1683
1720
  }
1684
1721
  return true;
1685
1722
  };
1723
+ var breaksAParse = async (before, after) => {
1724
+ for (const [i, was] of before.diagrams.entries()) {
1725
+ const now = after.diagrams[i];
1726
+ if (!now)
1727
+ continue;
1728
+ const [a, b] = await Promise.all([was.stats(), now.stats()]);
1729
+ if (!parseFailed(was.source, a) && parseFailed(now.source, b))
1730
+ return true;
1731
+ }
1732
+ return false;
1733
+ };
1686
1734
  var verify = async (edit, before, after) => {
1687
1735
  if (edit.expect.kind === "same-tree") {
1688
1736
  if (!sameTree(before.tree, after.tree, {}))
1689
1737
  return "refuse";
1690
- if (before.kind === "markdown" && after.kind === "markdown")
1691
- return "accept";
1738
+ if (before.kind === "markdown" && after.kind === "markdown") {
1739
+ return await breaksAParse(before, after) ? "refuse" : "accept";
1740
+ }
1692
1741
  }
1693
1742
  return await sameDiagrams(before, after) ? "accept" : "refuse";
1694
1743
  };
@@ -1708,6 +1757,7 @@ var overlaps = (edits) => {
1708
1757
  };
1709
1758
  var wellFormed = (e, length) => Number.isInteger(e.start) && Number.isInteger(e.end) && e.start >= 0 && e.start <= e.end && e.end <= length && typeof e.text === "string";
1710
1759
  var fixSourceReport = async (src, file, flags = {}, registry = BUILTIN) => {
1760
+ checkRunFlags(flags);
1711
1761
  const kind = documentKind(file);
1712
1762
  const applied = [];
1713
1763
  const refused = [];
@@ -5,6 +5,7 @@ export interface RunFlags {
5
5
  preset?: string;
6
6
  profile?: string;
7
7
  }
8
+ export declare const checkRunFlags: (flags: RunFlags, prefix?: string) => void;
8
9
  export declare const optionsFor: (rule: Rule, configured: RuleOptions | undefined, flags: RunFlags) => RuleOptions;
9
10
  export declare const checkModel: (doc: DocModel, file: string, flags?: RunFlags, registry?: Registry) => Promise<Finding[]>;
10
11
  export declare const checkMarkdown: (src: string, file: string, flags?: RunFlags, registry?: Registry) => Promise<Finding[]>;
@@ -3,6 +3,7 @@ export interface DiagramStats {
3
3
  edges: number;
4
4
  subgraphs: number;
5
5
  max_subgraph_depth: number;
6
+ max_tree_depth: number;
6
7
  node_ids: string[];
7
8
  subgraph_names: string[];
8
9
  parser_used: "langium" | "mermaid-core";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jpeakai/mermaidjs-lint",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "type": "module",
5
5
  "description": "Deterministic lint gates and verified fixers for Mermaid diagrams in markdown",
6
6
  "license": "MIT",
package/src/check.ts CHANGED
@@ -4,7 +4,10 @@
4
4
  // them. A rule may be synchronous or return a promise, so a rule that waits on
5
5
  // a tool or a service overlaps with the others.
6
6
 
7
+ import { FlagError } from "./errors.ts";
7
8
  import { buildDocModel, type DocModel, documentKind } from "./model.ts";
9
+ import { PROFILES } from "./parse/contrast.ts";
10
+ import { canonicalPreset, PRESET_NAMES } from "./parse/metrics.ts";
8
11
  import { BUILTIN, HELPERS, type Registry } from "./rules/registry.ts";
9
12
  import type { Finding, Rule, RuleOptions } from "./rules/types.ts";
10
13
 
@@ -15,6 +18,22 @@ export interface RunFlags {
15
18
  profile?: string;
16
19
  }
17
20
 
21
+ // A preset or profile that no rule can take fails as a usage error, with the
22
+ // code the command gives a bad flag, so a library caller can branch on it. The
23
+ // command passes "--" as the prefix, so the message names its own flag.
24
+ export const checkRunFlags = (flags: RunFlags, prefix = ""): void => {
25
+ const { preset, profile } = flags;
26
+ if (preset !== undefined && canonicalPreset(preset) === null) {
27
+ throw new FlagError(
28
+ `${prefix}preset`,
29
+ `${prefix}preset must be one of ${PRESET_NAMES.join(", ")}, not "${preset}"`,
30
+ );
31
+ }
32
+ if (profile !== undefined && !PROFILES.includes(profile as (typeof PROFILES)[number])) {
33
+ throw new FlagError(`${prefix}profile`, `${prefix}profile must be one of ${PROFILES.join(", ")}, not "${profile}"`);
34
+ }
35
+ };
36
+
18
37
  // The options a rule runs with: its configured options, then a flag for any
19
38
  // option the rule declares.
20
39
  export const optionsFor = (rule: Rule, configured: RuleOptions | undefined, flags: RunFlags): RuleOptions => {
@@ -32,6 +51,7 @@ export const checkModel = async (
32
51
  flags: RunFlags = {},
33
52
  registry: Registry = BUILTIN,
34
53
  ): Promise<Finding[]> => {
54
+ checkRunFlags(flags);
35
55
  const perRule = registry.rules.map((rule) =>
36
56
  rule.check({ doc, file, options: optionsFor(rule, registry.options.get(rule.id), flags), helpers: HELPERS }),
37
57
  );
package/src/cli.ts CHANGED
@@ -5,13 +5,13 @@ import { lstat, readFile, realpath, writeFile } from "node:fs/promises";
5
5
  import { isAbsolute, relative, resolve } from "node:path";
6
6
  import { parseArgs } from "node:util";
7
7
  import pkg from "../package.json" with { type: "json" };
8
- import { checkModel, type RunFlags } from "./check.ts";
8
+ import { checkModel, checkRunFlags, type RunFlags } from "./check.ts";
9
9
  import { FixOutsideRootError, FlagError, MermaidLintError, UsageError } from "./errors.ts";
10
10
  import { collectFiles, DIAGRAM_FILE_EXTS, SKIP_DIRS } from "./files.ts";
11
11
  import { fixSourceReport } from "./fix/engine.ts";
12
12
  import { buildDocModel, DIAGRAM_EXTS, documentKind } from "./model.ts";
13
13
  import { PROFILES } from "./parse/contrast.ts";
14
- import { canonicalPreset, PRESET_NAMES } from "./parse/metrics.ts";
14
+ import { PRESET_NAMES } from "./parse/metrics.ts";
15
15
  import type { PluginFailure } from "./plugins/index.ts";
16
16
  import { setUp } from "./project.ts";
17
17
  import { RULES } from "./rules/index.ts";
@@ -52,6 +52,7 @@ Examples of every rule, with fixes: https://jpeakai.github.io/jpai-mermaidjs-lin
52
52
  numeric threshold set in the config overrides both.
53
53
  --profile NAME sets how contrast is read: ${PROFILES.join(", ")}. It overrides a profile set in the config.
54
54
  auto reads mkdocs-material when any parent folder holds mkdocs.yml or mkdocs.yaml, else github.
55
+ --json prints one JSON object instead of text lines.
55
56
  --fix applies every fix it can prove safe, then reports what remains.
56
57
  A fixer that is unsure leaves the text alone and the finding stays.
57
58
  --config FILE reads that config instead of mermaidjs-lint.config.json or .mjs at the project root.
@@ -220,12 +221,7 @@ const run = async (argv: string[], cwd: string): Promise<number> => {
220
221
  }
221
222
  const listing = values["list-rules"];
222
223
  const validating = values["validate-plugins"];
223
- if (values.preset !== undefined && canonicalPreset(values.preset) === null) {
224
- throw new FlagError("--preset", `--preset must be one of ${PRESET_NAMES.join(", ")}, not "${values.preset}"`);
225
- }
226
- if (values.profile !== undefined && !PROFILES.includes(values.profile as (typeof PROFILES)[number])) {
227
- throw new FlagError("--profile", `--profile must be one of ${PROFILES.join(", ")}, not "${values.profile}"`);
228
- }
224
+ checkRunFlags({ preset: values.preset, profile: values.profile }, "--");
229
225
  if (values["no-plugins"] && values["lenient-plugins"]) {
230
226
  throw new FlagError("--lenient-plugins", "--no-plugins and --lenient-plugins contradict each other");
231
227
  }
package/src/errors.ts CHANGED
@@ -138,7 +138,7 @@ export class FixOutsideRootError extends UsageError {
138
138
 
139
139
  constructor(file: string, target: string, root: string) {
140
140
  super(
141
- `${file} is a symbolic link to ${target}, outside the project root ${root}; --fix does not write through it. Fix the target in its own project, or name it directly`,
141
+ `${file} is a symbolic link to ${target}, outside the project root ${root}; --fix does not write through it. Fix the target in its own project, or run on the target path itself`,
142
142
  "fix-outside-root",
143
143
  );
144
144
  this.file = file;
package/src/fix/engine.ts CHANGED
@@ -3,7 +3,7 @@
3
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
- import { optionsFor, type RunFlags } from "../check.ts";
6
+ import { checkRunFlags, optionsFor, type RunFlags } from "../check.ts";
7
7
  import { FixNotConvergingError } from "../errors.ts";
8
8
  import { buildDocModel, documentKind } from "../model.ts";
9
9
  import { BUILTIN, HELPERS, type Registry } from "../rules/registry.ts";
@@ -46,6 +46,7 @@ export const fixSourceReport = async (
46
46
  flags: RunFlags = {},
47
47
  registry: Registry = BUILTIN,
48
48
  ): Promise<FixReport> => {
49
+ checkRunFlags(flags);
49
50
  const kind = documentKind(file);
50
51
  const applied: Edit[] = [];
51
52
  const refused: Edit[] = [];
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,28 +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
+
60
78
  // A raw diagram file (.mmd, .mermaid) has no markdown around its diagram, so
61
79
  // its tree is an empty root and comparing trees would prove nothing. There,
62
80
  // same-tree also demands what same-diagrams does: the diagram is still there
63
- // and parses to the same structure.
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.
64
85
  export const verify = async (edit: Edit, before: DocModel, after: DocModel): Promise<Verdict> => {
65
86
  if (edit.expect.kind === "same-tree") {
66
87
  if (!sameTree(before.tree, after.tree, {})) return "refuse";
67
- if (before.kind === "markdown" && after.kind === "markdown") return "accept";
88
+ if (before.kind === "markdown" && after.kind === "markdown") {
89
+ return (await breaksAParse(before, after)) ? "refuse" : "accept";
90
+ }
68
91
  }
69
92
  return (await sameDiagrams(before, after)) ? "accept" : "refuse";
70
93
  };
@@ -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,7 +618,13 @@ 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
 
602
630
  type QuadrantText = { text?: string } | string | undefined;