@jpeakai/mermaidjs-lint 0.3.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.
package/README.md CHANGED
@@ -5,8 +5,9 @@ Deterministic checks and verified fixes for Mermaid diagrams in markdown.
5
5
 
6
6
  **[Docs](https://jpeakai.github.io/jpai-mermaidjs-lint/)** | **[Rules](https://jpeakai.github.io/jpai-mermaidjs-lint/reference/rules/)** | **[Changelog](CHANGELOG.md)** | **[GitHub](https://github.com/jpeakai/jpai-mermaidjs-lint)** | **[npm](https://www.npmjs.com/package/@jpeakai/mermaidjs-lint)**
7
7
 
8
- This README and the docs site describe the `main` branch.
9
- A feature on `main` but not yet on npm is listed under `Unreleased` in the [changelog](CHANGELOG.md).
8
+ The docs site describes the latest release on npm.
9
+ This README, read on GitHub, describes the `main` branch.
10
+ A change on `main` that is not yet released is listed under `Unreleased` in the [changelog](CHANGELOG.md).
10
11
 
11
12
  ## Usage
12
13
 
@@ -40,14 +41,14 @@ Three tenets are guarantees about every diagram the tool passes, and the fourth
40
41
  | Extensible | Local rules and plugin packages |
41
42
 
42
43
  The [concepts page](https://jpeakai.github.io/jpai-mermaidjs-lint/concepts/) states the limit of each tenet.
43
- `mermaidjs-lint --list-rules` prints every rule.
44
+ `bunx @jpeakai/mermaidjs-lint --list-rules` prints every rule.
44
45
  The [rules page](https://jpeakai.github.io/jpai-mermaidjs-lint/reference/rules/) draws each one before and after `--fix`, and [RULES.md](RULES.md) holds the same examples as plain markdown.
45
46
 
46
47
  Every finding fails the run, and a rule you do not want is switched off in the config.
47
48
  Node, edge and subgraph counts come from mermaid's own parsers.
48
49
  Style directives are read line by line, so a style joined to other statements by a semicolon is not seen.
49
50
  Every fix is verified by parsing the document again, and a fix that fails the check is refused, so the finding stays for a human.
50
- The check holds the markdown outside the diagrams unchanged, and each diagram's type, node ids, subgraph names and counts.
51
+ The check holds the markdown outside the diagrams unchanged, and each diagram's type, node ids, subgraph names and counts, and whether it parses.
51
52
  It does not compare edge endpoints, edge labels or node text, so review the diff a fix writes.
52
53
 
53
54
  ## Requirements and compatibility
package/dist/bin.js CHANGED
@@ -9,7 +9,7 @@ import { parseArgs } from "node:util";
9
9
  // package.json
10
10
  var package_default = {
11
11
  name: "@jpeakai/mermaidjs-lint",
12
- version: "0.3.0",
12
+ version: "0.4.0",
13
13
  type: "module",
14
14
  description: "Deterministic lint gates and verified fixers for Mermaid diagrams in markdown",
15
15
  license: "MIT",
@@ -281,6 +281,7 @@ async function extractStats(content) {
281
281
  edges: 0,
282
282
  subgraphs: 0,
283
283
  max_subgraph_depth: 0,
284
+ max_tree_depth: 0,
284
285
  node_ids: [],
285
286
  subgraph_names: [],
286
287
  parser_used: langium ? "langium" : "mermaid-core",
@@ -330,6 +331,7 @@ async function extractLangiumStats(content, keyword) {
330
331
  const subgraphNames = [];
331
332
  let edges = 0;
332
333
  let maxDepth = 0;
334
+ let treeDepth = 0;
333
335
  switch (mapped) {
334
336
  case "architecture": {
335
337
  const services = ast.services ?? [];
@@ -376,7 +378,7 @@ async function extractLangiumStats(content, keyword) {
376
378
  const stats = indentTreeStats(nodes.map((n, i) => ({ label: n.name ?? `node_${i}`, indent: n.indent ?? 0 })));
377
379
  nodeIds.push(...stats.labels);
378
380
  edges = stats.edges;
379
- maxDepth = stats.depth;
381
+ treeDepth = stats.depth;
380
382
  break;
381
383
  }
382
384
  case "treemap": {
@@ -384,7 +386,7 @@ async function extractLangiumStats(content, keyword) {
384
386
  const stats = indentTreeStats(rows.map((r, i) => ({ label: r.item?.name ?? `row_${i}`, indent: r.indent ?? 0 })));
385
387
  nodeIds.push(...stats.labels);
386
388
  edges = stats.edges;
387
- maxDepth = stats.depth;
389
+ treeDepth = stats.depth;
388
390
  break;
389
391
  }
390
392
  case "wardley": {
@@ -404,6 +406,7 @@ async function extractLangiumStats(content, keyword) {
404
406
  edges,
405
407
  subgraphs: subgraphNames.length,
406
408
  max_subgraph_depth: maxDepth,
409
+ max_tree_depth: treeDepth,
407
410
  node_ids: [...new Set(nodeIds.filter(Boolean))].sort(),
408
411
  subgraph_names: subgraphNames,
409
412
  parser_used: "langium",
@@ -454,12 +457,13 @@ async function extractCoreStats(content, keyword) {
454
457
  if (!adapter) {
455
458
  throw new Error(`no core adapter for diagram.type="${diagramType}" (keyword="${keyword}")`);
456
459
  }
457
- const { nodeIds, edges, subgraphNames, maxDepth } = adapter(diagram.db);
460
+ const { nodeIds, edges, subgraphNames, maxDepth, treeDepth = 0 } = adapter(diagram.db);
458
461
  return {
459
462
  nodes: nodeIds.length,
460
463
  edges,
461
464
  subgraphs: subgraphNames.length,
462
465
  max_subgraph_depth: maxDepth,
466
+ max_tree_depth: treeDepth,
463
467
  node_ids: [...nodeIds].sort(),
464
468
  subgraph_names: subgraphNames,
465
469
  parser_used: "mermaid-core",
@@ -470,6 +474,7 @@ async function extractCoreStats(content, keyword) {
470
474
  var CORE_ADAPTERS = {
471
475
  "flowchart-v2": adaptFlowchart,
472
476
  flowchart: adaptFlowchart,
477
+ "flowchart-elk": adaptFlowchart,
473
478
  graph: adaptFlowchart,
474
479
  classDiagram: adaptClass,
475
480
  "classDiagram-v2": adaptClass,
@@ -593,7 +598,13 @@ function adaptIshikawa(db) {
593
598
  walk(c, depth + 1);
594
599
  };
595
600
  walk(root, 0);
596
- return { nodeIds: labels, edges: Math.max(0, labels.length - 1), subgraphNames: [], maxDepth };
601
+ return {
602
+ nodeIds: labels,
603
+ edges: Math.max(0, labels.length - 1),
604
+ subgraphNames: [],
605
+ maxDepth: 0,
606
+ treeDepth: maxDepth
607
+ };
597
608
  }
598
609
  function adaptKanban(db) {
599
610
  const data = callGetter(db, "getData");
@@ -621,7 +632,13 @@ function adaptMindmap(db) {
621
632
  walk(c, depth + 1);
622
633
  };
623
634
  walk(root, 0);
624
- return { nodeIds: labels, edges: Math.max(0, labels.length - 1), subgraphNames: [], maxDepth };
635
+ return {
636
+ nodeIds: labels,
637
+ edges: Math.max(0, labels.length - 1),
638
+ subgraphNames: [],
639
+ maxDepth: 0,
640
+ treeDepth: maxDepth
641
+ };
625
642
  }
626
643
  function adaptQuadrantChart(db) {
627
644
  const data = callGetter(db, "getQuadrantData");
@@ -941,7 +958,7 @@ class FixOutsideRootError extends UsageError {
941
958
  file;
942
959
  target;
943
960
  constructor(file, target, root) {
944
- super(`${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`, "fix-outside-root");
961
+ super(`${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`, "fix-outside-root");
945
962
  this.file = file;
946
963
  this.target = target;
947
964
  }
@@ -1356,7 +1373,7 @@ var calculateComplexity = (stats, config) => {
1356
1373
  const n = stats.nodes;
1357
1374
  const e = stats.edges;
1358
1375
  const s = stats.subgraphs;
1359
- const d = stats.max_subgraph_depth;
1376
+ const d = Math.max(stats.max_subgraph_depth, stats.max_tree_depth);
1360
1377
  const base = n + e * config.edge_weight + s * config.subgraph_weight;
1361
1378
  const vcs = base * (1 + d * config.depth_weight);
1362
1379
  const maxEdges = n > 1 ? n * (n - 1) : 0;
@@ -1694,7 +1711,7 @@ var parseFenceUnterminated = {
1694
1711
  id: RULE.FENCE_UNTERMINATED,
1695
1712
  category: "parse",
1696
1713
  summary: "a mermaid fence that is never closed",
1697
- 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.")),
1714
+ 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.")),
1698
1715
  fix: (ctx) => {
1699
1716
  const edits = [];
1700
1717
  const { src } = ctx.doc;
@@ -1917,26 +1934,40 @@ var sameDiagrams = async (before, after) => {
1917
1934
  if (!now || was.keyword !== now.keyword)
1918
1935
  return false;
1919
1936
  const [a, b] = await Promise.all([was.stats(), now.stats()]);
1920
- const shape = (s) => [
1937
+ const shape = (s, source) => [
1938
+ parseFailed(source, s),
1921
1939
  s.diagram_type,
1922
1940
  s.nodes,
1923
1941
  s.edges,
1924
1942
  s.subgraphs,
1925
1943
  s.max_subgraph_depth,
1944
+ s.max_tree_depth,
1926
1945
  s.node_ids,
1927
1946
  s.subgraph_names
1928
1947
  ];
1929
- if (!sameJson(shape(a), shape(b)))
1948
+ if (!sameJson(shape(a, was.source), shape(b, now.source)))
1930
1949
  return false;
1931
1950
  }
1932
1951
  return true;
1933
1952
  };
1953
+ var breaksAParse = async (before, after) => {
1954
+ for (const [i, was] of before.diagrams.entries()) {
1955
+ const now = after.diagrams[i];
1956
+ if (!now)
1957
+ continue;
1958
+ const [a, b] = await Promise.all([was.stats(), now.stats()]);
1959
+ if (!parseFailed(was.source, a) && parseFailed(now.source, b))
1960
+ return true;
1961
+ }
1962
+ return false;
1963
+ };
1934
1964
  var verify = async (edit, before, after) => {
1935
1965
  if (edit.expect.kind === "same-tree") {
1936
1966
  if (!sameTree(before.tree, after.tree, {}))
1937
1967
  return "refuse";
1938
- if (before.kind === "markdown" && after.kind === "markdown")
1939
- return "accept";
1968
+ if (before.kind === "markdown" && after.kind === "markdown") {
1969
+ return await breaksAParse(before, after) ? "refuse" : "accept";
1970
+ }
1940
1971
  }
1941
1972
  return await sameDiagrams(before, after) ? "accept" : "refuse";
1942
1973
  };
package/dist/index.js CHANGED
@@ -203,6 +203,7 @@ async function extractStats(content) {
203
203
  edges: 0,
204
204
  subgraphs: 0,
205
205
  max_subgraph_depth: 0,
206
+ max_tree_depth: 0,
206
207
  node_ids: [],
207
208
  subgraph_names: [],
208
209
  parser_used: langium ? "langium" : "mermaid-core",
@@ -252,6 +253,7 @@ async function extractLangiumStats(content, keyword) {
252
253
  const subgraphNames = [];
253
254
  let edges = 0;
254
255
  let maxDepth = 0;
256
+ let treeDepth = 0;
255
257
  switch (mapped) {
256
258
  case "architecture": {
257
259
  const services = ast.services ?? [];
@@ -298,7 +300,7 @@ async function extractLangiumStats(content, keyword) {
298
300
  const stats = indentTreeStats(nodes.map((n, i) => ({ label: n.name ?? `node_${i}`, indent: n.indent ?? 0 })));
299
301
  nodeIds.push(...stats.labels);
300
302
  edges = stats.edges;
301
- maxDepth = stats.depth;
303
+ treeDepth = stats.depth;
302
304
  break;
303
305
  }
304
306
  case "treemap": {
@@ -306,7 +308,7 @@ async function extractLangiumStats(content, keyword) {
306
308
  const stats = indentTreeStats(rows.map((r, i) => ({ label: r.item?.name ?? `row_${i}`, indent: r.indent ?? 0 })));
307
309
  nodeIds.push(...stats.labels);
308
310
  edges = stats.edges;
309
- maxDepth = stats.depth;
311
+ treeDepth = stats.depth;
310
312
  break;
311
313
  }
312
314
  case "wardley": {
@@ -326,6 +328,7 @@ async function extractLangiumStats(content, keyword) {
326
328
  edges,
327
329
  subgraphs: subgraphNames.length,
328
330
  max_subgraph_depth: maxDepth,
331
+ max_tree_depth: treeDepth,
329
332
  node_ids: [...new Set(nodeIds.filter(Boolean))].sort(),
330
333
  subgraph_names: subgraphNames,
331
334
  parser_used: "langium",
@@ -376,12 +379,13 @@ async function extractCoreStats(content, keyword) {
376
379
  if (!adapter) {
377
380
  throw new Error(`no core adapter for diagram.type="${diagramType}" (keyword="${keyword}")`);
378
381
  }
379
- const { nodeIds, edges, subgraphNames, maxDepth } = adapter(diagram.db);
382
+ const { nodeIds, edges, subgraphNames, maxDepth, treeDepth = 0 } = adapter(diagram.db);
380
383
  return {
381
384
  nodes: nodeIds.length,
382
385
  edges,
383
386
  subgraphs: subgraphNames.length,
384
387
  max_subgraph_depth: maxDepth,
388
+ max_tree_depth: treeDepth,
385
389
  node_ids: [...nodeIds].sort(),
386
390
  subgraph_names: subgraphNames,
387
391
  parser_used: "mermaid-core",
@@ -392,6 +396,7 @@ async function extractCoreStats(content, keyword) {
392
396
  var CORE_ADAPTERS = {
393
397
  "flowchart-v2": adaptFlowchart,
394
398
  flowchart: adaptFlowchart,
399
+ "flowchart-elk": adaptFlowchart,
395
400
  graph: adaptFlowchart,
396
401
  classDiagram: adaptClass,
397
402
  "classDiagram-v2": adaptClass,
@@ -515,7 +520,13 @@ function adaptIshikawa(db) {
515
520
  walk(c, depth + 1);
516
521
  };
517
522
  walk(root, 0);
518
- return { nodeIds: labels, edges: Math.max(0, labels.length - 1), subgraphNames: [], maxDepth };
523
+ return {
524
+ nodeIds: labels,
525
+ edges: Math.max(0, labels.length - 1),
526
+ subgraphNames: [],
527
+ maxDepth: 0,
528
+ treeDepth: maxDepth
529
+ };
519
530
  }
520
531
  function adaptKanban(db) {
521
532
  const data = callGetter(db, "getData");
@@ -543,7 +554,13 @@ function adaptMindmap(db) {
543
554
  walk(c, depth + 1);
544
555
  };
545
556
  walk(root, 0);
546
- return { nodeIds: labels, edges: Math.max(0, labels.length - 1), subgraphNames: [], maxDepth };
557
+ return {
558
+ nodeIds: labels,
559
+ edges: Math.max(0, labels.length - 1),
560
+ subgraphNames: [],
561
+ maxDepth: 0,
562
+ treeDepth: maxDepth
563
+ };
547
564
  }
548
565
  function adaptQuadrantChart(db) {
549
566
  const data = callGetter(db, "getQuadrantData");
@@ -863,7 +880,7 @@ class FixOutsideRootError extends UsageError {
863
880
  file;
864
881
  target;
865
882
  constructor(file, target, root) {
866
- super(`${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`, "fix-outside-root");
883
+ super(`${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`, "fix-outside-root");
867
884
  this.file = file;
868
885
  this.target = target;
869
886
  }
@@ -1278,7 +1295,7 @@ var calculateComplexity = (stats, config) => {
1278
1295
  const n = stats.nodes;
1279
1296
  const e = stats.edges;
1280
1297
  const s = stats.subgraphs;
1281
- const d = stats.max_subgraph_depth;
1298
+ const d = Math.max(stats.max_subgraph_depth, stats.max_tree_depth);
1282
1299
  const base = n + e * config.edge_weight + s * config.subgraph_weight;
1283
1300
  const vcs = base * (1 + d * config.depth_weight);
1284
1301
  const maxEdges = n > 1 ? n * (n - 1) : 0;
@@ -1616,7 +1633,7 @@ var parseFenceUnterminated = {
1616
1633
  id: RULE.FENCE_UNTERMINATED,
1617
1634
  category: "parse",
1618
1635
  summary: "a mermaid fence that is never closed",
1619
- 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.")),
1636
+ 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.")),
1620
1637
  fix: (ctx) => {
1621
1638
  const edits = [];
1622
1639
  const { src } = ctx.doc;
@@ -1754,7 +1771,7 @@ import { parseArgs } from "node:util";
1754
1771
  // package.json
1755
1772
  var package_default = {
1756
1773
  name: "@jpeakai/mermaidjs-lint",
1757
- version: "0.3.0",
1774
+ version: "0.4.0",
1758
1775
  type: "module",
1759
1776
  description: "Deterministic lint gates and verified fixers for Mermaid diagrams in markdown",
1760
1777
  license: "MIT",
@@ -1916,26 +1933,40 @@ var sameDiagrams = async (before, after) => {
1916
1933
  if (!now || was.keyword !== now.keyword)
1917
1934
  return false;
1918
1935
  const [a, b] = await Promise.all([was.stats(), now.stats()]);
1919
- const shape = (s) => [
1936
+ const shape = (s, source) => [
1937
+ parseFailed(source, s),
1920
1938
  s.diagram_type,
1921
1939
  s.nodes,
1922
1940
  s.edges,
1923
1941
  s.subgraphs,
1924
1942
  s.max_subgraph_depth,
1943
+ s.max_tree_depth,
1925
1944
  s.node_ids,
1926
1945
  s.subgraph_names
1927
1946
  ];
1928
- if (!sameJson(shape(a), shape(b)))
1947
+ if (!sameJson(shape(a, was.source), shape(b, now.source)))
1929
1948
  return false;
1930
1949
  }
1931
1950
  return true;
1932
1951
  };
1952
+ var breaksAParse = async (before, after) => {
1953
+ for (const [i, was] of before.diagrams.entries()) {
1954
+ const now = after.diagrams[i];
1955
+ if (!now)
1956
+ continue;
1957
+ const [a, b] = await Promise.all([was.stats(), now.stats()]);
1958
+ if (!parseFailed(was.source, a) && parseFailed(now.source, b))
1959
+ return true;
1960
+ }
1961
+ return false;
1962
+ };
1933
1963
  var verify = async (edit, before, after) => {
1934
1964
  if (edit.expect.kind === "same-tree") {
1935
1965
  if (!sameTree(before.tree, after.tree, {}))
1936
1966
  return "refuse";
1937
- if (before.kind === "markdown" && after.kind === "markdown")
1938
- return "accept";
1967
+ if (before.kind === "markdown" && after.kind === "markdown") {
1968
+ return await breaksAParse(before, after) ? "refuse" : "accept";
1969
+ }
1939
1970
  }
1940
1971
  return await sameDiagrams(before, after) ? "accept" : "refuse";
1941
1972
  };
package/dist/testing.js CHANGED
@@ -203,6 +203,7 @@ async function extractStats(content) {
203
203
  edges: 0,
204
204
  subgraphs: 0,
205
205
  max_subgraph_depth: 0,
206
+ max_tree_depth: 0,
206
207
  node_ids: [],
207
208
  subgraph_names: [],
208
209
  parser_used: langium ? "langium" : "mermaid-core",
@@ -252,6 +253,7 @@ async function extractLangiumStats(content, keyword) {
252
253
  const subgraphNames = [];
253
254
  let edges = 0;
254
255
  let maxDepth = 0;
256
+ let treeDepth = 0;
255
257
  switch (mapped) {
256
258
  case "architecture": {
257
259
  const services = ast.services ?? [];
@@ -298,7 +300,7 @@ async function extractLangiumStats(content, keyword) {
298
300
  const stats = indentTreeStats(nodes.map((n, i) => ({ label: n.name ?? `node_${i}`, indent: n.indent ?? 0 })));
299
301
  nodeIds.push(...stats.labels);
300
302
  edges = stats.edges;
301
- maxDepth = stats.depth;
303
+ treeDepth = stats.depth;
302
304
  break;
303
305
  }
304
306
  case "treemap": {
@@ -306,7 +308,7 @@ async function extractLangiumStats(content, keyword) {
306
308
  const stats = indentTreeStats(rows.map((r, i) => ({ label: r.item?.name ?? `row_${i}`, indent: r.indent ?? 0 })));
307
309
  nodeIds.push(...stats.labels);
308
310
  edges = stats.edges;
309
- maxDepth = stats.depth;
311
+ treeDepth = stats.depth;
310
312
  break;
311
313
  }
312
314
  case "wardley": {
@@ -326,6 +328,7 @@ async function extractLangiumStats(content, keyword) {
326
328
  edges,
327
329
  subgraphs: subgraphNames.length,
328
330
  max_subgraph_depth: maxDepth,
331
+ max_tree_depth: treeDepth,
329
332
  node_ids: [...new Set(nodeIds.filter(Boolean))].sort(),
330
333
  subgraph_names: subgraphNames,
331
334
  parser_used: "langium",
@@ -376,12 +379,13 @@ async function extractCoreStats(content, keyword) {
376
379
  if (!adapter) {
377
380
  throw new Error(`no core adapter for diagram.type="${diagramType}" (keyword="${keyword}")`);
378
381
  }
379
- const { nodeIds, edges, subgraphNames, maxDepth } = adapter(diagram.db);
382
+ const { nodeIds, edges, subgraphNames, maxDepth, treeDepth = 0 } = adapter(diagram.db);
380
383
  return {
381
384
  nodes: nodeIds.length,
382
385
  edges,
383
386
  subgraphs: subgraphNames.length,
384
387
  max_subgraph_depth: maxDepth,
388
+ max_tree_depth: treeDepth,
385
389
  node_ids: [...nodeIds].sort(),
386
390
  subgraph_names: subgraphNames,
387
391
  parser_used: "mermaid-core",
@@ -392,6 +396,7 @@ async function extractCoreStats(content, keyword) {
392
396
  var CORE_ADAPTERS = {
393
397
  "flowchart-v2": adaptFlowchart,
394
398
  flowchart: adaptFlowchart,
399
+ "flowchart-elk": adaptFlowchart,
395
400
  graph: adaptFlowchart,
396
401
  classDiagram: adaptClass,
397
402
  "classDiagram-v2": adaptClass,
@@ -515,7 +520,13 @@ function adaptIshikawa(db) {
515
520
  walk(c, depth + 1);
516
521
  };
517
522
  walk(root, 0);
518
- return { nodeIds: labels, edges: Math.max(0, labels.length - 1), subgraphNames: [], maxDepth };
523
+ return {
524
+ nodeIds: labels,
525
+ edges: Math.max(0, labels.length - 1),
526
+ subgraphNames: [],
527
+ maxDepth: 0,
528
+ treeDepth: maxDepth
529
+ };
519
530
  }
520
531
  function adaptKanban(db) {
521
532
  const data = callGetter(db, "getData");
@@ -543,7 +554,13 @@ function adaptMindmap(db) {
543
554
  walk(c, depth + 1);
544
555
  };
545
556
  walk(root, 0);
546
- return { nodeIds: labels, edges: Math.max(0, labels.length - 1), subgraphNames: [], maxDepth };
557
+ return {
558
+ nodeIds: labels,
559
+ edges: Math.max(0, labels.length - 1),
560
+ subgraphNames: [],
561
+ maxDepth: 0,
562
+ treeDepth: maxDepth
563
+ };
547
564
  }
548
565
  function adaptQuadrantChart(db) {
549
566
  const data = callGetter(db, "getQuadrantData");
@@ -1168,7 +1185,7 @@ var calculateComplexity = (stats, config) => {
1168
1185
  const n = stats.nodes;
1169
1186
  const e = stats.edges;
1170
1187
  const s = stats.subgraphs;
1171
- const d = stats.max_subgraph_depth;
1188
+ const d = Math.max(stats.max_subgraph_depth, stats.max_tree_depth);
1172
1189
  const base = n + e * config.edge_weight + s * config.subgraph_weight;
1173
1190
  const vcs = base * (1 + d * config.depth_weight);
1174
1191
  const maxEdges = n > 1 ? n * (n - 1) : 0;
@@ -1505,7 +1522,7 @@ var parseFenceUnterminated = {
1505
1522
  id: RULE.FENCE_UNTERMINATED,
1506
1523
  category: "parse",
1507
1524
  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.")),
1525
+ 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
1526
  fix: (ctx) => {
1510
1527
  const edits = [];
1511
1528
  const { src } = ctx.doc;
@@ -1669,26 +1686,40 @@ var sameDiagrams = async (before, after) => {
1669
1686
  if (!now || was.keyword !== now.keyword)
1670
1687
  return false;
1671
1688
  const [a, b] = await Promise.all([was.stats(), now.stats()]);
1672
- const shape = (s) => [
1689
+ const shape = (s, source) => [
1690
+ parseFailed(source, s),
1673
1691
  s.diagram_type,
1674
1692
  s.nodes,
1675
1693
  s.edges,
1676
1694
  s.subgraphs,
1677
1695
  s.max_subgraph_depth,
1696
+ s.max_tree_depth,
1678
1697
  s.node_ids,
1679
1698
  s.subgraph_names
1680
1699
  ];
1681
- if (!sameJson(shape(a), shape(b)))
1700
+ if (!sameJson(shape(a, was.source), shape(b, now.source)))
1682
1701
  return false;
1683
1702
  }
1684
1703
  return true;
1685
1704
  };
1705
+ var breaksAParse = async (before, after) => {
1706
+ for (const [i, was] of before.diagrams.entries()) {
1707
+ const now = after.diagrams[i];
1708
+ if (!now)
1709
+ continue;
1710
+ const [a, b] = await Promise.all([was.stats(), now.stats()]);
1711
+ if (!parseFailed(was.source, a) && parseFailed(now.source, b))
1712
+ return true;
1713
+ }
1714
+ return false;
1715
+ };
1686
1716
  var verify = async (edit, before, after) => {
1687
1717
  if (edit.expect.kind === "same-tree") {
1688
1718
  if (!sameTree(before.tree, after.tree, {}))
1689
1719
  return "refuse";
1690
- if (before.kind === "markdown" && after.kind === "markdown")
1691
- return "accept";
1720
+ if (before.kind === "markdown" && after.kind === "markdown") {
1721
+ return await breaksAParse(before, after) ? "refuse" : "accept";
1722
+ }
1692
1723
  }
1693
1724
  return await sameDiagrams(before, after) ? "accept" : "refuse";
1694
1725
  };
@@ -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.0",
4
4
  "type": "module",
5
5
  "description": "Deterministic lint gates and verified fixers for Mermaid diagrams in markdown",
6
6
  "license": "MIT",
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/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;
@@ -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) => {