@jpeakai/mermaidjs-lint 0.2.0 → 0.3.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 (54) hide show
  1. package/README.md +7 -5
  2. package/dist/bin.js +174 -55
  3. package/dist/index.js +177 -55
  4. package/dist/testing.js +59 -23
  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 +24 -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 +6 -1
  48. package/src/index.ts +5 -3
  49. package/src/parse/stats.ts +39 -11
  50. package/src/plugins/contract.ts +4 -2
  51. package/src/plugins/discover.ts +38 -15
  52. package/src/plugins/index.ts +5 -3
  53. package/src/plugins/load.ts +32 -1
  54. package/src/project.ts +3 -1
package/README.md CHANGED
@@ -70,17 +70,19 @@ It does not compare edge endpoints, edge labels or node text, so review the diff
70
70
 
71
71
  [`skills/mermaidjs-lint/`](skills/mermaidjs-lint/SKILL.md) is an agent skill that runs the linter, applies the safe fixes, then fixes what remains by judgement.
72
72
  It is not in the npm package, so copy it from this repository.
73
+ The skill is the one file `SKILL.md`.
74
+ Its `evals/` folder holds deliberately failing fixtures for this repository's own tests, so never copy it.
73
75
  For Claude Code, run these in your project:
74
76
 
75
77
  ```sh
76
- git clone --depth 1 https://github.com/jpeakai/jpai-mermaidjs-lint.git mermaidjs-lint-src
77
- mkdir -p .claude/skills
78
- cp -R mermaidjs-lint-src/skills/mermaidjs-lint .claude/skills/
78
+ mkdir -p .claude/skills/mermaidjs-lint
79
+ curl -fsSL https://raw.githubusercontent.com/jpeakai/jpai-mermaidjs-lint/main/skills/mermaidjs-lint/SKILL.md \
80
+ -o .claude/skills/mermaidjs-lint/SKILL.md
79
81
  ```
80
82
 
81
- Copy it to `~/.claude/skills/` instead to use it in every project, then delete the clone.
83
+ Use `~/.claude/skills/mermaidjs-lint` instead to use it in every project.
82
84
  Another agent can follow `SKILL.md` as plain instructions.
83
- [Agent evals](https://jpeakai.github.io/jpai-mermaidjs-lint/gallery/agent-evals/) shows how well agents fix the fixtures with it.
85
+ [Agent evals](https://jpeakai.github.io/jpai-mermaidjs-lint/gallery/agent-evals/) shows how well agents fixed the failing fixtures it lists, and which skill revision they used.
84
86
 
85
87
  ## Library use
86
88
 
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.3.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
  };
@@ -624,12 +625,10 @@ function adaptMindmap(db) {
624
625
  }
625
626
  function adaptQuadrantChart(db) {
626
627
  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 };
628
+ const textOf = (t) => typeof t === "string" ? t : t?.text ?? "";
629
+ const quadrants = (data?.quadrants ?? []).map((q) => textOf(q.text)).filter((t) => t.trim() !== "");
630
+ const points = (data?.points ?? []).map((p, i) => textOf(p.text) || `point_${i}`);
631
+ return { nodeIds: [...quadrants, ...points], edges: 0, subgraphNames: [], maxDepth: 0 };
633
632
  }
