@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/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,16 +554,20 @@ 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");
550
- const quadrants = data?.quadrants ?? [];
551
- const labels = quadrants.map((q) => {
552
- const t = q.text;
553
- return typeof t === "string" ? t : t?.text ?? "";
554
- });
555
- return { nodeIds: labels, edges: 0, subgraphNames: [], maxDepth: 0 };
567
+ const textOf = (t) => typeof t === "string" ? t : t?.text ?? "";
568
+ const quadrants = (data?.quadrants ?? []).map((q) => textOf(q.text)).filter((t) => t.trim() !== "");
569
+ const points = (data?.points ?? []).map((p, i) => textOf(p.text) || `point_${i}`);
570
+ return { nodeIds: [...quadrants, ...points], edges: 0, subgraphNames: [], maxDepth: 0 };
556
571
  }
557
572
  function adaptRequirement(db) {
558
573
  const requirements = db.requirements instanceof Map ? db.requirements : new Map;
@@ -578,18 +593,30 @@ function adaptSankey(db) {
578
593
  function adaptState(db) {
579
594
  const nodes = db.nodes ?? [];
580
595
  const edges = db.edges ?? [];
596
+ const groups = nodes.filter((n) => n.isGroup === true);
597
+ const parentOf = new Map(nodes.map((n) => [n.id ?? "", n.parentId]));
598
+ let maxDepth = 0;
599
+ for (const g of groups) {
600
+ let depth = 1;
601
+ const seen = new Set;
602
+ for (let cur = g.parentId;cur && !seen.has(cur); cur = parentOf.get(cur)) {
603
+ seen.add(cur);
604
+ depth++;
605
+ }
606
+ maxDepth = Math.max(maxDepth, depth);
607
+ }
581
608
  return {
582
609
  nodeIds: nodes.map((n) => n.id ?? ""),
583
610
  edges: edges.length,
584
- subgraphNames: [],
585
- maxDepth: 0
611
+ subgraphNames: groups.map((g) => g.id ?? ""),
612
+ maxDepth
586
613
  };
587
614
  }
588
615
  function adaptTimeline(db) {
589
616
  const tasks = callGetter(db, "getTasks") ?? [];
590
617
  const sections = callGetter(db, "getSections") ?? [];
591
618
  return {
592
- nodeIds: tasks.map((t) => t.task ?? ""),
619
+ nodeIds: tasks.flatMap((t) => [t.task ?? "", ...(t.events ?? []).map((e) => `${t.task ?? ""}: ${e}`)]),
593
620
  edges: 0,
594
621
  subgraphNames: sections.map((s) => s.sectionName ?? s.section ?? ""),
595
622
  maxDepth: sections.length > 0 ? 1 : 0
@@ -619,7 +646,10 @@ function adaptVenn(db) {
619
646
  function adaptXychart(db) {
620
647
  const data = callGetter(db, "getXYChartData");
621
648
  const categories = data?.xAxis?.categories ?? [];
622
- return { nodeIds: categories, edges: 0, subgraphNames: [], maxDepth: 0 };
649
+ if (categories.length > 0)
650
+ return { nodeIds: categories, edges: 0, subgraphNames: [], maxDepth: 0 };
651
+ const longest = Math.max(0, ...(data?.plots ?? []).map((p) => p.data?.length ?? 0));
652
+ return { nodeIds: Array.from({ length: longest }, (_, i) => `x_${i}`), edges: 0, subgraphNames: [], maxDepth: 0 };
623
653
  }
624
654
  function subgraphNestingDepth(subGraphs) {
625
655
  if (subGraphs.length === 0)
@@ -846,6 +876,34 @@ class RuleOptionError extends UsageError {
846
876
  }
847
877
  }
848
878
 
879
+ class FixOutsideRootError extends UsageError {
880
+ file;
881
+ target;
882
+ constructor(file, target, 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");
884
+ this.file = file;
885
+ this.target = target;
886
+ }
887
+ }
888
+
889
+ class PackageJsonError extends MermaidLintError {
890
+ file;
891
+ constructor(file) {
892
+ super("package-json", `${file} is not a JSON object; fix or remove it`);
893
+ this.file = file;
894
+ }
895
+ }
896
+
897
+ class FixNotConvergingError extends MermaidLintError {
898
+ file;
899
+ rules;
900
+ constructor(file, passes, rules) {
901
+ 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`);
902
+ this.file = file;
903
+ this.rules = rules;
904
+ }
905
+ }
906
+
849
907
  class PluginLoadError extends MermaidLintError {
850
908
  source;
851
909
  constructor(source, reason, code = "plugin") {
@@ -1237,7 +1295,7 @@ var calculateComplexity = (stats, config) => {
1237
1295
  const n = stats.nodes;
1238
1296
  const e = stats.edges;
1239
1297
  const s = stats.subgraphs;
1240
- const d = stats.max_subgraph_depth;
1298
+ const d = Math.max(stats.max_subgraph_depth, stats.max_tree_depth);
1241
1299
  const base = n + e * config.edge_weight + s * config.subgraph_weight;
1242
1300
  const vcs = base * (1 + d * config.depth_weight);
1243
1301
  const maxEdges = n > 1 ? n * (n - 1) : 0;
@@ -1575,7 +1633,7 @@ var parseFenceUnterminated = {
1575
1633
  id: RULE.FENCE_UNTERMINATED,
1576
1634
  category: "parse",
1577
1635
  summary: "a mermaid fence that is never closed",
1578
- 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.")),
1579
1637
  fix: (ctx) => {
1580
1638
  const edits = [];
1581
1639
  const { src } = ctx.doc;
@@ -1707,13 +1765,13 @@ var checkModel = async (doc, file, flags = {}, registry = BUILTIN) => {
1707
1765
  };
1708
1766
  var checkMarkdown = (src, file, flags = {}, registry = BUILTIN) => checkModel(buildDocModel(src, documentKind(file)), file, flags, registry);
1709
1767
  // src/cli.ts
1710
- import { readFile as readFile3, writeFile } from "node:fs/promises";
1711
- import { relative } from "node:path";
1768
+ import { lstat, readFile as readFile3, realpath, writeFile } from "node:fs/promises";
1769
+ import { isAbsolute as isAbsolute2, relative, resolve as resolve5 } from "node:path";
1712
1770
  import { parseArgs } from "node:util";
1713
1771
  // package.json
1714
1772
  var package_default = {
1715
1773
  name: "@jpeakai/mermaidjs-lint",
1716
- version: "0.2.0",
1774
+ version: "0.4.0",
1717
1775
  type: "module",
1718
1776
  description: "Deterministic lint gates and verified fixers for Mermaid diagrams in markdown",
1719
1777
  license: "MIT",
@@ -1742,15 +1800,16 @@ var package_default = {
1742
1800
  bin: {
1743
1801
  "mermaidjs-lint": "dist/bin.js"
1744
1802
  },
1803
+ types: "./dist/types/index.d.ts",
1745
1804
  exports: {
1746
1805
  ".": {
1806
+ types: "./dist/types/index.d.ts",
1747
1807
  bun: "./src/index.ts",
1748
- types: "./src/index.ts",
1749
1808
  default: "./dist/index.js"
1750
1809
  },
1751
1810
  "./testing": {
1811
+ types: "./dist/types/testing.d.ts",
1752
1812
  bun: "./src/testing.ts",
1753
- types: "./src/testing.ts",
1754
1813
  default: "./dist/testing.js"
1755
1814
  }
1756
1815
  },
@@ -1764,6 +1823,7 @@ var package_default = {
1764
1823
  ],
1765
1824
  dependencies: {
1766
1825
  "@mermaid-js/parser": "^1.1.0",
1826
+ "@types/mdast": "^4",
1767
1827
  "colorjs.io": "^0.6.1",
1768
1828
  "happy-dom": "^20.9.0",
1769
1829
  "mdast-util-from-markdown": "^2.0.3",
@@ -1777,7 +1837,6 @@ var package_default = {
1777
1837
  "@biomejs/biome": "^2",
1778
1838
  "@jpeakai/prose-gates": "^0.2.0",
1779
1839
  "@types/bun": "latest",
1780
- "@types/mdast": "^4",
1781
1840
  typescript: "^5"
1782
1841
  }
1783
1842
  };
@@ -1788,9 +1847,17 @@ import { extname, join as join2, resolve as resolve2 } from "node:path";
1788
1847
  var DIAGRAM_FILE_EXTS = new Set([...DIAGRAM_EXTS, ".md", ".markdown"]);
1789
1848
  var SKIP_DIRS = new Set(["node_modules", "vendor", "dist", "build", "tmp", "__pycache__"]);
1790
1849
  var skipDir = (name) => name.startsWith(".") || SKIP_DIRS.has(name);
1850
+ var linksToDir = (path) => {
1851
+ try {
1852
+ return statSync(path).isDirectory();
1853
+ } catch {
1854
+ return false;
1855
+ }
1856
+ };
1791
1857
  var collectFiles = (paths) => {
1792
1858
  const files = [];
1793
1859
  const unreadable = [];
1860
+ const symlinks = [];
1794
1861
  const seen = new Set;
1795
1862
  const walk = (dir) => {
1796
1863
  let entries;
@@ -1802,7 +1869,11 @@ var collectFiles = (paths) => {
1802
1869
  }
1803
1870
  for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
1804
1871
  const abs = join2(dir, entry.name);
1805
- if (entry.isDirectory()) {
1872
+ if (entry.isSymbolicLink()) {
1873
+ if (!skipDir(entry.name) && (DIAGRAM_FILE_EXTS.has(extname(entry.name).toLowerCase()) || linksToDir(abs))) {
1874
+ symlinks.push(abs);
1875
+ }
1876
+ } else if (entry.isDirectory()) {
1806
1877
  if (!skipDir(entry.name))
1807
1878
  walk(abs);
1808
1879
  } else if (entry.isFile() && DIAGRAM_FILE_EXTS.has(extname(entry.name).toLowerCase())) {
@@ -1827,7 +1898,7 @@ var collectFiles = (paths) => {
1827
1898
  unreadable.push(p);
1828
1899
  }
1829
1900
  }
1830
- return { files, unreadable };
1901
+ return { files, unreadable, symlinks };
1831
1902
  };
1832
1903
 
1833
1904
  // src/fix/verify.ts
@@ -1862,29 +1933,47 @@ var sameDiagrams = async (before, after) => {
1862
1933
  if (!now || was.keyword !== now.keyword)
1863
1934
  return false;
1864
1935
  const [a, b] = await Promise.all([was.stats(), now.stats()]);
1865
- const shape = (s) => [
1936
+ const shape = (s, source) => [
1937
+ parseFailed(source, s),
1866
1938
  s.diagram_type,
1867
1939
  s.nodes,
1868
1940
  s.edges,
1869
1941
  s.subgraphs,
1870
1942
  s.max_subgraph_depth,
1943
+ s.max_tree_depth,
1871
1944
  s.node_ids,
1872
1945
  s.subgraph_names
1873
1946
  ];
1874
- if (!sameJson(shape(a), shape(b)))
1947
+ if (!sameJson(shape(a, was.source), shape(b, now.source)))
1875
1948
  return false;
1876
1949
  }
1877
1950
  return true;
1878
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
+ };
1879
1963
  var verify = async (edit, before, after) => {
1880
1964
  if (edit.expect.kind === "same-tree") {
1881
- return sameTree(before.tree, after.tree, {}) ? "accept" : "refuse";
1965
+ if (!sameTree(before.tree, after.tree, {}))
1966
+ return "refuse";
1967
+ if (before.kind === "markdown" && after.kind === "markdown") {
1968
+ return await breaksAParse(before, after) ? "refuse" : "accept";
1969
+ }
1882
1970
  }
1883
1971
  return await sameDiagrams(before, after) ? "accept" : "refuse";
1884
1972
  };
1885
1973
 
1886
1974
  // src/fix/engine.ts
1887
1975
  var MAX_PASSES = 1000;
1976
+ var RECENT_PASSES = 50;
1888
1977
  var splice = (src, edits) => {
1889
1978
  let out = src;
1890
1979
  for (const e of [...edits].sort((a, b) => b.start - a.start))
@@ -1902,6 +1991,7 @@ var fixSourceReport = async (src, file, flags = {}, registry = BUILTIN) => {
1902
1991
  const refused = [];
1903
1992
  const refusedKeys = new Set;
1904
1993
  let current = src;
1994
+ const keptBy = [];
1905
1995
  for (let pass = 0;pass < MAX_PASSES; pass++) {
1906
1996
  const doc = buildDocModel(current, kind);
1907
1997
  const keyOf = (e) => `${e.rule}\x00${current.slice(e.start, e.end)}\x00${e.text}`;
@@ -1934,23 +2024,26 @@ var fixSourceReport = async (src, file, flags = {}, registry = BUILTIN) => {
1934
2024
  continue;
1935
2025
  if (edits.length > 1 && !overlaps(edits) && await attempt(edits)) {
1936
2026
  progressed = true;
1937
- break;
1938
- }
1939
- for (const edit of edits) {
1940
- if (await attempt([edit])) {
1941
- progressed = true;
1942
- break;
2027
+ } else {
2028
+ for (const edit of edits) {
2029
+ if (await attempt([edit])) {
2030
+ progressed = true;
2031
+ break;
2032
+ }
2033
+ refusedKeys.add(keyOf(edit));
2034
+ refused.push(edit);
1943
2035
  }
1944
- refusedKeys.add(keyOf(edit));
1945
- refused.push(edit);
1946
2036
  }
1947
- if (progressed)
2037
+ if (progressed) {
2038
+ keptBy.push(rule.id);
1948
2039
  break;
2040
+ }
1949
2041
  }
1950
2042
  if (!progressed)
1951
2043
  return { output: current, applied, refused };
1952
2044
  }
1953
- throw new Error(`fix did not converge after ${MAX_PASSES} passes; a fixer keeps proposing edits`);
2045
+ const lingering = [...new Set(keptBy.slice(-RECENT_PASSES))].sort();
2046
+ throw new FixNotConvergingError(file, MAX_PASSES, lingering);
1954
2047
  };
1955
2048
  var fixSource = async (src, file, flags = {}, registry = BUILTIN) => (await fixSourceReport(src, file, flags, registry)).output;
1956
2049
 
@@ -2053,7 +2146,7 @@ var readJson = async (path) => {
2053
2146
  if (isRecord2(value))
2054
2147
  return value;
2055
2148
  } catch {}
2056
- throw new Error(`${path} is not a JSON object`);
2149
+ throw new PackageJsonError(path);
2057
2150
  };
2058
2151
  var LOCAL_EXTENSIONS = new Set([".js", ".mjs", ".ts", ".mts", ".cts"]);
2059
2152
  var localSources = async (root) => {
@@ -2097,7 +2190,14 @@ var entryOf = (pkg) => {
2097
2190
  var resolvePackage = async (root, name) => {
2098
2191
  for (let dir = resolve3(root);; dir = dirname2(dir)) {
2099
2192
  const folder = join4(dir, "node_modules", name);
2100
- const pkg = await readJson(join4(folder, "package.json"));
2193
+ let pkg;
2194
+ try {
2195
+ pkg = await readJson(join4(folder, "package.json"));
2196
+ } catch (err) {
2197
+ if (!(err instanceof PackageJsonError))
2198
+ throw err;
2199
+ throw new PluginSourceError(name, `its package.json at ${err.file} is not a JSON object`);
2200
+ }
2101
2201
  if (pkg)
2102
2202
  return join4(folder, entryOf(pkg));
2103
2203
  if (dirname2(dir) === dir)
@@ -2105,8 +2205,15 @@ var resolvePackage = async (root, name) => {
2105
2205
  }
2106
2206
  throw new PluginNotInstalledError(name, root);
2107
2207
  };
2108
- var declaredPlugins = async (root) => {
2109
- const pkg = await readJson(join4(root, "package.json"));
2208
+ var declaredPlugins = async (root, tolerateBadManifest) => {
2209
+ let pkg;
2210
+ try {
2211
+ pkg = await readJson(join4(root, "package.json"));
2212
+ } catch (err) {
2213
+ if (tolerateBadManifest && err instanceof PackageJsonError)
2214
+ return [];
2215
+ throw err;
2216
+ }
2110
2217
  if (!pkg)
2111
2218
  return [];
2112
2219
  const names = ["dependencies", "devDependencies", "optionalDependencies"].flatMap((field) => {
@@ -2115,19 +2222,19 @@ var declaredPlugins = async (root) => {
2115
2222
  });
2116
2223
  return [...new Set(names)].filter((name) => PACKAGE_PATTERN.test(name)).sort();
2117
2224
  };
2118
- var discoverSources = async (root, config) => {
2225
+ var discoverSources = async (root, config, pluginsOff = false) => {
2119
2226
  const sources = await localSources(root);
2120
2227
  const failures = [];
2121
2228
  const add = async (kind, spec, resolveIt) => {
2122
2229
  try {
2123
2230
  sources.push({ kind, spec, path: await resolveIt() });
2124
2231
  } catch (err) {
2125
- if (!(err instanceof PluginNotInstalledError))
2232
+ if (!(err instanceof PluginLoadError))
2126
2233
  throw err;
2127
- failures.push(err);
2234
+ failures.push({ kind, error: err });
2128
2235
  }
2129
2236
  };
2130
- for (const name of await declaredPlugins(root))
2237
+ for (const name of await declaredPlugins(root, pluginsOff))
2131
2238
  await add("package", name, () => resolvePackage(root, name));
2132
2239
  for (const spec of config.plugins || []) {
2133
2240
  const path = spec.startsWith(".") || isAbsolute(spec);
@@ -2144,8 +2251,8 @@ import { pathToFileURL as pathToFileURL2 } from "node:url";
2144
2251
  // src/plugins/contract.ts
2145
2252
  var API_VERSION = 1;
2146
2253
  var CATEGORY_WORD = /^[a-z]+$/;
2147
- var NAMESPACE = /^[a-z][a-z0-9-]*$/;
2148
- var RULE_KEY = /^[a-z][a-z0-9-]*$/;
2254
+ var NAMESPACE = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
2255
+ var RULE_KEY = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
2149
2256
 
2150
2257
  // src/plugins/runtime.ts
2151
2258
  var canImportTypeScript = (runtime = process) => typeof runtime.versions.bun === "string" || Boolean(runtime.features.typescript);
@@ -2172,6 +2279,7 @@ var importModule = async (source, runtime) => {
2172
2279
  }
2173
2280
  };
2174
2281
  var OPTION_TYPES = new Set(["number", "string", "boolean", "string[]"]);
2282
+ var RESERVED_OPTIONS = ["preset", "profile"];
2175
2283
  var checkRule = (spec, key, raw, own) => {
2176
2284
  const where = `rule "${key}"`;
2177
2285
  if (!isRecord3(raw))
@@ -2201,6 +2309,11 @@ var checkRule = (spec, key, raw, own) => {
2201
2309
  if (!valid) {
2202
2310
  throw new PluginContractError(spec, `${where} options must map names to number, string, boolean or string[]`);
2203
2311
  }
2312
+ for (const name of RESERVED_OPTIONS) {
2313
+ if (name in options && options[name] !== "string") {
2314
+ throw new PluginContractError(spec, `${where} declares option "${name}" as ${String(options[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`);
2315
+ }
2316
+ }
2204
2317
  }
2205
2318
  return raw;
2206
2319
  };
@@ -2252,7 +2365,18 @@ var loadLocal = async (sources, runtime) => {
2252
2365
  const categories = {};
2253
2366
  const owner = {};
2254
2367
  const loaded = [];
2368
+ const keyOf = (source) => basename(source.path, extname4(source.path));
2369
+ const byKey = new Map;
2370
+ for (const source of sources)
2371
+ byKey.set(keyOf(source), [...byKey.get(keyOf(source)) ?? [], source]);
2372
+ for (const [key, group] of byKey) {
2373
+ if (group.length < 2)
2374
+ continue;
2375
+ fail(new PluginContractError(group[0].spec, `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`));
2376
+ }
2255
2377
  for (const source of sources) {
2378
+ if ((byKey.get(keyOf(source))?.length ?? 0) > 1)
2379
+ continue;
2256
2380
  try {
2257
2381
  const mod = await importModule(source, runtime);
2258
2382
  if (mod.default === undefined) {
@@ -2266,7 +2390,7 @@ var loadLocal = async (sources, runtime) => {
2266
2390
  categories[word] = description;
2267
2391
  owner[word] = source.spec;
2268
2392
  }
2269
- loaded.push({ key: basename(source.path, extname4(source.path)), source, raw: mod.default });
2393
+ loaded.push({ key: keyOf(source), source, raw: mod.default });
2270
2394
  } catch (err) {
2271
2395
  fail(err);
2272
2396
  }
@@ -2330,7 +2454,7 @@ var toRule = (namespace, key, rule) => {
2330
2454
 
2331
2455
  // src/plugins/index.ts
2332
2456
  var loadPlugins = async (root, config, mode) => {
2333
- const { sources: found, failures: unresolved } = await discoverSources(root, config);
2457
+ const { sources: found, failures: unresolved } = await discoverSources(root, config, mode === "off");
2334
2458
  const set = {
2335
2459
  rules: [],
2336
2460
  categories: new Map(BUILTIN_CATEGORIES),
@@ -2338,16 +2462,17 @@ var loadPlugins = async (root, config, mode) => {
2338
2462
  skipped: [],
2339
2463
  failures: []
2340
2464
  };
2341
- if (mode === "off")
2342
- return { ...set, skipped: [...found.map((s) => s.spec), ...unresolved.map((e) => e.source)] };
2465
+ if (mode === "off") {
2466
+ return { ...set, skipped: [...found.map((s) => s.spec), ...unresolved.map((u) => u.error.source)] };
2467
+ }
2343
2468
  const settle = (error, kind) => {
2344
2469
  if (mode === "strict")
2345
2470
  throw error;
2346
2471
  if (!set.failures.some((f) => f.source === error.source))
2347
2472
  set.failures.push({ source: error.source, kind, error });
2348
2473
  };
2349
- for (const error of unresolved)
2350
- settle(error, "package");
2474
+ for (const { error, kind } of unresolved)
2475
+ settle(error, kind);
2351
2476
  const loaded = [];
2352
2477
  const locals = found.filter((s) => s.kind === "local");
2353
2478
  if (locals.length > 0) {
@@ -2444,7 +2569,7 @@ var setUp = async ({
2444
2569
  config,
2445
2570
  ignoreUnknownPluginIds: mode !== "strict"
2446
2571
  });
2447
- return { registry, skipped: plugins.skipped, failures: plugins.failures };
2572
+ return { root, registry, skipped: plugins.skipped, failures: plugins.failures };
2448
2573
  };
2449
2574
 
2450
2575
  // src/cli.ts
@@ -2453,17 +2578,21 @@ var SYNOPSIS = `usage: mermaidjs-lint <path...> [--fix] [--json] [--preset NAME]
2453
2578
  mermaidjs-lint --list-rules
2454
2579
  mermaidjs-lint --validate-plugins [--json] [--config FILE]
2455
2580
  mermaidjs-lint --version | -h, --help`;
2581
+ var ruleRow = (r, width) => `${`${r.id}${r.fix ? "*" : ""}`.padEnd(width)} ${r.summary}`;
2582
+ var idWidth = (rules) => Math.max(0, ...rules.map((r) => r.id.length + 1));
2456
2583
  var USAGE = `${SYNOPSIS}
2457
2584
 
2458
2585
  Deterministic Mermaid diagram gates (* = autofixable):
2459
- ${CATEGORIES.map((c) => [` ${c}:`, ...RULES.filter((r) => r.category === c).map((r) => ` ${r.id}${r.fix ? "*" : " "} ${r.summary}`)].join(`
2586
+ ${CATEGORIES.map((c) => [` ${c}:`, ...RULES.filter((r) => r.category === c).map((r) => ` ${ruleRow(r, idWidth(RULES))}`)].join(`
2460
2587
  `)).join(`
2461
2588
  `)}
2462
2589
 
2463
2590
  A path is a file or a directory, and every path must exist. A file ending in
2464
2591
  ${DIAGRAM_EXTS.join(" or ")} is read as one raw diagram, and any other file is read as markdown
2465
2592
  whose mermaid fences are checked. A directory is walked for ${[...DIAGRAM_FILE_EXTS].join(", ")} files,
2466
- skipping dot-folders and ${[...SKIP_DIRS].join(", ")}.
2593
+ skipping dot-folders and ${[...SKIP_DIRS].join(", ")}. The walk does not follow a symbolic link,
2594
+ and names each one it skipped. A link named as a path is read, but --fix does not write
2595
+ through one whose target is outside the project root.
2467
2596
  Examples of every rule, with fixes: https://jpeakai.github.io/jpai-mermaidjs-lint/reference/rules/
2468
2597
 
2469
2598
  -p, --preset NAME sets the density for every complexity rule: ${PRESET_NAMES.join(", ")}
@@ -2474,14 +2603,16 @@ Examples of every rule, with fixes: https://jpeakai.github.io/jpai-mermaidjs-lin
2474
2603
  --fix applies every fix it can prove safe, then reports what remains.
2475
2604
  A fixer that is unsure leaves the text alone and the finding stays.
2476
2605
  --config FILE reads that config instead of mermaidjs-lint.config.json or .mjs at the project root.
2477
- Plugins load on their own from .mermaidjs-lint/rules and from dependencies named mermaidjs-lint-plugin-*.
2606
+ Plugins load on their own from .mermaidjs-lint/rules, from dependencies named mermaidjs-lint-plugin,
2607
+ mermaidjs-lint-plugin-* or @scope/mermaidjs-lint-plugin(-*), and from the config's plugins list.
2478
2608
  --no-plugins runs the built-in rules only and loads no plugin. It reads a .json config as usual,
2479
2609
  and stops with a usage error rather than import a .mjs config, which is code.
2480
2610
  --lenient-plugins skips a plugin that fails to load and says so, instead of stopping the run.
2481
- --validate-plugins loads and checks every plugin, reports every failure, and runs no check or fix.
2611
+ --validate-plugins imports and checks every plugin, reports every failure, and runs no check or fix.
2482
2612
  --list-rules prints every active rule by category, including plugin rules, and where each plugin came from.
2613
+ It imports the plugins to list them.
2483
2614
  --version prints the version, to quote in a bug report.
2484
- Exits 0 when no findings remain, 1 when findings remain or a plugin fails to load (a lenient run
2615
+ Exits 0 when no findings remain, 1 when findings remain, a plugin fails to load or the run fails (a lenient run
2485
2616
  skips a broken plugin, so it alone does not exit 1), and 2 on a usage error.`;
2486
2617
  var parse2 = (argv) => parseArgs({
2487
2618
  args: argv,
@@ -2517,13 +2648,14 @@ var skippedLines = (failures, ignored) => [
2517
2648
  ];
2518
2649
  var listRules = (registry) => {
2519
2650
  const lines = [];
2651
+ const width = idWidth(registry.rules);
2520
2652
  for (const [category, description] of registry.categories) {
2521
2653
  const rules = registry.rules.filter((r) => r.category === category);
2522
2654
  if (rules.length === 0)
2523
2655
  continue;
2524
2656
  lines.push(`${category}: ${description}`);
2525
2657
  for (const r of rules)
2526
- lines.push(` ${r.id}${r.fix ? "*" : " "} ${r.summary}`);
2658
+ lines.push(` ${ruleRow(r, width)}`);
2527
2659
  }
2528
2660
  if (registry.sources.length > 0) {
2529
2661
  lines.push("", "plugins:");
@@ -2566,6 +2698,17 @@ var display = (file, cwd) => {
2566
2698
  const rel = relative(cwd, file);
2567
2699
  return !rel || rel.startsWith("..") ? file : rel;
2568
2700
  };
2701
+ var refuseOutsideLinks = async (files, root, cwd) => {
2702
+ const realRoot = await realpath(root);
2703
+ for (const file of files) {
2704
+ if (!(await lstat(file)).isSymbolicLink())
2705
+ continue;
2706
+ const target = await realpath(file);
2707
+ const rel = relative(realRoot, target);
2708
+ if (rel.startsWith("..") || isAbsolute2(rel))
2709
+ throw new FixOutsideRootError(display(file, cwd), target, root);
2710
+ }
2711
+ };
2569
2712
  var run = async (argv, cwd) => {
2570
2713
  let parsed;
2571
2714
  try {
@@ -2601,7 +2744,7 @@ var run = async (argv, cwd) => {
2601
2744
  }
2602
2745
  if (positionals.length === 0 && !listing && !validating)
2603
2746
  throw new FlagError("files", "no files given");
2604
- const { registry, skipped, failures } = await setUp({
2747
+ const { root, registry, skipped, failures } = await setUp({
2605
2748
  cwd,
2606
2749
  configPath: values.config,
2607
2750
  noPlugins: values["no-plugins"],
@@ -2625,11 +2768,16 @@ var run = async (argv, cwd) => {
2625
2768
  console.log(listRules(registry));
2626
2769
  return 0;
2627
2770
  }
2628
- const { files, unreadable } = collectFiles(positionals.map((p) => p.startsWith("/") ? p : `${cwd}/${p}`));
2771
+ const { files, unreadable, symlinks } = collectFiles(positionals.map((p) => resolve5(cwd, p)));
2629
2772
  if (unreadable.length > 0)
2630
2773
  throw new FlagError("files", `cannot read ${unreadable.join(", ")}`);
2774
+ for (const link of symlinks) {
2775
+ console.error(`mermaidjs-lint: skipped symbolic link ${display(link, cwd)}; name it as a path to check it`);
2776
+ }
2631
2777
  if (files.length === 0)
2632
2778
  throw new FlagError("files", "no matching files");
2779
+ if (values.fix)
2780
+ await refuseOutsideLinks(files, root, cwd);
2633
2781
  const flags = { preset: values.preset, profile: values.profile };
2634
2782
  const perFile = await Promise.all(files.map(async (file) => {
2635
2783
  let src = await readFile3(file, "utf8");
@@ -2640,7 +2788,9 @@ var run = async (argv, cwd) => {
2640
2788
  src = fixed;
2641
2789
  }
2642
2790
  }
2643
- return checkModel(buildDocModel(src, documentKind(file)), display(file, cwd), flags, registry);
2791
+ const shown = display(file, cwd);
2792
+ const found = await checkModel(buildDocModel(src, documentKind(file)), file, flags, registry);
2793
+ return found.map((f) => ({ ...f, file: shown }));
2644
2794
  }));
2645
2795
  const all = perFile.flat();
2646
2796
  if (values.json) {
@@ -2701,9 +2851,12 @@ export {
2701
2851
  PluginFixerError,
2702
2852
  PluginContractError,
2703
2853
  PluginConflictError,
2854
+ PackageJsonError,
2704
2855
  PRESETS,
2705
2856
  MermaidLintError,
2706
2857
  FlagError,
2858
+ FixOutsideRootError,
2859
+ FixNotConvergingError,
2707
2860
  FIX_ORDER,
2708
2861
  DIAGRAM_EXTS,
2709
2862
  ConfigShapeError,