@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/bin.js CHANGED
@@ -3,13 +3,13 @@ import { createRequire } from "node:module";
3
3
  var __require = /* @__PURE__ */ createRequire(import.meta.url);
4
4
 
5
5
  // src/cli.ts
6
- import { readFile as readFile3, writeFile } from "node:fs/promises";
7
- import { relative } from "node:path";
6
+ import { lstat, readFile as readFile3, realpath, writeFile } from "node:fs/promises";
7
+ import { isAbsolute as isAbsolute2, relative, resolve as resolve5 } from "node:path";
8
8
  import { parseArgs } from "node:util";
9
9
  // package.json
10
10
  var package_default = {
11
11
  name: "@jpeakai/mermaidjs-lint",
12
- version: "0.2.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",
@@ -38,15 +38,16 @@ var package_default = {
38
38
  bin: {
39
39
  "mermaidjs-lint": "dist/bin.js"
40
40
  },
41
+ types: "./dist/types/index.d.ts",
41
42
  exports: {
42
43
  ".": {
44
+ types: "./dist/types/index.d.ts",
43
45
  bun: "./src/index.ts",
44
- types: "./src/index.ts",
45
46
  default: "./dist/index.js"
46
47
  },
47
48
  "./testing": {
49
+ types: "./dist/types/testing.d.ts",
48
50
  bun: "./src/testing.ts",
49
- types: "./src/testing.ts",
50
51
  default: "./dist/testing.js"
51
52
  }
52
53
  },
@@ -60,6 +61,7 @@ var package_default = {
60
61
  ],
61
62
  dependencies: {
62
63
  "@mermaid-js/parser": "^1.1.0",
64
+ "@types/mdast": "^4",
63
65
  "colorjs.io": "^0.6.1",
64
66
  "happy-dom": "^20.9.0",
65
67
  "mdast-util-from-markdown": "^2.0.3",
@@ -73,7 +75,6 @@ var package_default = {
73
75
  "@biomejs/biome": "^2",
74
76
  "@jpeakai/prose-gates": "^0.2.0",
75
77
  "@types/bun": "latest",
76
- "@types/mdast": "^4",
77
78
  typescript: "^5"
78
79
  }
79
80
  };
@@ -280,6 +281,7 @@ async function extractStats(content) {
280
281
  edges: 0,
281
282
  subgraphs: 0,
282
283
  max_subgraph_depth: 0,
284
+ max_tree_depth: 0,
283
285
  node_ids: [],
284
286
  subgraph_names: [],
285
287
  parser_used: langium ? "langium" : "mermaid-core",
@@ -329,6 +331,7 @@ async function extractLangiumStats(content, keyword) {
329
331
  const subgraphNames = [];
330
332
  let edges = 0;
331
333
  let maxDepth = 0;
334
+ let treeDepth = 0;
332
335
  switch (mapped) {
333
336
  case "architecture": {
334
337
  const services = ast.services ?? [];
@@ -375,7 +378,7 @@ async function extractLangiumStats(content, keyword) {
375
378
  const stats = indentTreeStats(nodes.map((n, i) => ({ label: n.name ?? `node_${i}`, indent: n.indent ?? 0 })));
376
379
  nodeIds.push(...stats.labels);
377
380
  edges = stats.edges;
378
- maxDepth = stats.depth;
381
+ treeDepth = stats.depth;
379
382
  break;
380
383
  }
381
384
  case "treemap": {
@@ -383,7 +386,7 @@ async function extractLangiumStats(content, keyword) {
383
386
  const stats = indentTreeStats(rows.map((r, i) => ({ label: r.item?.name ?? `row_${i}`, indent: r.indent ?? 0 })));
384
387
  nodeIds.push(...stats.labels);
385
388
  edges = stats.edges;
386
- maxDepth = stats.depth;
389
+ treeDepth = stats.depth;
387
390
  break;
388
391
  }
389
392
  case "wardley": {
@@ -403,6 +406,7 @@ async function extractLangiumStats(content, keyword) {
403
406
  edges,
404
407
  subgraphs: subgraphNames.length,
405
408
  max_subgraph_depth: maxDepth,
409
+ max_tree_depth: treeDepth,
406
410
  node_ids: [...new Set(nodeIds.filter(Boolean))].sort(),
407
411
  subgraph_names: subgraphNames,
408
412
  parser_used: "langium",
@@ -453,12 +457,13 @@ async function extractCoreStats(content, keyword) {
453
457
  if (!adapter) {
454
458
  throw new Error(`no core adapter for diagram.type="${diagramType}" (keyword="${keyword}")`);
455
459
  }
456
- const { nodeIds, edges, subgraphNames, maxDepth } = adapter(diagram.db);
460
+ const { nodeIds, edges, subgraphNames, maxDepth, treeDepth = 0 } = adapter(diagram.db);
457
461
  return {
458
462
  nodes: nodeIds.length,
459
463
  edges,
460
464
  subgraphs: subgraphNames.length,
461
465
  max_subgraph_depth: maxDepth,
466
+ max_tree_depth: treeDepth,
462
467
  node_ids: [...nodeIds].sort(),
463
468
  subgraph_names: subgraphNames,
464
469
  parser_used: "mermaid-core",
@@ -469,6 +474,7 @@ async function extractCoreStats(content, keyword) {
469
474
  var CORE_ADAPTERS = {
470
475
  "flowchart-v2": adaptFlowchart,
471
476
  flowchart: adaptFlowchart,
477
+ "flowchart-elk": adaptFlowchart,
472
478
  graph: adaptFlowchart,
473
479
  classDiagram: adaptClass,
474
480
  "classDiagram-v2": adaptClass,
@@ -592,7 +598,13 @@ function adaptIshikawa(db) {
592
598
  walk(c, depth + 1);
593
599
  };
594
600
  walk(root, 0);
595
- 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
+ };
596
608
  }
597
609
  function adaptKanban(db) {
598
610
  const data = callGetter(db, "getData");
@@ -620,16 +632,20 @@ function adaptMindmap(db) {
620
632
  walk(c, depth + 1);
621
633
  };
622
634
  walk(root, 0);
623
- 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
+ };
624
642
  }
625
643
  function adaptQuadrantChart(db) {
626
644
  const data = callGetter(db, "getQuadrantData");
627
- const quadrants = data?.quadrants ?? [];
628
- const labels = quadrants.map((q) => {
629
- const t = q.text;
630
- return typeof t === "string" ? t : t?.text ?? "";
631
- });
632
- return { nodeIds: labels, edges: 0, subgraphNames: [], maxDepth: 0 };
645
+ const textOf = (t) => typeof t === "string" ? t : t?.text ?? "";
646
+ const quadrants = (data?.quadrants ?? []).map((q) => textOf(q.text)).filter((t) => t.trim() !== "");
647
+ const points = (data?.points ?? []).map((p, i) => textOf(p.text) || `point_${i}`);
648
+ return { nodeIds: [...quadrants, ...points], edges: 0, subgraphNames: [], maxDepth: 0 };
633
649
  }
634
650
  function adaptRequirement(db) {
635
651
  const requirements = db.requirements instanceof Map ? db.requirements : new Map;
@@ -655,18 +671,30 @@ function adaptSankey(db) {
655
671
  function adaptState(db) {
656
672
  const nodes = db.nodes ?? [];
657
673
  const edges = db.edges ?? [];
674
+ const groups = nodes.filter((n) => n.isGroup === true);
675
+ const parentOf = new Map(nodes.map((n) => [n.id ?? "", n.parentId]));
676
+ let maxDepth = 0;
677
+ for (const g of groups) {
678
+ let depth = 1;
679
+ const seen = new Set;
680
+ for (let cur = g.parentId;cur && !seen.has(cur); cur = parentOf.get(cur)) {
681
+ seen.add(cur);
682
+ depth++;
683
+ }
684
+ maxDepth = Math.max(maxDepth, depth);
685
+ }
658
686
  return {
659
687
  nodeIds: nodes.map((n) => n.id ?? ""),
660
688
  edges: edges.length,
661
- subgraphNames: [],
662
- maxDepth: 0
689
+ subgraphNames: groups.map((g) => g.id ?? ""),
690
+ maxDepth
663
691
  };
664
692
  }
665
693
  function adaptTimeline(db) {
666
694
  const tasks = callGetter(db, "getTasks") ?? [];
667
695
  const sections = callGetter(db, "getSections") ?? [];
668
696
  return {
669
- nodeIds: tasks.map((t) => t.task ?? ""),
697
+ nodeIds: tasks.flatMap((t) => [t.task ?? "", ...(t.events ?? []).map((e) => `${t.task ?? ""}: ${e}`)]),
670
698
  edges: 0,
671
699
  subgraphNames: sections.map((s) => s.sectionName ?? s.section ?? ""),
672
700
  maxDepth: sections.length > 0 ? 1 : 0
@@ -696,7 +724,10 @@ function adaptVenn(db) {
696
724
  function adaptXychart(db) {
697
725
  const data = callGetter(db, "getXYChartData");
698
726
  const categories = data?.xAxis?.categories ?? [];
699
- return { nodeIds: categories, edges: 0, subgraphNames: [], maxDepth: 0 };
727
+ if (categories.length > 0)
728
+ return { nodeIds: categories, edges: 0, subgraphNames: [], maxDepth: 0 };
729
+ const longest = Math.max(0, ...(data?.plots ?? []).map((p) => p.data?.length ?? 0));
730
+ return { nodeIds: Array.from({ length: longest }, (_, i) => `x_${i}`), edges: 0, subgraphNames: [], maxDepth: 0 };
700
731
  }
701
732
  function subgraphNestingDepth(subGraphs) {
702
733
  if (subGraphs.length === 0)
@@ -923,6 +954,34 @@ class RuleOptionError extends UsageError {
923
954
  }
924
955
  }
925
956
 
957
+ class FixOutsideRootError extends UsageError {
958
+ file;
959
+ target;
960
+ constructor(file, target, 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");
962
+ this.file = file;
963
+ this.target = target;
964
+ }
965
+ }
966
+
967
+ class PackageJsonError extends MermaidLintError {
968
+ file;
969
+ constructor(file) {
970
+ super("package-json", `${file} is not a JSON object; fix or remove it`);
971
+ this.file = file;
972
+ }
973
+ }
974
+
975
+ class FixNotConvergingError extends MermaidLintError {
976
+ file;
977
+ rules;
978
+ constructor(file, passes, rules) {
979
+ 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`);
980
+ this.file = file;
981
+ this.rules = rules;
982
+ }
983
+ }
984
+
926
985
  class PluginLoadError extends MermaidLintError {
927
986
  source;
928
987
  constructor(source, reason, code = "plugin") {
@@ -1314,7 +1373,7 @@ var calculateComplexity = (stats, config) => {
1314
1373
  const n = stats.nodes;
1315
1374
  const e = stats.edges;
1316
1375
  const s = stats.subgraphs;
1317
- const d = stats.max_subgraph_depth;
1376
+ const d = Math.max(stats.max_subgraph_depth, stats.max_tree_depth);
1318
1377
  const base = n + e * config.edge_weight + s * config.subgraph_weight;
1319
1378
  const vcs = base * (1 + d * config.depth_weight);
1320
1379
  const maxEdges = n > 1 ? n * (n - 1) : 0;
@@ -1652,7 +1711,7 @@ var parseFenceUnterminated = {
1652
1711
  id: RULE.FENCE_UNTERMINATED,
1653
1712
  category: "parse",
1654
1713
  summary: "a mermaid fence that is never closed",
1655
- 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.")),
1656
1715
  fix: (ctx) => {
1657
1716
  const edits = [];
1658
1717
  const { src } = ctx.doc;
@@ -1789,9 +1848,17 @@ import { extname, join as join2, resolve as resolve2 } from "node:path";
1789
1848
  var DIAGRAM_FILE_EXTS = new Set([...DIAGRAM_EXTS, ".md", ".markdown"]);
1790
1849
  var SKIP_DIRS = new Set(["node_modules", "vendor", "dist", "build", "tmp", "__pycache__"]);
1791
1850
  var skipDir = (name) => name.startsWith(".") || SKIP_DIRS.has(name);
1851
+ var linksToDir = (path) => {
1852
+ try {
1853
+ return statSync(path).isDirectory();
1854
+ } catch {
1855
+ return false;
1856
+ }
1857
+ };
1792
1858
  var collectFiles = (paths) => {
1793
1859
  const files = [];
1794
1860
  const unreadable = [];
1861
+ const symlinks = [];
1795
1862
  const seen = new Set;
1796
1863
  const walk = (dir) => {
1797
1864
  let entries;
@@ -1803,7 +1870,11 @@ var collectFiles = (paths) => {
1803
1870
  }
1804
1871
  for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
1805
1872
  const abs = join2(dir, entry.name);
1806
- if (entry.isDirectory()) {
1873
+ if (entry.isSymbolicLink()) {
1874
+ if (!skipDir(entry.name) && (DIAGRAM_FILE_EXTS.has(extname(entry.name).toLowerCase()) || linksToDir(abs))) {
1875
+ symlinks.push(abs);
1876
+ }
1877
+ } else if (entry.isDirectory()) {
1807
1878
  if (!skipDir(entry.name))
1808
1879
  walk(abs);
1809
1880
  } else if (entry.isFile() && DIAGRAM_FILE_EXTS.has(extname(entry.name).toLowerCase())) {
@@ -1828,7 +1899,7 @@ var collectFiles = (paths) => {
1828
1899
  unreadable.push(p);
1829
1900
  }
1830
1901
  }
1831
- return { files, unreadable };
1902
+ return { files, unreadable, symlinks };
1832
1903
  };
1833
1904
 
1834
1905
  // src/fix/verify.ts
@@ -1863,29 +1934,47 @@ var sameDiagrams = async (before, after) => {
1863
1934
  if (!now || was.keyword !== now.keyword)
1864
1935
  return false;
1865
1936
  const [a, b] = await Promise.all([was.stats(), now.stats()]);
1866
- const shape = (s) => [
1937
+ const shape = (s, source) => [
1938
+ parseFailed(source, s),
1867
1939
  s.diagram_type,
1868
1940
  s.nodes,
1869
1941
  s.edges,
1870
1942
  s.subgraphs,
1871
1943
  s.max_subgraph_depth,
1944
+ s.max_tree_depth,
1872
1945
  s.node_ids,
1873
1946
  s.subgraph_names
1874
1947
  ];
1875
- if (!sameJson(shape(a), shape(b)))
1948
+ if (!sameJson(shape(a, was.source), shape(b, now.source)))
1876
1949
  return false;
1877
1950
  }
1878
1951
  return true;
1879
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
+ };
1880
1964
  var verify = async (edit, before, after) => {
1881
1965
  if (edit.expect.kind === "same-tree") {
1882
- return sameTree(before.tree, after.tree, {}) ? "accept" : "refuse";
1966
+ if (!sameTree(before.tree, after.tree, {}))
1967
+ return "refuse";
1968
+ if (before.kind === "markdown" && after.kind === "markdown") {
1969
+ return await breaksAParse(before, after) ? "refuse" : "accept";
1970
+ }
1883
1971
  }
1884
1972
  return await sameDiagrams(before, after) ? "accept" : "refuse";
1885
1973
  };
1886
1974
 
1887
1975
  // src/fix/engine.ts
1888
1976
  var MAX_PASSES = 1000;
1977
+ var RECENT_PASSES = 50;
1889
1978
  var splice = (src, edits) => {
1890
1979
  let out = src;
1891
1980
  for (const e of [...edits].sort((a, b) => b.start - a.start))
@@ -1903,6 +1992,7 @@ var fixSourceReport = async (src, file, flags = {}, registry = BUILTIN) => {
1903
1992
  const refused = [];
1904
1993
  const refusedKeys = new Set;
1905
1994
  let current = src;
1995
+ const keptBy = [];
1906
1996
  for (let pass = 0;pass < MAX_PASSES; pass++) {
1907
1997
  const doc = buildDocModel(current, kind);
1908
1998
  const keyOf = (e) => `${e.rule}\x00${current.slice(e.start, e.end)}\x00${e.text}`;
@@ -1935,23 +2025,26 @@ var fixSourceReport = async (src, file, flags = {}, registry = BUILTIN) => {
1935
2025
  continue;
1936
2026
  if (edits.length > 1 && !overlaps(edits) && await attempt(edits)) {
1937
2027
  progressed = true;
1938
- break;
1939
- }
1940
- for (const edit of edits) {
1941
- if (await attempt([edit])) {
1942
- progressed = true;
1943
- break;
2028
+ } else {
2029
+ for (const edit of edits) {
2030
+ if (await attempt([edit])) {
2031
+ progressed = true;
2032
+ break;
2033
+ }
2034
+ refusedKeys.add(keyOf(edit));
2035
+ refused.push(edit);
1944
2036
  }
1945
- refusedKeys.add(keyOf(edit));
1946
- refused.push(edit);
1947
2037
  }
1948
- if (progressed)
2038
+ if (progressed) {
2039
+ keptBy.push(rule.id);
1949
2040
  break;
2041
+ }
1950
2042
  }
1951
2043
  if (!progressed)
1952
2044
  return { output: current, applied, refused };
1953
2045
  }
1954
- throw new Error(`fix did not converge after ${MAX_PASSES} passes; a fixer keeps proposing edits`);
2046
+ const lingering = [...new Set(keptBy.slice(-RECENT_PASSES))].sort();
2047
+ throw new FixNotConvergingError(file, MAX_PASSES, lingering);
1955
2048
  };
1956
2049
 
1957
2050
  // src/project.ts
@@ -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) {