634
633
  function adaptRequirement(db) {
635
634
  const requirements = db.requirements instanceof Map ? db.requirements : new Map;
@@ -655,18 +654,30 @@ function adaptSankey(db) {
655
654
  function adaptState(db) {
656
655
  const nodes = db.nodes ?? [];
657
656
  const edges = db.edges ?? [];
657
+ const groups = nodes.filter((n) => n.isGroup === true);
658
+ const parentOf = new Map(nodes.map((n) => [n.id ?? "", n.parentId]));
659
+ let maxDepth = 0;
660
+ for (const g of groups) {
661
+ let depth = 1;
662
+ const seen = new Set;
663
+ for (let cur = g.parentId;cur && !seen.has(cur); cur = parentOf.get(cur)) {
664
+ seen.add(cur);
665
+ depth++;
666
+ }
667
+ maxDepth = Math.max(maxDepth, depth);
668
+ }
658
669
  return {
659
670
  nodeIds: nodes.map((n) => n.id ?? ""),
660
671
  edges: edges.length,
661
- subgraphNames: [],
662
- maxDepth: 0
672
+ subgraphNames: groups.map((g) => g.id ?? ""),
673
+ maxDepth
663
674
  };
664
675
  }
665
676
  function adaptTimeline(db) {
666
677
  const tasks = callGetter(db, "getTasks") ?? [];
667
678
  const sections = callGetter(db, "getSections") ?? [];
668
679
  return {
669
- nodeIds: tasks.map((t) => t.task ?? ""),
680
+ nodeIds: tasks.flatMap((t) => [t.task ?? "", ...(t.events ?? []).map((e) => `${t.task ?? ""}: ${e}`)]),
670
681
  edges: 0,
671
682
  subgraphNames: sections.map((s) => s.sectionName ?? s.section ?? ""),
672
683
  maxDepth: sections.length > 0 ? 1 : 0
@@ -696,7 +707,10 @@ function adaptVenn(db) {
696
707
  function adaptXychart(db) {
697
708
  const data = callGetter(db, "getXYChartData");
698
709
  const categories = data?.xAxis?.categories ?? [];
699
- return { nodeIds: categories, edges: 0, subgraphNames: [], maxDepth: 0 };
710
+ if (categories.length > 0)
711
+ return { nodeIds: categories, edges: 0, subgraphNames: [], maxDepth: 0 };
712
+ const longest = Math.max(0, ...(data?.plots ?? []).map((p) => p.data?.length ?? 0));
713
+ return { nodeIds: Array.from({ length: longest }, (_, i) => `x_${i}`), edges: 0, subgraphNames: [], maxDepth: 0 };
700
714
  }
701
715
  function subgraphNestingDepth(subGraphs) {
702
716
  if (subGraphs.length === 0)
@@ -923,6 +937,34 @@ class RuleOptionError extends UsageError {
923
937
  }
924
938
  }
925
939
 
940
+ class FixOutsideRootError extends UsageError {
941
+ file;
942
+ target;
943
+ 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");
945
+ this.file = file;
946
+ this.target = target;
947
+ }
948
+ }
949
+
950
+ class PackageJsonError extends MermaidLintError {
951
+ file;
952
+ constructor(file) {
953
+ super("package-json", `${file} is not a JSON object; fix or remove it`);
954
+ this.file = file;
955
+ }
956
+ }
957
+
958
+ class FixNotConvergingError extends MermaidLintError {
959
+ file;
960
+ rules;
961
+ constructor(file, passes, rules) {
962
+ 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`);
963
+ this.file = file;
964
+ this.rules = rules;
965
+ }
966
+ }
967
+
926
968
  class PluginLoadError extends MermaidLintError {
927
969
  source;
928
970
  constructor(source, reason, code = "plugin") {
@@ -1789,9 +1831,17 @@ import { extname, join as join2, resolve as resolve2 } from "node:path";
1789
1831
  var DIAGRAM_FILE_EXTS = new Set([...DIAGRAM_EXTS, ".md", ".markdown"]);
1790
1832
  var SKIP_DIRS = new Set(["node_modules", "vendor", "dist", "build", "tmp", "__pycache__"]);
1791
1833
  var skipDir = (name) => name.startsWith(".") || SKIP_DIRS.has(name);
1834
+ var linksToDir = (path) => {
1835
+ try {
1836
+ return statSync(path).isDirectory();
1837
+ } catch {
1838
+ return false;
1839
+ }
1840
+ };
1792
1841
  var collectFiles = (paths) => {
1793
1842
  const files = [];
1794
1843
  const unreadable = [];
1844
+ const symlinks = [];
1795
1845
  const seen = new Set;
1796
1846
  const walk = (dir) => {
1797
1847
  let entries;
@@ -1803,7 +1853,11 @@ var collectFiles = (paths) => {
1803
1853
  }
1804
1854
  for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
1805
1855
  const abs = join2(dir, entry.name);
1806
- if (entry.isDirectory()) {
1856
+ if (entry.isSymbolicLink()) {
1857
+ if (!skipDir(entry.name) && (DIAGRAM_FILE_EXTS.has(extname(entry.name).toLowerCase()) || linksToDir(abs))) {
1858
+ symlinks.push(abs);
1859
+ }
1860
+ } else if (entry.isDirectory()) {
1807
1861
  if (!skipDir(entry.name))
1808
1862
  walk(abs);
1809
1863
  } else if (entry.isFile() && DIAGRAM_FILE_EXTS.has(extname(entry.name).toLowerCase())) {
@@ -1828,7 +1882,7 @@ var collectFiles = (paths) => {
1828
1882
  unreadable.push(p);
1829
1883
  }
1830
1884
  }
1831
- return { files, unreadable };
1885
+ return { files, unreadable, symlinks };
1832
1886
  };
1833
1887
 
1834
1888
  // src/fix/verify.ts
@@ -1879,13 +1933,17 @@ var sameDiagrams = async (before, after) => {
1879
1933
  };
1880
1934
  var verify = async (edit, before, after) => {
1881
1935
  if (edit.expect.kind === "same-tree") {
1882
- return sameTree(before.tree, after.tree, {}) ? "accept" : "refuse";
1936
+ if (!sameTree(before.tree, after.tree, {}))
1937
+ return "refuse";
1938
+ if (before.kind === "markdown" && after.kind === "markdown")
1939
+ return "accept";
1883
1940
  }
1884
1941
  return await sameDiagrams(before, after) ? "accept" : "refuse";
1885
1942
  };
1886
1943
 
1887
1944
  // src/fix/engine.ts
1888
1945
  var MAX_PASSES = 1000;
1946
+ var RECENT_PASSES = 50;
1889
1947
  var splice = (src, edits) => {
1890
1948
  let out = src;
1891
1949
  for (const e of [...edits].sort((a, b) => b.start - a.start))
@@ -1903,6 +1961,7 @@ var fixSourceReport = async (src, file, flags = {}, registry = BUILTIN) => {
1903
1961
  const refused = [];
1904
1962
  const refusedKeys = new Set;
1905
1963
  let current = src;
1964
+ const keptBy = [];
1906
1965
  for (let pass = 0;pass < MAX_PASSES; pass++) {
1907
1966
  const doc = buildDocModel(current, kind);
1908
1967
  const keyOf = (e) => `${e.rule}\x00${current.slice(e.start, e.end)}\x00${e.text}`;
@@ -1935,23 +1994,26 @@ var fixSourceReport = async (src, file, flags = {}, registry = BUILTIN) => {
1935
1994
  continue;
1936
1995
  if (edits.length > 1 && !overlaps(edits) && await attempt(edits)) {
1937
1996
  progressed = true;
1938
- break;
1939
- }
1940
- for (const edit of edits) {
1941
- if (await attempt([edit])) {
1942
- progressed = true;
1943
- break;
1997
+ } else {
1998
+ for (const edit of edits) {
1999
+ if (await attempt([edit])) {
2000
+ progressed = true;
2001
+ break;
2002
+ }
2003
+ refusedKeys.add(keyOf(edit));
2004
+ refused.push(edit);
1944
2005
  }
1945
- refusedKeys.add(keyOf(edit));
1946
- refused.push(edit);
1947
2006
  }
1948
- if (progressed)
2007
+ if (progressed) {
2008
+ keptBy.push(rule.id);
1949
2009
  break;
2010
+ }
1950
2011
  }
1951
2012
  if (!progressed)
1952
2013
  return { output: current, applied, refused };
1953
2014
  }
1954
- throw new Error(`fix did not converge after ${MAX_PASSES} passes; a fixer keeps proposing edits`);
2015
+ const lingering = [...new Set(keptBy.slice(-RECENT_PASSES))].sort();
2016
+ throw new FixNotConvergingError(file, MAX_PASSES, lingering);
1955
2017
  };
1956
2018
 
1957
2019
  // src/project.ts
@@ -2053,7 +2115,7 @@ var readJson = async (path) => {
2053
2115
  if (isRecord2(value))
2054
2116
  return value;
2055
2117
  } catch {}
2056
- throw new Error(`${path} is not a JSON object`);
2118
+ throw new PackageJsonError(path);
2057
2119
  };
2058
2120
  var LOCAL_EXTENSIONS = new Set([".js", ".mjs", ".ts", ".mts", ".cts"]);
2059
2121
  var localSources = async (root) => {
@@ -2097,7 +2159,14 @@ var entryOf = (pkg) => {
2097
2159
  var resolvePackage = async (root, name) => {
2098
2160
  for (let dir = resolve3(root);; dir = dirname2(dir)) {
2099
2161
  const folder = join4(dir, "node_modules", name);
2100
- const pkg = await readJson(join4(folder, "package.json"));
2162
+ let pkg;
2163
+ try {
2164
+ pkg = await readJson(join4(folder, "package.json"));
2165
+ } catch (err) {
2166
+ if (!(err instanceof PackageJsonError))
2167
+ throw err;
2168
+ throw new PluginSourceError(name, `its package.json at ${err.file} is not a JSON object`);
2169
+ }
2101
2170
  if (pkg)
2102
2171
  return join4(folder, entryOf(pkg));
2103
2172
  if (dirname2(dir) === dir)
@@ -2105,8 +2174,15 @@ var resolvePackage = async (root, name) => {
2105
2174
  }
2106
2175
  throw new PluginNotInstalledError(name, root);
2107
2176
  };
2108
- var declaredPlugins = async (root) => {
2109
- const pkg = await readJson(join4(root, "package.json"));
2177
+ var declaredPlugins = async (root, tolerateBadManifest) => {
2178
+ let pkg;
2179
+ try {
2180
+ pkg = await readJson(join4(root, "package.json"));
2181
+ } catch (err) {
2182
+ if (tolerateBadManifest && err instanceof PackageJsonError)
2183
+ return [];
2184
+ throw err;
2185
+ }
2110
2186
  if (!pkg)
2111
2187
  return [];
2112
2188
  const names = ["dependencies", "devDependencies", "optionalDependencies"].flatMap((field) => {
@@ -2115,19 +2191,19 @@ var declaredPlugins = async (root) => {
2115
2191
  });
2116
2192
  return [...new Set(names)].filter((name) => PACKAGE_PATTERN.test(name)).sort();
2117
2193
  };
2118
- var discoverSources = async (root, config) => {
2194
+ var discoverSources = async (root, config, pluginsOff = false) => {
2119
2195
  const sources = await localSources(root);
2120
2196
  const failures = [];
2121
2197
  const add = async (kind, spec, resolveIt) => {
2122
2198
  try {
2123
2199
  sources.push({ kind, spec, path: await resolveIt() });
2124
2200
  } catch (err) {
2125
- if (!(err instanceof PluginNotInstalledError))
2201
+ if (!(err instanceof PluginLoadError))
2126
2202
  throw err;
2127
- failures.push(err);
2203
+ failures.push({ kind, error: err });
2128
2204
  }
2129
2205
  };
2130
- for (const name of await declaredPlugins(root))
2206
+ for (const name of await declaredPlugins(root, pluginsOff))
2131
2207
  await add("package", name, () => resolvePackage(root, name));
2132
2208
  for (const spec of config.plugins || []) {
2133
2209
  const path = spec.startsWith(".") || isAbsolute(spec);
@@ -2144,8 +2220,8 @@ import { pathToFileURL as pathToFileURL2 } from "node:url";
2144
2220
  // src/plugins/contract.ts
2145
2221
  var API_VERSION = 1;
2146
2222
  var CATEGORY_WORD = /^[a-z]+$/;
2147
- var NAMESPACE = /^[a-z][a-z0-9-]*$/;
2148
- var RULE_KEY = /^[a-z][a-z0-9-]*$/;
2223
+ var NAMESPACE = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
2224
+ var RULE_KEY = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
2149
2225
 
2150
2226
  // src/plugins/runtime.ts
2151
2227
  var canImportTypeScript = (runtime = process) => typeof runtime.versions.bun === "string" || Boolean(runtime.features.typescript);
@@ -2172,6 +2248,7 @@ var importModule = async (source, runtime) => {
2172
2248
  }
2173
2249
  };
2174
2250
  var OPTION_TYPES = new Set(["number", "string", "boolean", "string[]"]);
2251
+ var RESERVED_OPTIONS = ["preset", "profile"];
2175
2252
  var checkRule = (spec, key, raw, own) => {
2176
2253
  const where = `rule "${key}"`;
2177
2254
  if (!isRecord3(raw))
@@ -2201,6 +2278,11 @@ var checkRule = (spec, key, raw, own) => {
2201
2278
  if (!valid) {
2202
2279
  throw new PluginContractError(spec, `${where} options must map names to number, string, boolean or string[]`);
2203
2280
  }
2281
+ for (const name of RESERVED_OPTIONS) {
2282
+ if (name in options && options[name] !== "string") {
2283
+ 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`);
2284
+ }
2285
+ }
2204
2286
  }
2205
2287
  return raw;
2206
2288
  };
@@ -2252,7 +2334,18 @@ var loadLocal = async (sources, runtime) => {
2252
2334
  const categories = {};
2253
2335
  const owner = {};
2254
2336
  const loaded = [];
2337
+ const keyOf = (source) => basename(source.path, extname4(source.path));
2338
+ const byKey = new Map;
2339
+ for (const source of sources)
2340
+ byKey.set(keyOf(source), [...byKey.get(keyOf(source)) ?? [], source]);
2341
+ for (const [key, group] of byKey) {
2342
+ if (group.length < 2)
2343
+ continue;
2344
+ 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`));
2345
+ }
2255
2346
  for (const source of sources) {
2347
+ if ((byKey.get(keyOf(source))?.length ?? 0) > 1)
2348
+ continue;
2256
2349
  try {
2257
2350
  const mod = await importModule(source, runtime);
2258
2351
  if (mod.default === undefined) {
@@ -2266,7 +2359,7 @@ var loadLocal = async (sources, runtime) => {
2266
2359
  categories[word] = description;
2267
2360
  owner[word] = source.spec;
2268
2361
  }
2269
- loaded.push({ key: basename(source.path, extname4(source.path)), source, raw: mod.default });
2362
+ loaded.push({ key: keyOf(source), source, raw: mod.default });
2270
2363
  } catch (err) {
2271
2364
  fail(err);
2272
2365
  }
@@ -2330,7 +2423,7 @@ var toRule = (namespace, key, rule) => {
2330
2423
 
2331
2424
  // src/plugins/index.ts
2332
2425
  var loadPlugins = async (root, config, mode) => {
2333
- const { sources: found, failures: unresolved } = await discoverSources(root, config);
2426
+ const { sources: found, failures: unresolved } = await discoverSources(root, config, mode === "off");
2334
2427
  const set = {
2335
2428
  rules: [],
2336
2429
  categories: new Map(BUILTIN_CATEGORIES),
@@ -2338,16 +2431,17 @@ var loadPlugins = async (root, config, mode) => {
2338
2431
  skipped: [],
2339
2432
  failures: []
2340
2433
  };
2341
- if (mode === "off")
2342
- return { ...set, skipped: [...found.map((s) => s.spec), ...unresolved.map((e) => e.source)] };
2434
+ if (mode === "off") {
2435
+ return { ...set, skipped: [...found.map((s) => s.spec), ...unresolved.map((u) => u.error.source)] };
2436
+ }
2343
2437
  const settle = (error, kind) => {
2344
2438
  if (mode === "strict")
2345
2439
  throw error;
2346
2440
  if (!set.failures.some((f) => f.source === error.source))
2347
2441
  set.failures.push({ source: error.source, kind, error });
2348
2442
  };
2349
- for (const error of unresolved)
2350
- settle(error, "package");
2443
+ for (const { error, kind } of unresolved)
2444
+ settle(error, kind);
2351
2445
  const loaded = [];
2352
2446
  const locals = found.filter((s) => s.kind === "local");
2353
2447
  if (locals.length > 0) {
@@ -2444,7 +2538,7 @@ var setUp = async ({
2444
2538
  config,
2445
2539
  ignoreUnknownPluginIds: mode !== "strict"
2446
2540
  });
2447
- return { registry, skipped: plugins.skipped, failures: plugins.failures };
2541
+ return { root, registry, skipped: plugins.skipped, failures: plugins.failures };
2448
2542
  };
2449
2543
 
2450
2544
  // src/cli.ts
@@ -2453,17 +2547,21 @@ var SYNOPSIS = `usage: mermaidjs-lint <path...> [--fix] [--json] [--preset NAME]
2453
2547
  mermaidjs-lint --list-rules
2454
2548
  mermaidjs-lint --validate-plugins [--json] [--config FILE]
2455
2549
  mermaidjs-lint --version | -h, --help`;
2550
+ var ruleRow = (r, width) => `${`${r.id}${r.fix ? "*" : ""}`.padEnd(width)} ${r.summary}`;
2551
+ var idWidth = (rules) => Math.max(0, ...rules.map((r) => r.id.length + 1));
2456
2552
  var USAGE = `${SYNOPSIS}
2457
2553
 
2458
2554
  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(`
2555
+ ${CATEGORIES.map((c) => [` ${c}:`, ...RULES.filter((r) => r.category === c).map((r) => ` ${ruleRow(r, idWidth(RULES))}`)].join(`
2460
2556
  `)).join(`
2461
2557
  `)}
2462
2558
 
2463
2559
  A path is a file or a directory, and every path must exist. A file ending in
2464
2560
  ${DIAGRAM_EXTS.join(" or ")} is read as one raw diagram, and any other file is read as markdown
2465
2561
  whose mermaid fences are checked. A directory is walked for ${[...DIAGRAM_FILE_EXTS].join(", ")} files,
2466
- skipping dot-folders and ${[...SKIP_DIRS].join(", ")}.
2562
+ skipping dot-folders and ${[...SKIP_DIRS].join(", ")}. The walk does not follow a symbolic link,
2563
+ and names each one it skipped. A link named as a path is read, but --fix does not write
2564
+ through one whose target is outside the project root.
2467
2565
  Examples of every rule, with fixes: https://jpeakai.github.io/jpai-mermaidjs-lint/reference/rules/
2468
2566
 
2469
2567
  -p, --preset NAME sets the density for every complexity rule: ${PRESET_NAMES.join(", ")}
@@ -2474,14 +2572,16 @@ Examples of every rule, with fixes: https://jpeakai.github.io/jpai-mermaidjs-lin
2474
2572
  --fix applies every fix it can prove safe, then reports what remains.
2475
2573
  A fixer that is unsure leaves the text alone and the finding stays.
2476
2574
  --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-*.
2575
+ Plugins load on their own from .mermaidjs-lint/rules, from dependencies named mermaidjs-lint-plugin,
2576
+ mermaidjs-lint-plugin-* or @scope/mermaidjs-lint-plugin(-*), and from the config's plugins list.
2478
2577
  --no-plugins runs the built-in rules only and loads no plugin. It reads a .json config as usual,
2479
2578
  and stops with a usage error rather than import a .mjs config, which is code.
2480
2579
  --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.
2580
+ --validate-plugins imports and checks every plugin, reports every failure, and runs no check or fix.
2482
2581
  --list-rules prints every active rule by category, including plugin rules, and where each plugin came from.
2582
+ It imports the plugins to list them.
2483
2583
  --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
2584
+ Exits 0 when no findings remain, 1 when findings remain, a plugin fails to load or the run fails (a lenient run
2485
2585
  skips a broken plugin, so it alone does not exit 1), and 2 on a usage error.`;
2486
2586
  var parse2 = (argv) => parseArgs({
2487
2587
  args: argv,
@@ -2517,13 +2617,14 @@ var skippedLines = (failures, ignored) => [
2517
2617
  ];
2518
2618
  var listRules = (registry) => {
2519
2619
  const lines = [];
2620
+ const width = idWidth(registry.rules);
2520
2621
  for (const [category, description] of registry.categories) {
2521
2622
  const rules = registry.rules.filter((r) => r.category === category);
2522
2623
  if (rules.length === 0)
2523
2624
  continue;
2524
2625
  lines.push(`${category}: ${description}`);
2525
2626
  for (const r of rules)
2526
- lines.push(` ${r.id}${r.fix ? "*" : " "} ${r.summary}`);
2627
+ lines.push(` ${ruleRow(r, width)}`);
2527
2628
  }
2528
2629
  if (registry.sources.length > 0) {
2529
2630
  lines.push("", "plugins:");
@@ -2566,6 +2667,17 @@ var display = (file, cwd) => {
2566
2667
  const rel = relative(cwd, file);
2567
2668
  return !rel || rel.startsWith("..") ? file : rel;
2568
2669
  };
2670
+ var refuseOutsideLinks = async (files, root, cwd) => {
2671
+ const realRoot = await realpath(root);
2672
+ for (const file of files) {
2673
+ if (!(await lstat(file)).isSymbolicLink())
2674
+ continue;
2675
+ const target = await realpath(file);
2676
+ const rel = relative(realRoot, target);
2677
+ if (rel.startsWith("..") || isAbsolute2(rel))
2678
+ throw new FixOutsideRootError(display(file, cwd), target, root);
2679
+ }
2680
+ };
2569
2681
  var run = async (argv, cwd) => {
2570
2682
  let parsed;
2571
2683
  try {
@@ -2601,7 +2713,7 @@ var run = async (argv, cwd) => {
2601
2713
  }
2602
2714
  if (positionals.length === 0 && !listing && !validating)
2603
2715
  throw new FlagError("files", "no files given");
2604
- const { registry, skipped, failures } = await setUp({
2716
+ const { root, registry, skipped, failures } = await setUp({
2605
2717
  cwd,
2606
2718
  configPath: values.config,
2607
2719
  noPlugins: values["no-plugins"],
@@ -2625,11 +2737,16 @@ var run = async (argv, cwd) => {
2625
2737
  console.log(listRules(registry));
2626
2738
  return 0;
2627
2739
  }
2628
- const { files, unreadable } = collectFiles(positionals.map((p) => p.startsWith("/") ? p : `${cwd}/${p}`));
2740
+ const { files, unreadable, symlinks } = collectFiles(positionals.map((p) => resolve5(cwd, p)));
2629
2741
  if (unreadable.length > 0)
2630
2742
  throw new FlagError("files", `cannot read ${unreadable.join(", ")}`);
2743
+ for (const link of symlinks) {
2744
+ console.error(`mermaidjs-lint: skipped symbolic link ${display(link, cwd)}; name it as a path to check it`);
2745
+ }
2631
2746
  if (files.length === 0)
2632
2747
  throw new FlagError("files", "no matching files");
2748
+ if (values.fix)
2749
+ await refuseOutsideLinks(files, root, cwd);
2633
2750
  const flags = { preset: values.preset, profile: values.profile };
2634
2751
  const perFile = await Promise.all(files.map(async (file) => {
2635
2752
  let src = await readFile3(file, "utf8");
@@ -2640,7 +2757,9 @@ var run = async (argv, cwd) => {
2640
2757
  src = fixed;
2641
2758
  }
2642
2759
  }
2643
- return checkModel(buildDocModel(src, documentKind(file)), display(file, cwd), flags, registry);
2760
+ const shown = display(file, cwd);
2761
+ const found = await checkModel(buildDocModel(src, documentKind(file)), file, flags, registry);
2762
+ return found.map((f) => ({ ...f, file: shown }));
2644
2763
  }));
2645
2764
  const all = perFile.flat();
2646
2765
  if (values.json) {