@jpeakai/mermaidjs-lint 0.3.0 → 0.4.1
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 +5 -4
- package/dist/bin.js +234 -196
- package/dist/index.js +234 -196
- package/dist/testing.js +129 -79
- package/dist/types/check.d.ts +1 -0
- package/dist/types/parse/stats.d.ts +1 -0
- package/package.json +1 -1
- package/src/check.ts +20 -0
- package/src/cli.ts +4 -8
- package/src/errors.ts +1 -1
- package/src/fix/engine.ts +2 -1
- package/src/fix/verify.ts +27 -4
- package/src/parse/metrics.ts +8 -1
- package/src/parse/stats.ts +33 -5
- package/src/rules/complexity-subgraph-nesting-too-deep.ts +5 -0
- package/src/rules/parse-fence-unterminated.ts +1 -1
package/dist/testing.js
CHANGED
|
@@ -1,6 +1,82 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
2
|
var __require = /* @__PURE__ */ createRequire(import.meta.url);
|
|
3
3
|
|
|
4
|
+
// src/errors.ts
|
|
5
|
+
class MermaidLintError extends Error {
|
|
6
|
+
code;
|
|
7
|
+
constructor(code, message) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.code = code;
|
|
10
|
+
this.name = new.target.name;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
class UsageError extends MermaidLintError {
|
|
15
|
+
constructor(message, code = "usage") {
|
|
16
|
+
super(code, message);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
class FlagError extends UsageError {
|
|
21
|
+
flag;
|
|
22
|
+
constructor(flag, message) {
|
|
23
|
+
super(message, "flag");
|
|
24
|
+
this.flag = flag;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
class UnknownRuleError extends UsageError {
|
|
28
|
+
file;
|
|
29
|
+
rule;
|
|
30
|
+
known;
|
|
31
|
+
constructor(file, rule, known) {
|
|
32
|
+
super(`${file}: unknown rule "${rule}"; a retired or misspelt id cannot be switched off. Known rules: ${known.join(", ")}`, "unknown-rule");
|
|
33
|
+
this.file = file;
|
|
34
|
+
this.rule = rule;
|
|
35
|
+
this.known = known;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
class RuleOptionError extends UsageError {
|
|
40
|
+
file;
|
|
41
|
+
rule;
|
|
42
|
+
option;
|
|
43
|
+
constructor(file, rule, option, message) {
|
|
44
|
+
super(`${file}: ${message}`, "rule-option");
|
|
45
|
+
this.file = file;
|
|
46
|
+
this.rule = rule;
|
|
47
|
+
this.option = option;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
class FixNotConvergingError extends MermaidLintError {
|
|
51
|
+
file;
|
|
52
|
+
rules;
|
|
53
|
+
constructor(file, passes, rules) {
|
|
54
|
+
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`);
|
|
55
|
+
this.file = file;
|
|
56
|
+
this.rules = rules;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
class PluginLoadError extends MermaidLintError {
|
|
61
|
+
source;
|
|
62
|
+
constructor(source, reason, code = "plugin") {
|
|
63
|
+
super(code, `plugin ${source}: ${reason}`);
|
|
64
|
+
this.source = source;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
class PluginContractError extends PluginLoadError {
|
|
68
|
+
constructor(source, reason) {
|
|
69
|
+
super(source, reason, "plugin-contract");
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
class PluginFixerError extends MermaidLintError {
|
|
73
|
+
rule;
|
|
74
|
+
constructor(rule, reason) {
|
|
75
|
+
super("plugin-fixer", `plugin rule ${rule} ${reason}`);
|
|
76
|
+
this.rule = rule;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
4
80
|
// src/model.ts
|
|
5
81
|
import { fromMarkdown } from "mdast-util-from-markdown";
|
|
6
82
|
import { frontmatterFromMarkdown } from "mdast-util-frontmatter";
|
|
@@ -203,6 +279,7 @@ async function extractStats(content) {
|
|
|
203
279
|
edges: 0,
|
|
204
280
|
subgraphs: 0,
|
|
205
281
|
max_subgraph_depth: 0,
|
|
282
|
+
max_tree_depth: 0,
|
|
206
283
|
node_ids: [],
|
|
207
284
|
subgraph_names: [],
|
|
208
285
|
parser_used: langium ? "langium" : "mermaid-core",
|
|
@@ -252,6 +329,7 @@ async function extractLangiumStats(content, keyword) {
|
|
|
252
329
|
const subgraphNames = [];
|
|
253
330
|
let edges = 0;
|
|
254
331
|
let maxDepth = 0;
|
|
332
|
+
let treeDepth = 0;
|
|
255
333
|
switch (mapped) {
|
|
256
334
|
case "architecture": {
|
|
257
335
|
const services = ast.services ?? [];
|
|
@@ -298,7 +376,7 @@ async function extractLangiumStats(content, keyword) {
|
|
|
298
376
|
const stats = indentTreeStats(nodes.map((n, i) => ({ label: n.name ?? `node_${i}`, indent: n.indent ?? 0 })));
|
|
299
377
|
nodeIds.push(...stats.labels);
|
|
300
378
|
edges = stats.edges;
|
|
301
|
-
|
|
379
|
+
treeDepth = stats.depth;
|
|
302
380
|
break;
|
|
303
381
|
}
|
|
304
382
|
case "treemap": {
|
|
@@ -306,7 +384,7 @@ async function extractLangiumStats(content, keyword) {
|
|
|
306
384
|
const stats = indentTreeStats(rows.map((r, i) => ({ label: r.item?.name ?? `row_${i}`, indent: r.indent ?? 0 })));
|
|
307
385
|
nodeIds.push(...stats.labels);
|
|
308
386
|
edges = stats.edges;
|
|
309
|
-
|
|
387
|
+
treeDepth = stats.depth;
|
|
310
388
|
break;
|
|
311
389
|
}
|
|
312
390
|
case "wardley": {
|
|
@@ -326,6 +404,7 @@ async function extractLangiumStats(content, keyword) {
|
|
|
326
404
|
edges,
|
|
327
405
|
subgraphs: subgraphNames.length,
|
|
328
406
|
max_subgraph_depth: maxDepth,
|
|
407
|
+
max_tree_depth: treeDepth,
|
|
329
408
|
node_ids: [...new Set(nodeIds.filter(Boolean))].sort(),
|
|
330
409
|
subgraph_names: subgraphNames,
|
|
331
410
|
parser_used: "langium",
|
|
@@ -376,12 +455,13 @@ async function extractCoreStats(content, keyword) {
|
|
|
376
455
|
if (!adapter) {
|
|
377
456
|
throw new Error(`no core adapter for diagram.type="${diagramType}" (keyword="${keyword}")`);
|
|
378
457
|
}
|
|
379
|
-
const { nodeIds, edges, subgraphNames, maxDepth } = adapter(diagram.db);
|
|
458
|
+
const { nodeIds, edges, subgraphNames, maxDepth, treeDepth = 0 } = adapter(diagram.db);
|
|
380
459
|
return {
|
|
381
460
|
nodes: nodeIds.length,
|
|
382
461
|
edges,
|
|
383
462
|
subgraphs: subgraphNames.length,
|
|
384
463
|
max_subgraph_depth: maxDepth,
|
|
464
|
+
max_tree_depth: treeDepth,
|
|
385
465
|
node_ids: [...nodeIds].sort(),
|
|
386
466
|
subgraph_names: subgraphNames,
|
|
387
467
|
parser_used: "mermaid-core",
|
|
@@ -392,6 +472,7 @@ async function extractCoreStats(content, keyword) {
|
|
|
392
472
|
var CORE_ADAPTERS = {
|
|
393
473
|
"flowchart-v2": adaptFlowchart,
|
|
394
474
|
flowchart: adaptFlowchart,
|
|
475
|
+
"flowchart-elk": adaptFlowchart,
|
|
395
476
|
graph: adaptFlowchart,
|
|
396
477
|
classDiagram: adaptClass,
|
|
397
478
|
"classDiagram-v2": adaptClass,
|
|
@@ -515,7 +596,13 @@ function adaptIshikawa(db) {
|
|
|
515
596
|
walk(c, depth + 1);
|
|
516
597
|
};
|
|
517
598
|
walk(root, 0);
|
|
518
|
-
return {
|
|
599
|
+
return {
|
|
600
|
+
nodeIds: labels,
|
|
601
|
+
edges: Math.max(0, labels.length - 1),
|
|
602
|
+
subgraphNames: [],
|
|
603
|
+
maxDepth: 0,
|
|
604
|
+
treeDepth: maxDepth
|
|
605
|
+
};
|
|
519
606
|
}
|
|
520
607
|
function adaptKanban(db) {
|
|
521
608
|
const data = callGetter(db, "getData");
|
|
@@ -543,7 +630,13 @@ function adaptMindmap(db) {
|
|
|
543
630
|
walk(c, depth + 1);
|
|
544
631
|
};
|
|
545
632
|
walk(root, 0);
|
|
546
|
-
return {
|
|
633
|
+
return {
|
|
634
|
+
nodeIds: labels,
|
|
635
|
+
edges: Math.max(0, labels.length - 1),
|
|
636
|
+
subgraphNames: [],
|
|
637
|
+
maxDepth: 0,
|
|
638
|
+
treeDepth: maxDepth
|
|
639
|
+
};
|
|
547
640
|
}
|
|
548
641
|
function adaptQuadrantChart(db) {
|
|
549
642
|
const data = callGetter(db, "getQuadrantData");
|
|
@@ -759,74 +852,6 @@ var buildDocModel = (src, kind = "markdown") => {
|
|
|
759
852
|
return { src, kind, tree, diagrams };
|
|
760
853
|
};
|
|
761
854
|
|
|
762
|
-
// src/errors.ts
|
|
763
|
-
class MermaidLintError extends Error {
|
|
764
|
-
code;
|
|
765
|
-
constructor(code, message) {
|
|
766
|
-
super(message);
|
|
767
|
-
this.code = code;
|
|
768
|
-
this.name = new.target.name;
|
|
769
|
-
}
|
|
770
|
-
}
|
|
771
|
-
|
|
772
|
-
class UsageError extends MermaidLintError {
|
|
773
|
-
constructor(message, code = "usage") {
|
|
774
|
-
super(code, message);
|
|
775
|
-
}
|
|
776
|
-
}
|
|
777
|
-
class UnknownRuleError extends UsageError {
|
|
778
|
-
file;
|
|
779
|
-
rule;
|
|
780
|
-
known;
|
|
781
|
-
constructor(file, rule, known) {
|
|
782
|
-
super(`${file}: unknown rule "${rule}"; a retired or misspelt id cannot be switched off. Known rules: ${known.join(", ")}`, "unknown-rule");
|
|
783
|
-
this.file = file;
|
|
784
|
-
this.rule = rule;
|
|
785
|
-
this.known = known;
|
|
786
|
-
}
|
|
787
|
-
}
|
|
788
|
-
|
|
789
|
-
class RuleOptionError extends UsageError {
|
|
790
|
-
file;
|
|
791
|
-
rule;
|
|
792
|
-
option;
|
|
793
|
-
constructor(file, rule, option, message) {
|
|
794
|
-
super(`${file}: ${message}`, "rule-option");
|
|
795
|
-
this.file = file;
|
|
796
|
-
this.rule = rule;
|
|
797
|
-
this.option = option;
|
|
798
|
-
}
|
|
799
|
-
}
|
|
800
|
-
class FixNotConvergingError extends MermaidLintError {
|
|
801
|
-
file;
|
|
802
|
-
rules;
|
|
803
|
-
constructor(file, passes, rules) {
|
|
804
|
-
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`);
|
|
805
|
-
this.file = file;
|
|
806
|
-
this.rules = rules;
|
|
807
|
-
}
|
|
808
|
-
}
|
|
809
|
-
|
|
810
|
-
class PluginLoadError extends MermaidLintError {
|
|
811
|
-
source;
|
|
812
|
-
constructor(source, reason, code = "plugin") {
|
|
813
|
-
super(code, `plugin ${source}: ${reason}`);
|
|
814
|
-
this.source = source;
|
|
815
|
-
}
|
|
816
|
-
}
|
|
817
|
-
class PluginContractError extends PluginLoadError {
|
|
818
|
-
constructor(source, reason) {
|
|
819
|
-
super(source, reason, "plugin-contract");
|
|
820
|
-
}
|
|
821
|
-
}
|
|
822
|
-
class PluginFixerError extends MermaidLintError {
|
|
823
|
-
rule;
|
|
824
|
-
constructor(rule, reason) {
|
|
825
|
-
super("plugin-fixer", `plugin rule ${rule} ${reason}`);
|
|
826
|
-
this.rule = rule;
|
|
827
|
-
}
|
|
828
|
-
}
|
|
829
|
-
|
|
830
855
|
// src/parse/contrast.ts
|
|
831
856
|
import { existsSync } from "node:fs";
|
|
832
857
|
import { dirname, join, resolve } from "node:path";
|
|
@@ -1168,7 +1193,7 @@ var calculateComplexity = (stats, config) => {
|
|
|
1168
1193
|
const n = stats.nodes;
|
|
1169
1194
|
const e = stats.edges;
|
|
1170
1195
|
const s = stats.subgraphs;
|
|
1171
|
-
const d = stats.max_subgraph_depth;
|
|
1196
|
+
const d = Math.max(stats.max_subgraph_depth, stats.max_tree_depth);
|
|
1172
1197
|
const base = n + e * config.edge_weight + s * config.subgraph_weight;
|
|
1173
1198
|
const vcs = base * (1 + d * config.depth_weight);
|
|
1174
1199
|
const maxEdges = n > 1 ? n * (n - 1) : 0;
|
|
@@ -1505,7 +1530,7 @@ var parseFenceUnterminated = {
|
|
|
1505
1530
|
id: RULE.FENCE_UNTERMINATED,
|
|
1506
1531
|
category: "parse",
|
|
1507
1532
|
summary: "a mermaid fence that is never closed",
|
|
1508
|
-
check: (ctx) => ctx.doc.diagrams.filter((d) => !d.terminated).map((d) => finding(ctx, d, RULE.FENCE_UNTERMINATED, `mermaid fence opened at line ${d.startLine} is never closed`, "Add a closing fence after the last line of the diagram.")),
|
|
1533
|
+
check: (ctx) => ctx.doc.diagrams.filter((d) => !d.terminated).map((d) => finding(ctx, d, RULE.FENCE_UNTERMINATED, `mermaid fence opened at line ${d.startLine} is never closed`, "Add a closing fence directly after the last line of the diagram, with the opening line's prefix: `> ` in a quote, the item's indent in a list.")),
|
|
1509
1534
|
fix: (ctx) => {
|
|
1510
1535
|
const edits = [];
|
|
1511
1536
|
const { src } = ctx.doc;
|
|
@@ -1621,6 +1646,15 @@ var buildRegistry = (input) => {
|
|
|
1621
1646
|
var BUILTIN = buildRegistry({ rules: RULES, fixOrder: FIX_ORDER, categories: BUILTIN_CATEGORIES });
|
|
1622
1647
|
|
|
1623
1648
|
// src/check.ts
|
|
1649
|
+
var checkRunFlags = (flags, prefix = "") => {
|
|
1650
|
+
const { preset, profile } = flags;
|
|
1651
|
+
if (preset !== undefined && canonicalPreset(preset) === null) {
|
|
1652
|
+
throw new FlagError(`${prefix}preset`, `${prefix}preset must be one of ${PRESET_NAMES.join(", ")}, not "${preset}"`);
|
|
1653
|
+
}
|
|
1654
|
+
if (profile !== undefined && !PROFILES.includes(profile)) {
|
|
1655
|
+
throw new FlagError(`${prefix}profile`, `${prefix}profile must be one of ${PROFILES.join(", ")}, not "${profile}"`);
|
|
1656
|
+
}
|
|
1657
|
+
};
|
|
1624
1658
|
var optionsFor = (rule, configured, flags) => {
|
|
1625
1659
|
const options = { ...configured };
|
|
1626
1660
|
for (const name of ["preset", "profile"]) {
|
|
@@ -1631,6 +1665,7 @@ var optionsFor = (rule, configured, flags) => {
|
|
|
1631
1665
|
return options;
|
|
1632
1666
|
};
|
|
1633
1667
|
var checkModel = async (doc, file, flags = {}, registry = BUILTIN) => {
|
|
1668
|
+
checkRunFlags(flags);
|
|
1634
1669
|
const perRule = registry.rules.map((rule) => rule.check({ doc, file, options: optionsFor(rule, registry.options.get(rule.id), flags), helpers: HELPERS }));
|
|
1635
1670
|
const findings = (await Promise.all(perRule)).flat();
|
|
1636
1671
|
return findings.sort((a, b) => a.line - b.line || a.rule.localeCompare(b.rule));
|
|
@@ -1669,26 +1704,40 @@ var sameDiagrams = async (before, after) => {
|
|
|
1669
1704
|
if (!now || was.keyword !== now.keyword)
|
|
1670
1705
|
return false;
|
|
1671
1706
|
const [a, b] = await Promise.all([was.stats(), now.stats()]);
|
|
1672
|
-
const shape = (s) => [
|
|
1707
|
+
const shape = (s, source) => [
|
|
1708
|
+
parseFailed(source, s),
|
|
1673
1709
|
s.diagram_type,
|
|
1674
1710
|
s.nodes,
|
|
1675
1711
|
s.edges,
|
|
1676
1712
|
s.subgraphs,
|
|
1677
1713
|
s.max_subgraph_depth,
|
|
1714
|
+
s.max_tree_depth,
|
|
1678
1715
|
s.node_ids,
|
|
1679
1716
|
s.subgraph_names
|
|
1680
1717
|
];
|
|
1681
|
-
if (!sameJson(shape(a), shape(b)))
|
|
1718
|
+
if (!sameJson(shape(a, was.source), shape(b, now.source)))
|
|
1682
1719
|
return false;
|
|
1683
1720
|
}
|
|
1684
1721
|
return true;
|
|
1685
1722
|
};
|
|
1723
|
+
var breaksAParse = async (before, after) => {
|
|
1724
|
+
for (const [i, was] of before.diagrams.entries()) {
|
|
1725
|
+
const now = after.diagrams[i];
|
|
1726
|
+
if (!now)
|
|
1727
|
+
continue;
|
|
1728
|
+
const [a, b] = await Promise.all([was.stats(), now.stats()]);
|
|
1729
|
+
if (!parseFailed(was.source, a) && parseFailed(now.source, b))
|
|
1730
|
+
return true;
|
|
1731
|
+
}
|
|
1732
|
+
return false;
|
|
1733
|
+
};
|
|
1686
1734
|
var verify = async (edit, before, after) => {
|
|
1687
1735
|
if (edit.expect.kind === "same-tree") {
|
|
1688
1736
|
if (!sameTree(before.tree, after.tree, {}))
|
|
1689
1737
|
return "refuse";
|
|
1690
|
-
if (before.kind === "markdown" && after.kind === "markdown")
|
|
1691
|
-
return "accept";
|
|
1738
|
+
if (before.kind === "markdown" && after.kind === "markdown") {
|
|
1739
|
+
return await breaksAParse(before, after) ? "refuse" : "accept";
|
|
1740
|
+
}
|
|
1692
1741
|
}
|
|
1693
1742
|
return await sameDiagrams(before, after) ? "accept" : "refuse";
|
|
1694
1743
|
};
|
|
@@ -1708,6 +1757,7 @@ var overlaps = (edits) => {
|
|
|
1708
1757
|
};
|
|
1709
1758
|
var wellFormed = (e, length) => Number.isInteger(e.start) && Number.isInteger(e.end) && e.start >= 0 && e.start <= e.end && e.end <= length && typeof e.text === "string";
|
|
1710
1759
|
var fixSourceReport = async (src, file, flags = {}, registry = BUILTIN) => {
|
|
1760
|
+
checkRunFlags(flags);
|
|
1711
1761
|
const kind = documentKind(file);
|
|
1712
1762
|
const applied = [];
|
|
1713
1763
|
const refused = [];
|
package/dist/types/check.d.ts
CHANGED
|
@@ -5,6 +5,7 @@ export interface RunFlags {
|
|
|
5
5
|
preset?: string;
|
|
6
6
|
profile?: string;
|
|
7
7
|
}
|
|
8
|
+
export declare const checkRunFlags: (flags: RunFlags, prefix?: string) => void;
|
|
8
9
|
export declare const optionsFor: (rule: Rule, configured: RuleOptions | undefined, flags: RunFlags) => RuleOptions;
|
|
9
10
|
export declare const checkModel: (doc: DocModel, file: string, flags?: RunFlags, registry?: Registry) => Promise<Finding[]>;
|
|
10
11
|
export declare const checkMarkdown: (src: string, file: string, flags?: RunFlags, registry?: Registry) => Promise<Finding[]>;
|
package/package.json
CHANGED
package/src/check.ts
CHANGED
|
@@ -4,7 +4,10 @@
|
|
|
4
4
|
// them. A rule may be synchronous or return a promise, so a rule that waits on
|
|
5
5
|
// a tool or a service overlaps with the others.
|
|
6
6
|
|
|
7
|
+
import { FlagError } from "./errors.ts";
|
|
7
8
|
import { buildDocModel, type DocModel, documentKind } from "./model.ts";
|
|
9
|
+
import { PROFILES } from "./parse/contrast.ts";
|
|
10
|
+
import { canonicalPreset, PRESET_NAMES } from "./parse/metrics.ts";
|
|
8
11
|
import { BUILTIN, HELPERS, type Registry } from "./rules/registry.ts";
|
|
9
12
|
import type { Finding, Rule, RuleOptions } from "./rules/types.ts";
|
|
10
13
|
|
|
@@ -15,6 +18,22 @@ export interface RunFlags {
|
|
|
15
18
|
profile?: string;
|
|
16
19
|
}
|
|
17
20
|
|
|
21
|
+
// A preset or profile that no rule can take fails as a usage error, with the
|
|
22
|
+
// code the command gives a bad flag, so a library caller can branch on it. The
|
|
23
|
+
// command passes "--" as the prefix, so the message names its own flag.
|
|
24
|
+
export const checkRunFlags = (flags: RunFlags, prefix = ""): void => {
|
|
25
|
+
const { preset, profile } = flags;
|
|
26
|
+
if (preset !== undefined && canonicalPreset(preset) === null) {
|
|
27
|
+
throw new FlagError(
|
|
28
|
+
`${prefix}preset`,
|
|
29
|
+
`${prefix}preset must be one of ${PRESET_NAMES.join(", ")}, not "${preset}"`,
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
if (profile !== undefined && !PROFILES.includes(profile as (typeof PROFILES)[number])) {
|
|
33
|
+
throw new FlagError(`${prefix}profile`, `${prefix}profile must be one of ${PROFILES.join(", ")}, not "${profile}"`);
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
|
|
18
37
|
// The options a rule runs with: its configured options, then a flag for any
|
|
19
38
|
// option the rule declares.
|
|
20
39
|
export const optionsFor = (rule: Rule, configured: RuleOptions | undefined, flags: RunFlags): RuleOptions => {
|
|
@@ -32,6 +51,7 @@ export const checkModel = async (
|
|
|
32
51
|
flags: RunFlags = {},
|
|
33
52
|
registry: Registry = BUILTIN,
|
|
34
53
|
): Promise<Finding[]> => {
|
|
54
|
+
checkRunFlags(flags);
|
|
35
55
|
const perRule = registry.rules.map((rule) =>
|
|
36
56
|
rule.check({ doc, file, options: optionsFor(rule, registry.options.get(rule.id), flags), helpers: HELPERS }),
|
|
37
57
|
);
|
package/src/cli.ts
CHANGED
|
@@ -5,13 +5,13 @@ import { lstat, readFile, realpath, writeFile } from "node:fs/promises";
|
|
|
5
5
|
import { isAbsolute, relative, resolve } from "node:path";
|
|
6
6
|
import { parseArgs } from "node:util";
|
|
7
7
|
import pkg from "../package.json" with { type: "json" };
|
|
8
|
-
import { checkModel, type RunFlags } from "./check.ts";
|
|
8
|
+
import { checkModel, checkRunFlags, type RunFlags } from "./check.ts";
|
|
9
9
|
import { FixOutsideRootError, FlagError, MermaidLintError, UsageError } from "./errors.ts";
|
|
10
10
|
import { collectFiles, DIAGRAM_FILE_EXTS, SKIP_DIRS } from "./files.ts";
|
|
11
11
|
import { fixSourceReport } from "./fix/engine.ts";
|
|
12
12
|
import { buildDocModel, DIAGRAM_EXTS, documentKind } from "./model.ts";
|
|
13
13
|
import { PROFILES } from "./parse/contrast.ts";
|
|
14
|
-
import {
|
|
14
|
+
import { PRESET_NAMES } from "./parse/metrics.ts";
|
|
15
15
|
import type { PluginFailure } from "./plugins/index.ts";
|
|
16
16
|
import { setUp } from "./project.ts";
|
|
17
17
|
import { RULES } from "./rules/index.ts";
|
|
@@ -52,6 +52,7 @@ Examples of every rule, with fixes: https://jpeakai.github.io/jpai-mermaidjs-lin
|
|
|
52
52
|
numeric threshold set in the config overrides both.
|
|
53
53
|
--profile NAME sets how contrast is read: ${PROFILES.join(", ")}. It overrides a profile set in the config.
|
|
54
54
|
auto reads mkdocs-material when any parent folder holds mkdocs.yml or mkdocs.yaml, else github.
|
|
55
|
+
--json prints one JSON object instead of text lines.
|
|
55
56
|
--fix applies every fix it can prove safe, then reports what remains.
|
|
56
57
|
A fixer that is unsure leaves the text alone and the finding stays.
|
|
57
58
|
--config FILE reads that config instead of mermaidjs-lint.config.json or .mjs at the project root.
|
|
@@ -220,12 +221,7 @@ const run = async (argv: string[], cwd: string): Promise<number> => {
|
|
|
220
221
|
}
|
|
221
222
|
const listing = values["list-rules"];
|
|
222
223
|
const validating = values["validate-plugins"];
|
|
223
|
-
|
|
224
|
-
throw new FlagError("--preset", `--preset must be one of ${PRESET_NAMES.join(", ")}, not "${values.preset}"`);
|
|
225
|
-
}
|
|
226
|
-
if (values.profile !== undefined && !PROFILES.includes(values.profile as (typeof PROFILES)[number])) {
|
|
227
|
-
throw new FlagError("--profile", `--profile must be one of ${PROFILES.join(", ")}, not "${values.profile}"`);
|
|
228
|
-
}
|
|
224
|
+
checkRunFlags({ preset: values.preset, profile: values.profile }, "--");
|
|
229
225
|
if (values["no-plugins"] && values["lenient-plugins"]) {
|
|
230
226
|
throw new FlagError("--lenient-plugins", "--no-plugins and --lenient-plugins contradict each other");
|
|
231
227
|
}
|
package/src/errors.ts
CHANGED
|
@@ -138,7 +138,7 @@ export class FixOutsideRootError extends UsageError {
|
|
|
138
138
|
|
|
139
139
|
constructor(file: string, target: string, root: string) {
|
|
140
140
|
super(
|
|
141
|
-
`${file} is a symbolic link to ${target}, outside the project root ${root}; --fix does not write through it. Fix the target in its own project, or
|
|
141
|
+
`${file} is a symbolic link to ${target}, outside the project root ${root}; --fix does not write through it. Fix the target in its own project, or run on the target path itself`,
|
|
142
142
|
"fix-outside-root",
|
|
143
143
|
);
|
|
144
144
|
this.file = file;
|
package/src/fix/engine.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// or single edit that survives verification. Offsets are only valid for the source they were
|
|
4
4
|
// computed from, so every accepted edit restarts the pass.
|
|
5
5
|
|
|
6
|
-
import { optionsFor, type RunFlags } from "../check.ts";
|
|
6
|
+
import { checkRunFlags, optionsFor, type RunFlags } from "../check.ts";
|
|
7
7
|
import { FixNotConvergingError } from "../errors.ts";
|
|
8
8
|
import { buildDocModel, documentKind } from "../model.ts";
|
|
9
9
|
import { BUILTIN, HELPERS, type Registry } from "../rules/registry.ts";
|
|
@@ -46,6 +46,7 @@ export const fixSourceReport = async (
|
|
|
46
46
|
flags: RunFlags = {},
|
|
47
47
|
registry: Registry = BUILTIN,
|
|
48
48
|
): Promise<FixReport> => {
|
|
49
|
+
checkRunFlags(flags);
|
|
49
50
|
const kind = documentKind(file);
|
|
50
51
|
const applied: Edit[] = [];
|
|
51
52
|
const refused: Edit[] = [];
|
package/src/fix/verify.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import type { Node, Root } from "mdast";
|
|
6
6
|
import type { DocModel } from "../model.ts";
|
|
7
|
+
import { parseFailed } from "../parse/metrics.ts";
|
|
7
8
|
import type { Edit } from "../rules/types.ts";
|
|
8
9
|
|
|
9
10
|
type Plain = { [k: string]: unknown };
|
|
@@ -43,28 +44,50 @@ const sameDiagrams = async (before: DocModel, after: DocModel): Promise<boolean>
|
|
|
43
44
|
const now = after.diagrams[i];
|
|
44
45
|
if (!now || was.keyword !== now.keyword) return false;
|
|
45
46
|
const [a, b] = await Promise.all([was.stats(), now.stats()]);
|
|
46
|
-
const shape = (s: typeof a): unknown => [
|
|
47
|
+
const shape = (s: typeof a, source: string): unknown => [
|
|
48
|
+
parseFailed(source, s),
|
|
47
49
|
s.diagram_type,
|
|
48
50
|
s.nodes,
|
|
49
51
|
s.edges,
|
|
50
52
|
s.subgraphs,
|
|
51
53
|
s.max_subgraph_depth,
|
|
54
|
+
s.max_tree_depth,
|
|
52
55
|
s.node_ids,
|
|
53
56
|
s.subgraph_names,
|
|
54
57
|
];
|
|
55
|
-
if (!sameJson(shape(a), shape(b))) return false;
|
|
58
|
+
if (!sameJson(shape(a, was.source), shape(b, now.source))) return false;
|
|
56
59
|
}
|
|
57
60
|
return true;
|
|
58
61
|
};
|
|
59
62
|
|
|
63
|
+
// Whether a diagram that parsed before the edit fails to parse after it. The
|
|
64
|
+
// diagrams are matched by position, which same-tree's identical tree already
|
|
65
|
+
// pins. Asked of same-tree edits as a belt to the tree comparison's braces: a
|
|
66
|
+
// diagram's text sits in the tree, so an unchanged tree cannot break it today,
|
|
67
|
+
// but no edit may ever trade a parseable diagram for an unparseable one.
|
|
68
|
+
const breaksAParse = async (before: DocModel, after: DocModel): Promise<boolean> => {
|
|
69
|
+
for (const [i, was] of before.diagrams.entries()) {
|
|
70
|
+
const now = after.diagrams[i];
|
|
71
|
+
if (!now) continue;
|
|
72
|
+
const [a, b] = await Promise.all([was.stats(), now.stats()]);
|
|
73
|
+
if (!parseFailed(was.source, a) && parseFailed(now.source, b)) return true;
|
|
74
|
+
}
|
|
75
|
+
return false;
|
|
76
|
+
};
|
|
77
|
+
|
|
60
78
|
// A raw diagram file (.mmd, .mermaid) has no markdown around its diagram, so
|
|
61
79
|
// its tree is an empty root and comparing trees would prove nothing. There,
|
|
62
80
|
// same-tree also demands what same-diagrams does: the diagram is still there
|
|
63
|
-
// and parses to the same structure.
|
|
81
|
+
// and parses to the same structure. same-diagrams compares parse success too
|
|
82
|
+
// (parseFailed, the test parse-diagram-unparseable applies), so an edit can
|
|
83
|
+
// neither break a diagram that parsed nor claim to have kept the diagrams the
|
|
84
|
+
// same while making an unparseable one parse.
|
|
64
85
|
export const verify = async (edit: Edit, before: DocModel, after: DocModel): Promise<Verdict> => {
|
|
65
86
|
if (edit.expect.kind === "same-tree") {
|
|
66
87
|
if (!sameTree(before.tree, after.tree, {})) return "refuse";
|
|
67
|
-
if (before.kind === "markdown" && after.kind === "markdown")
|
|
88
|
+
if (before.kind === "markdown" && after.kind === "markdown") {
|
|
89
|
+
return (await breaksAParse(before, after)) ? "refuse" : "accept";
|
|
90
|
+
}
|
|
68
91
|
}
|
|
69
92
|
return (await sameDiagrams(before, after)) ? "accept" : "refuse";
|
|
70
93
|
};
|
package/src/parse/metrics.ts
CHANGED
|
@@ -152,11 +152,18 @@ const round4 = (x: number): number => Math.round(x * 10000) / 10000;
|
|
|
152
152
|
// (nodes + edges*w_e + subgraphs*w_s) * (1 + depth*w_d). Edge density is
|
|
153
153
|
// reported as 0 below two nodes rather than dividing by a fallback, which gave
|
|
154
154
|
// nonsense when a parser returned edges but no nodes.
|
|
155
|
+
//
|
|
156
|
+
// The depth is subgraph nesting or, for a tree-shaped type (mindmap, treemap,
|
|
157
|
+
// treeView, ishikawa), tree depth; a diagram has only one of the two. A deep
|
|
158
|
+
// tree is as hard to follow as deep nesting, so its depth still weighs on the
|
|
159
|
+
// score, and the score of those types is unchanged from when tree depth was
|
|
160
|
+
// counted as nesting. Only complexity-subgraph-nesting-too-deep ignores it,
|
|
161
|
+
// because its remedy (flatten subgraphs) cannot apply to a tree.
|
|
155
162
|
export const calculateComplexity = (stats: DiagramStats, config: ThresholdConfig): ComplexityMetrics => {
|
|
156
163
|
const n = stats.nodes;
|
|
157
164
|
const e = stats.edges;
|
|
158
165
|
const s = stats.subgraphs;
|
|
159
|
-
const d = stats.max_subgraph_depth;
|
|
166
|
+
const d = Math.max(stats.max_subgraph_depth, stats.max_tree_depth);
|
|
160
167
|
const base = n + e * config.edge_weight + s * config.subgraph_weight;
|
|
161
168
|
const vcs = base * (1 + d * config.depth_weight);
|
|
162
169
|
const maxEdges = n > 1 ? n * (n - 1) : 0;
|
package/src/parse/stats.ts
CHANGED
|
@@ -36,7 +36,14 @@ export interface DiagramStats {
|
|
|
36
36
|
nodes: number;
|
|
37
37
|
edges: number;
|
|
38
38
|
subgraphs: number;
|
|
39
|
+
// How deep real containers nest: flowchart subgraphs, composite states,
|
|
40
|
+
// architecture groups and the like.
|
|
39
41
|
max_subgraph_depth: number;
|
|
42
|
+
// How far a tree-shaped diagram (mindmap, treemap, treeView, ishikawa)
|
|
43
|
+
// reaches below its root. Those types draw no subgraphs, so their depth is
|
|
44
|
+
// kept apart from max_subgraph_depth and the nesting rule never reads it.
|
|
45
|
+
// It is 0 for every other type.
|
|
46
|
+
max_tree_depth: number;
|
|
40
47
|
node_ids: string[];
|
|
41
48
|
subgraph_names: string[];
|
|
42
49
|
// The parser that read the diagram, or, on a failure, the one whose reason
|
|
@@ -137,6 +144,7 @@ export async function extractStats(content: string): Promise<MermaidStats> {
|
|
|
137
144
|
edges: 0,
|
|
138
145
|
subgraphs: 0,
|
|
139
146
|
max_subgraph_depth: 0,
|
|
147
|
+
max_tree_depth: 0,
|
|
140
148
|
node_ids: [],
|
|
141
149
|
subgraph_names: [],
|
|
142
150
|
parser_used: langium ? "langium" : "mermaid-core",
|
|
@@ -200,6 +208,7 @@ async function extractLangiumStats(content: string, keyword: string): Promise<Me
|
|
|
200
208
|
const subgraphNames: string[] = [];
|
|
201
209
|
let edges = 0;
|
|
202
210
|
let maxDepth = 0;
|
|
211
|
+
let treeDepth = 0;
|
|
203
212
|
|
|
204
213
|
switch (mapped) {
|
|
205
214
|
case "architecture": {
|
|
@@ -246,7 +255,7 @@ async function extractLangiumStats(content: string, keyword: string): Promise<Me
|
|
|
246
255
|
const stats = indentTreeStats(nodes.map((n, i) => ({ label: n.name ?? `node_${i}`, indent: n.indent ?? 0 })));
|
|
247
256
|
nodeIds.push(...stats.labels);
|
|
248
257
|
edges = stats.edges;
|
|
249
|
-
|
|
258
|
+
treeDepth = stats.depth;
|
|
250
259
|
break;
|
|
251
260
|
}
|
|
252
261
|
case "treemap": {
|
|
@@ -255,7 +264,7 @@ async function extractLangiumStats(content: string, keyword: string): Promise<Me
|
|
|
255
264
|
const stats = indentTreeStats(rows.map((r, i) => ({ label: r.item?.name ?? `row_${i}`, indent: r.indent ?? 0 })));
|
|
256
265
|
nodeIds.push(...stats.labels);
|
|
257
266
|
edges = stats.edges;
|
|
258
|
-
|
|
267
|
+
treeDepth = stats.depth;
|
|
259
268
|
break;
|
|
260
269
|
}
|
|
261
270
|
case "wardley": {
|
|
@@ -277,6 +286,7 @@ async function extractLangiumStats(content: string, keyword: string): Promise<Me
|
|
|
277
286
|
edges,
|
|
278
287
|
subgraphs: subgraphNames.length,
|
|
279
288
|
max_subgraph_depth: maxDepth,
|
|
289
|
+
max_tree_depth: treeDepth,
|
|
280
290
|
node_ids: [...new Set(nodeIds.filter(Boolean))].sort(),
|
|
281
291
|
subgraph_names: subgraphNames,
|
|
282
292
|
parser_used: "langium",
|
|
@@ -340,6 +350,8 @@ interface Structure {
|
|
|
340
350
|
edges: number;
|
|
341
351
|
subgraphNames: string[];
|
|
342
352
|
maxDepth: number;
|
|
353
|
+
// Set only by the tree-shaped types: see max_tree_depth.
|
|
354
|
+
treeDepth?: number;
|
|
343
355
|
}
|
|
344
356
|
type CoreAdapter = (db: AnyDb) => Structure;
|
|
345
357
|
|
|
@@ -362,13 +374,14 @@ async function extractCoreStats(content: string, keyword: string): Promise<Merma
|
|
|
362
374
|
if (!adapter) {
|
|
363
375
|
throw new Error(`no core adapter for diagram.type="${diagramType}" (keyword="${keyword}")`);
|
|
364
376
|
}
|
|
365
|
-
const { nodeIds, edges, subgraphNames, maxDepth } = adapter(diagram.db);
|
|
377
|
+
const { nodeIds, edges, subgraphNames, maxDepth, treeDepth = 0 } = adapter(diagram.db);
|
|
366
378
|
|
|
367
379
|
return {
|
|
368
380
|
nodes: nodeIds.length,
|
|
369
381
|
edges,
|
|
370
382
|
subgraphs: subgraphNames.length,
|
|
371
383
|
max_subgraph_depth: maxDepth,
|
|
384
|
+
max_tree_depth: treeDepth,
|
|
372
385
|
node_ids: [...nodeIds].sort(),
|
|
373
386
|
subgraph_names: subgraphNames,
|
|
374
387
|
parser_used: "mermaid-core",
|
|
@@ -386,6 +399,9 @@ async function extractCoreStats(content: string, keyword: string): Promise<Merma
|
|
|
386
399
|
const CORE_ADAPTERS: Record<string, CoreAdapter> = {
|
|
387
400
|
"flowchart-v2": adaptFlowchart,
|
|
388
401
|
flowchart: adaptFlowchart,
|
|
402
|
+
// `flowchart-elk` is a flowchart drawn with the ELK layout. Mermaid parses it
|
|
403
|
+
// with the flowchart grammar into the same DB, and only the layout differs.
|
|
404
|
+
"flowchart-elk": adaptFlowchart,
|
|
389
405
|
graph: adaptFlowchart,
|
|
390
406
|
classDiagram: adaptClass,
|
|
391
407
|
"classDiagram-v2": adaptClass,
|
|
@@ -557,7 +573,13 @@ function adaptIshikawa(db: AnyDb): Structure {
|
|
|
557
573
|
for (const c of node.children ?? []) walk(c, depth + 1);
|
|
558
574
|
};
|
|
559
575
|
walk(root, 0);
|
|
560
|
-
return {
|
|
576
|
+
return {
|
|
577
|
+
nodeIds: labels,
|
|
578
|
+
edges: Math.max(0, labels.length - 1),
|
|
579
|
+
subgraphNames: [],
|
|
580
|
+
maxDepth: 0,
|
|
581
|
+
treeDepth: maxDepth,
|
|
582
|
+
};
|
|
561
583
|
}
|
|
562
584
|
|
|
563
585
|
interface KanbanNode {
|
|
@@ -596,7 +618,13 @@ function adaptMindmap(db: AnyDb): Structure {
|
|
|
596
618
|
for (const c of node.children ?? []) walk(c, depth + 1);
|
|
597
619
|
};
|
|
598
620
|
walk(root, 0);
|
|
599
|
-
return {
|
|
621
|
+
return {
|
|
622
|
+
nodeIds: labels,
|
|
623
|
+
edges: Math.max(0, labels.length - 1),
|
|
624
|
+
subgraphNames: [],
|
|
625
|
+
maxDepth: 0,
|
|
626
|
+
treeDepth: maxDepth,
|
|
627
|
+
};
|
|
600
628
|
}
|
|
601
629
|
|
|
602
630
|
type QuadrantText = { text?: string } | string | undefined;
|