@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.
- package/README.md +12 -9
- package/dist/bin.js +214 -64
- package/dist/index.js +217 -64
- package/dist/testing.js +99 -32
- package/dist/types/bin.d.ts +2 -0
- package/dist/types/check.d.ts +10 -0
- package/dist/types/cli.d.ts +9 -0
- package/dist/types/config.d.ts +20 -0
- package/dist/types/errors.d.ts +88 -0
- package/dist/types/files.d.ts +8 -0
- package/dist/types/fix/engine.d.ts +10 -0
- package/dist/types/fix/verify.d.ts +12 -0
- package/dist/types/index.d.ts +18 -0
- package/dist/types/model.d.ts +27 -0
- package/dist/types/parse/color.d.ts +28 -0
- package/dist/types/parse/contrast.d.ts +35 -0
- package/dist/types/parse/metrics.d.ts +48 -0
- package/dist/types/parse/stats.d.ts +25 -0
- package/dist/types/plugins/contract.d.ts +24 -0
- package/dist/types/plugins/discover.d.ts +20 -0
- package/dist/types/plugins/index.d.ts +19 -0
- package/dist/types/plugins/load.d.ts +14 -0
- package/dist/types/plugins/runtime.d.ts +6 -0
- package/dist/types/project.d.ts +18 -0
- package/dist/types/rules/complexity-node-count-acceptable-exceeded.d.ts +1 -0
- package/dist/types/rules/complexity-node-count-cognitive-limit.d.ts +1 -0
- package/dist/types/rules/complexity-node-count-hard-limit.d.ts +1 -0
- package/dist/types/rules/complexity-subgraph-nesting-too-deep.d.ts +2 -0
- package/dist/types/rules/complexity-visual-score-acceptable-exceeded.d.ts +1 -0
- package/dist/types/rules/complexity-visual-score-critical.d.ts +1 -0
- package/dist/types/rules/complexity.d.ts +12 -0
- package/dist/types/rules/contrast-border-below-ui-minimum.d.ts +2 -0
- package/dist/types/rules/contrast-directive-unauditable.d.ts +2 -0
- package/dist/types/rules/contrast-text-below-aa.d.ts +2 -0
- package/dist/types/rules/index.d.ts +3 -0
- package/dist/types/rules/parse-diagram-unparseable.d.ts +2 -0
- package/dist/types/rules/parse-fence-unterminated.d.ts +2 -0
- package/dist/types/rules/registry.d.ts +29 -0
- package/dist/types/rules/shared.d.ts +18 -0
- package/dist/types/rules/types.d.ts +67 -0
- package/dist/types/testing.d.ts +15 -0
- package/package.json +5 -4
- package/src/cli.ts +47 -15
- package/src/errors.ts +46 -0
- package/src/files.ts +21 -2
- package/src/fix/engine.ts +22 -10
- package/src/fix/verify.ts +31 -3
- package/src/index.ts +5 -3
- package/src/parse/metrics.ts +8 -1
- package/src/parse/stats.ts +72 -16
- package/src/plugins/contract.ts +4 -2
- package/src/plugins/discover.ts +38 -15
- package/src/plugins/index.ts +5 -3
- package/src/plugins/load.ts +32 -1
- package/src/project.ts +3 -1
- package/src/rules/complexity-subgraph-nesting-too-deep.ts +5 -0
- 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
|
-
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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
|
|
551
|
-
const
|
|
552
|
-
|
|
553
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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((
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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) =>
|
|
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
|
-
|
|
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,
|