@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/src/fix/engine.ts
CHANGED
|
@@ -4,12 +4,14 @@
|
|
|
4
4
|
// computed from, so every accepted edit restarts the pass.
|
|
5
5
|
|
|
6
6
|
import { optionsFor, type RunFlags } from "../check.ts";
|
|
7
|
+
import { FixNotConvergingError } from "../errors.ts";
|
|
7
8
|
import { buildDocModel, documentKind } from "../model.ts";
|
|
8
9
|
import { BUILTIN, HELPERS, type Registry } from "../rules/registry.ts";
|
|
9
10
|
import type { Edit } from "../rules/types.ts";
|
|
10
11
|
import { verify } from "./verify.ts";
|
|
11
12
|
|
|
12
13
|
const MAX_PASSES = 1_000;
|
|
14
|
+
const RECENT_PASSES = 50;
|
|
13
15
|
|
|
14
16
|
export interface FixReport {
|
|
15
17
|
output: string;
|
|
@@ -51,6 +53,10 @@ export const fixSourceReport = async (
|
|
|
51
53
|
const refusedKeys = new Set<string>();
|
|
52
54
|
let current = src;
|
|
53
55
|
|
|
56
|
+
// The rule whose edit each pass kept, so a run that never converges can name
|
|
57
|
+
// the rules still proposing edits at the end.
|
|
58
|
+
const keptBy: string[] = [];
|
|
59
|
+
|
|
54
60
|
for (let pass = 0; pass < MAX_PASSES; pass++) {
|
|
55
61
|
const doc = buildDocModel(current, kind);
|
|
56
62
|
const keyOf = (e: Edit): string => `${e.rule}\u0000${current.slice(e.start, e.end)}\u0000${e.text}`;
|
|
@@ -85,21 +91,27 @@ export const fixSourceReport = async (
|
|
|
85
91
|
// the rest.
|
|
86
92
|
if (edits.length > 1 && !overlaps(edits) && (await attempt(edits))) {
|
|
87
93
|
progressed = true;
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
+
} else {
|
|
95
|
+
for (const edit of edits) {
|
|
96
|
+
if (await attempt([edit])) {
|
|
97
|
+
progressed = true;
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
refusedKeys.add(keyOf(edit));
|
|
101
|
+
refused.push(edit);
|
|
94
102
|
}
|
|
95
|
-
refusedKeys.add(keyOf(edit));
|
|
96
|
-
refused.push(edit);
|
|
97
103
|
}
|
|
98
|
-
if (progressed)
|
|
104
|
+
if (progressed) {
|
|
105
|
+
keptBy.push(rule.id);
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
99
108
|
}
|
|
100
109
|
if (!progressed) return { output: current, applied, refused };
|
|
101
110
|
}
|
|
102
|
-
|
|
111
|
+
// The last passes show who was still editing: a fixer that never converges
|
|
112
|
+
// keeps one in every pass.
|
|
113
|
+
const lingering = [...new Set(keptBy.slice(-RECENT_PASSES))].sort();
|
|
114
|
+
throw new FixNotConvergingError(file, MAX_PASSES, lingering);
|
|
103
115
|
};
|
|
104
116
|
|
|
105
117
|
export const fixSource = async (
|
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,23 +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
|
+
|
|
78
|
+
// A raw diagram file (.mmd, .mermaid) has no markdown around its diagram, so
|
|
79
|
+
// its tree is an empty root and comparing trees would prove nothing. There,
|
|
80
|
+
// same-tree also demands what same-diagrams does: the diagram is still there
|
|
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.
|
|
60
85
|
export const verify = async (edit: Edit, before: DocModel, after: DocModel): Promise<Verdict> => {
|
|
61
86
|
if (edit.expect.kind === "same-tree") {
|
|
62
|
-
|
|
87
|
+
if (!sameTree(before.tree, after.tree, {})) return "refuse";
|
|
88
|
+
if (before.kind === "markdown" && after.kind === "markdown") {
|
|
89
|
+
return (await breaksAParse(before, after)) ? "refuse" : "accept";
|
|
90
|
+
}
|
|
63
91
|
}
|
|
64
92
|
return (await sameDiagrams(before, after)) ? "accept" : "refuse";
|
|
65
93
|
};
|
package/src/index.ts
CHANGED
|
@@ -4,14 +4,16 @@ export { checkMarkdown, checkModel, type RunFlags } from "./check.ts";
|
|
|
4
4
|
export { main } from "./cli.ts";
|
|
5
5
|
export { type Config, loadConfig, type PluginLoading, parseConfig, type RuleSetting, type Severity } from "./config.ts";
|
|
6
6
|
export * from "./errors.ts";
|
|
7
|
-
export { collectFiles } from "./files.ts";
|
|
7
|
+
export { type Collected, collectFiles } from "./files.ts";
|
|
8
8
|
export { type FixReport, fixSource, fixSourceReport } from "./fix/engine.ts";
|
|
9
9
|
export { buildDocModel, DIAGRAM_EXTS, type Diagram, type DocModel, documentKind } from "./model.ts";
|
|
10
10
|
export type { ContrastAssessment, StyleDirective, ValueSpan } from "./parse/color.ts";
|
|
11
|
-
export { PRESETS, thresholdsFrom } from "./parse/metrics.ts";
|
|
11
|
+
export { PRESETS, type Preset, type ThresholdConfig, thresholdsFrom } from "./parse/metrics.ts";
|
|
12
12
|
export { type DiagramStats, detectKeyword, extractStats } from "./parse/stats.ts";
|
|
13
13
|
export type { Plugin, PluginEdit, PluginFinding, PluginMeta, PluginRule } from "./plugins/contract.ts";
|
|
14
|
-
export {
|
|
14
|
+
export type { SourceKind } from "./plugins/discover.ts";
|
|
15
|
+
export type { PluginFailure } from "./plugins/index.ts";
|
|
16
|
+
export { findProjectRoot, type Setup, type SetupOptions, setUp } from "./project.ts";
|
|
15
17
|
export { FIX_ORDER, RULES } from "./rules/index.ts";
|
|
16
18
|
export {
|
|
17
19
|
BUILTIN,
|
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,20 +618,29 @@ 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
|
|
|
630
|
+
type QuadrantText = { text?: string } | string | undefined;
|
|
602
631
|
interface QuadrantData {
|
|
603
|
-
quadrants?: Array<{ text?:
|
|
632
|
+
quadrants?: Array<{ text?: QuadrantText }>;
|
|
633
|
+
points?: Array<{ text?: QuadrantText }>;
|
|
604
634
|
}
|
|
635
|
+
// A node is every plotted point, plus every quadrant that carries a label. The
|
|
636
|
+
// four quadrant boxes are always drawn, so an unlabelled one adds nothing to
|
|
637
|
+
// read, while every point is a mark the reader must place.
|
|
605
638
|
function adaptQuadrantChart(db: AnyDb): Structure {
|
|
606
639
|
const data = callGetter<QuadrantData | null>(db, "getQuadrantData");
|
|
607
|
-
const
|
|
608
|
-
const
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
});
|
|
612
|
-
return { nodeIds: labels, edges: 0, subgraphNames: [], maxDepth: 0 };
|
|
640
|
+
const textOf = (t: QuadrantText): string => (typeof t === "string" ? t : (t?.text ?? ""));
|
|
641
|
+
const quadrants = (data?.quadrants ?? []).map((q) => textOf(q.text)).filter((t) => t.trim() !== "");
|
|
642
|
+
const points = (data?.points ?? []).map((p, i) => textOf(p.text) || `point_${i}`);
|
|
643
|
+
return { nodeIds: [...quadrants, ...points], edges: 0, subgraphNames: [], maxDepth: 0 };
|
|
613
644
|
}
|
|
614
645
|
|
|
615
646
|
function adaptRequirement(db: AnyDb): Structure {
|
|
@@ -639,36 +670,56 @@ function adaptSankey(db: AnyDb): Structure {
|
|
|
639
670
|
interface StateNode {
|
|
640
671
|
id?: string;
|
|
641
672
|
type?: string;
|
|
673
|
+
isGroup?: boolean;
|
|
674
|
+
parentId?: string;
|
|
642
675
|
}
|
|
643
676
|
interface StateEdge {
|
|
644
677
|
id1?: string;
|
|
645
678
|
id2?: string;
|
|
646
679
|
}
|
|
680
|
+
// A composite state (`state A { ... }`) is a group that holds other states,
|
|
681
|
+
// so it counts as a subgraph too, and its nesting depth is read from each
|
|
682
|
+
// state's parentId chain. It stays a node as well, because it is drawn as a
|
|
683
|
+
// labelled state.
|
|
647
684
|
function adaptState(db: AnyDb): Structure {
|
|
648
685
|
// stateDiagram db exposes data on direct properties.
|
|
649
686
|
const nodes = (db.nodes as StateNode[] | undefined) ?? [];
|
|
650
687
|
const edges = (db.edges as StateEdge[] | undefined) ?? [];
|
|
688
|
+
const groups = nodes.filter((n) => n.isGroup === true);
|
|
689
|
+
const parentOf = new Map(nodes.map((n) => [n.id ?? "", n.parentId]));
|
|
690
|
+
let maxDepth = 0;
|
|
691
|
+
for (const g of groups) {
|
|
692
|
+
let depth = 1;
|
|
693
|
+
const seen = new Set<string>();
|
|
694
|
+
for (let cur = g.parentId; cur && !seen.has(cur); cur = parentOf.get(cur)) {
|
|
695
|
+
seen.add(cur);
|
|
696
|
+
depth++;
|
|
697
|
+
}
|
|
698
|
+
maxDepth = Math.max(maxDepth, depth);
|
|
699
|
+
}
|
|
651
700
|
return {
|
|
652
701
|
nodeIds: nodes.map((n) => n.id ?? ""),
|
|
653
702
|
edges: edges.length,
|
|
654
|
-
subgraphNames:
|
|
655
|
-
maxDepth
|
|
703
|
+
subgraphNames: groups.map((g) => g.id ?? ""),
|
|
704
|
+
maxDepth,
|
|
656
705
|
};
|
|
657
706
|
}
|
|
658
707
|
|
|
659
708
|
interface TimelineTask {
|
|
660
709
|
task?: string;
|
|
661
710
|
section?: string;
|
|
711
|
+
events?: string[];
|
|
662
712
|
}
|
|
663
713
|
interface TimelineSection {
|
|
664
714
|
sectionName?: string;
|
|
665
715
|
section?: string;
|
|
666
716
|
}
|
|
667
717
|
function adaptTimeline(db: AnyDb): Structure {
|
|
718
|
+
// Every period and every event under it is a box on the page.
|
|
668
719
|
const tasks = callGetter<TimelineTask[]>(db, "getTasks") ?? [];
|
|
669
720
|
const sections = callGetter<TimelineSection[]>(db, "getSections") ?? [];
|
|
670
721
|
return {
|
|
671
|
-
nodeIds: tasks.
|
|
722
|
+
nodeIds: tasks.flatMap((t) => [t.task ?? "", ...(t.events ?? []).map((e) => `${t.task ?? ""}: ${e}`)]),
|
|
672
723
|
edges: 0,
|
|
673
724
|
subgraphNames: sections.map((s) => s.sectionName ?? s.section ?? ""),
|
|
674
725
|
maxDepth: sections.length > 0 ? 1 : 0,
|
|
@@ -714,11 +765,16 @@ function adaptVenn(db: AnyDb): Structure {
|
|
|
714
765
|
|
|
715
766
|
interface XYChartData {
|
|
716
767
|
xAxis?: { categories?: string[] };
|
|
768
|
+
plots?: Array<{ data?: unknown[] }>;
|
|
717
769
|
}
|
|
770
|
+
// A node is an x position: a category, or, on a numeric x-axis, a point of the
|
|
771
|
+
// longest series.
|
|
718
772
|
function adaptXychart(db: AnyDb): Structure {
|
|
719
773
|
const data = callGetter<XYChartData | null>(db, "getXYChartData");
|
|
720
774
|
const categories = data?.xAxis?.categories ?? [];
|
|
721
|
-
return { nodeIds: categories, edges: 0, subgraphNames: [], maxDepth: 0 };
|
|
775
|
+
if (categories.length > 0) return { nodeIds: categories, edges: 0, subgraphNames: [], maxDepth: 0 };
|
|
776
|
+
const longest = Math.max(0, ...(data?.plots ?? []).map((p) => p.data?.length ?? 0));
|
|
777
|
+
return { nodeIds: Array.from({ length: longest }, (_, i) => `x_${i}`), edges: 0, subgraphNames: [], maxDepth: 0 };
|
|
722
778
|
}
|
|
723
779
|
|
|
724
780
|
function subgraphNestingDepth(subGraphs: Array<{ id?: string; nodes?: string[] }>): number {
|
package/src/plugins/contract.ts
CHANGED
|
@@ -37,5 +37,7 @@ export interface Plugin {
|
|
|
37
37
|
|
|
38
38
|
// A one-word category and a namespace or rule key, as they appear in an id.
|
|
39
39
|
export const CATEGORY_WORD = /^[a-z]+$/;
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
// Lower-case words joined by single hyphens: no leading, trailing or doubled
|
|
41
|
+
// hyphen, so `house-` and `a-` are refused.
|
|
42
|
+
export const NAMESPACE = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
|
|
43
|
+
export const RULE_KEY = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
|
package/src/plugins/discover.ts
CHANGED
|
@@ -4,14 +4,16 @@
|
|
|
4
4
|
// names a config lists. A directory of node_modules is never scanned, so only
|
|
5
5
|
// what the project chose to depend on can run.
|
|
6
6
|
//
|
|
7
|
-
// A declared package that is not installed
|
|
8
|
-
//
|
|
9
|
-
// validating run can report every one. A
|
|
7
|
+
// A declared package that is not installed, or whose package.json is not a
|
|
8
|
+
// JSON object, is a failure the caller decides how to treat, not an exception,
|
|
9
|
+
// so a lenient run can skip it and a validating run can report every one. A
|
|
10
|
+
// malformed project package.json throws PackageJsonError, except in a run with
|
|
11
|
+
// plugins off, which loads no package it names.
|
|
10
12
|
|
|
11
13
|
import { readdir, readFile } from "node:fs/promises";
|
|
12
14
|
import { dirname, extname, isAbsolute, join, resolve } from "node:path";
|
|
13
15
|
import type { Config } from "../config.ts";
|
|
14
|
-
import { PluginNotInstalledError } from "../errors.ts";
|
|
16
|
+
import { PackageJsonError, PluginLoadError, PluginNotInstalledError, PluginSourceError } from "../errors.ts";
|
|
15
17
|
|
|
16
18
|
export type SourceKind = "local" | "package" | "config";
|
|
17
19
|
|
|
@@ -23,10 +25,16 @@ export interface PluginSource {
|
|
|
23
25
|
path: string;
|
|
24
26
|
}
|
|
25
27
|
|
|
28
|
+
export interface UnresolvedSource {
|
|
29
|
+
kind: SourceKind;
|
|
30
|
+
error: PluginLoadError;
|
|
31
|
+
}
|
|
32
|
+
|
|
26
33
|
export interface Discovery {
|
|
27
34
|
sources: PluginSource[];
|
|
28
|
-
// Sources found but not resolvable, such as a declared package that is not
|
|
29
|
-
|
|
35
|
+
// Sources found but not resolvable, such as a declared package that is not
|
|
36
|
+
// installed, each with the kind of source that named it.
|
|
37
|
+
failures: UnresolvedSource[];
|
|
30
38
|
}
|
|
31
39
|
|
|
32
40
|
export const LOCAL_RULES = join(".mermaidjs-lint", "rules");
|
|
@@ -49,7 +57,7 @@ const readJson = async (path: string): Promise<Record<string, unknown> | null> =
|
|
|
49
57
|
} catch {
|
|
50
58
|
// Reported below with the path.
|
|
51
59
|
}
|
|
52
|
-
throw new
|
|
60
|
+
throw new PackageJsonError(path);
|
|
53
61
|
};
|
|
54
62
|
|
|
55
63
|
// Every extension a local rule may have. Whether the runtime can import a
|
|
@@ -101,15 +109,27 @@ const entryOf = (pkg: Record<string, unknown>): string => {
|
|
|
101
109
|
export const resolvePackage = async (root: string, name: string): Promise<string> => {
|
|
102
110
|
for (let dir = resolve(root); ; dir = dirname(dir)) {
|
|
103
111
|
const folder = join(dir, "node_modules", name);
|
|
104
|
-
|
|
112
|
+
let pkg: Record<string, unknown> | null;
|
|
113
|
+
try {
|
|
114
|
+
pkg = await readJson(join(folder, "package.json"));
|
|
115
|
+
} catch (err) {
|
|
116
|
+
if (!(err instanceof PackageJsonError)) throw err;
|
|
117
|
+
throw new PluginSourceError(name, `its package.json at ${err.file} is not a JSON object`);
|
|
118
|
+
}
|
|
105
119
|
if (pkg) return join(folder, entryOf(pkg));
|
|
106
120
|
if (dirname(dir) === dir) break;
|
|
107
121
|
}
|
|
108
122
|
throw new PluginNotInstalledError(name, root);
|
|
109
123
|
};
|
|
110
124
|
|
|
111
|
-
const declaredPlugins = async (root: string): Promise<string[]> => {
|
|
112
|
-
|
|
125
|
+
const declaredPlugins = async (root: string, tolerateBadManifest: boolean): Promise<string[]> => {
|
|
126
|
+
let pkg: Record<string, unknown> | null;
|
|
127
|
+
try {
|
|
128
|
+
pkg = await readJson(join(root, "package.json"));
|
|
129
|
+
} catch (err) {
|
|
130
|
+
if (tolerateBadManifest && err instanceof PackageJsonError) return [];
|
|
131
|
+
throw err;
|
|
132
|
+
}
|
|
113
133
|
if (!pkg) return [];
|
|
114
134
|
const names = ["dependencies", "devDependencies", "optionalDependencies"].flatMap((field) => {
|
|
115
135
|
const deps = pkg[field];
|
|
@@ -118,18 +138,21 @@ const declaredPlugins = async (root: string): Promise<string[]> => {
|
|
|
118
138
|
return [...new Set(names)].filter((name) => PACKAGE_PATTERN.test(name)).sort();
|
|
119
139
|
};
|
|
120
140
|
|
|
121
|
-
|
|
141
|
+
// A run with plugins off loads nothing, so a malformed project package.json,
|
|
142
|
+
// which could only name packages that run will not load, does not stop it.
|
|
143
|
+
export const discoverSources = async (root: string, config: Config, pluginsOff = false): Promise<Discovery> => {
|
|
122
144
|
const sources = await localSources(root);
|
|
123
|
-
const failures:
|
|
145
|
+
const failures: UnresolvedSource[] = [];
|
|
124
146
|
const add = async (kind: SourceKind, spec: string, resolveIt: () => Promise<string>): Promise<void> => {
|
|
125
147
|
try {
|
|
126
148
|
sources.push({ kind, spec, path: await resolveIt() });
|
|
127
149
|
} catch (err) {
|
|
128
|
-
if (!(err instanceof
|
|
129
|
-
failures.push(err);
|
|
150
|
+
if (!(err instanceof PluginLoadError)) throw err;
|
|
151
|
+
failures.push({ kind, error: err });
|
|
130
152
|
}
|
|
131
153
|
};
|
|
132
|
-
for (const name of await declaredPlugins(root
|
|
154
|
+
for (const name of await declaredPlugins(root, pluginsOff))
|
|
155
|
+
await add("package", name, () => resolvePackage(root, name));
|
|
133
156
|
for (const spec of config.plugins || []) {
|
|
134
157
|
const path = spec.startsWith(".") || isAbsolute(spec);
|
|
135
158
|
await add("config", spec, () => (path ? Promise.resolve(resolve(root, spec)) : resolvePackage(root, spec)));
|
package/src/plugins/index.ts
CHANGED
|
@@ -34,7 +34,7 @@ export interface PluginSet {
|
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
export const loadPlugins = async (root: string, config: Config, mode: PluginMode): Promise<PluginSet> => {
|
|
37
|
-
const { sources: found, failures: unresolved } = await discoverSources(root, config);
|
|
37
|
+
const { sources: found, failures: unresolved } = await discoverSources(root, config, mode === "off");
|
|
38
38
|
const set: PluginSet = {
|
|
39
39
|
rules: [],
|
|
40
40
|
categories: new Map(BUILTIN_CATEGORIES),
|
|
@@ -42,14 +42,16 @@ export const loadPlugins = async (root: string, config: Config, mode: PluginMode
|
|
|
42
42
|
skipped: [],
|
|
43
43
|
failures: [],
|
|
44
44
|
};
|
|
45
|
-
if (mode === "off")
|
|
45
|
+
if (mode === "off") {
|
|
46
|
+
return { ...set, skipped: [...found.map((s) => s.spec), ...unresolved.map((u) => u.error.source)] };
|
|
47
|
+
}
|
|
46
48
|
|
|
47
49
|
// Strict stops at the first failure. The others record it and carry on.
|
|
48
50
|
const settle = (error: PluginLoadError, kind: SourceKind): void => {
|
|
49
51
|
if (mode === "strict") throw error;
|
|
50
52
|
if (!set.failures.some((f) => f.source === error.source)) set.failures.push({ source: error.source, kind, error });
|
|
51
53
|
};
|
|
52
|
-
for (const error of unresolved) settle(error,
|
|
54
|
+
for (const { error, kind } of unresolved) settle(error, kind);
|
|
53
55
|
|
|
54
56
|
const loaded: { plugin: Plugin; kind: SourceKind; spec: string }[] = [];
|
|
55
57
|
const locals = found.filter((s) => s.kind === "local");
|
package/src/plugins/load.ts
CHANGED
|
@@ -49,6 +49,9 @@ const importModule = async (source: PluginSource, runtime?: RuntimeInfo): Promis
|
|
|
49
49
|
|
|
50
50
|
const OPTION_TYPES = new Set<OptionType>(["number", "string", "boolean", "string[]"]);
|
|
51
51
|
|
|
52
|
+
// Option names every rule shares with the built-ins. See checkRule.
|
|
53
|
+
const RESERVED_OPTIONS = ["preset", "profile"] as const;
|
|
54
|
+
|
|
52
55
|
// Checks one rule's declaration and returns it, so a bad rule fails at load
|
|
53
56
|
// rather than the first time it runs.
|
|
54
57
|
const checkRule = (spec: string, key: string, raw: unknown, own: ReadonlySet<string>): PluginRule => {
|
|
@@ -80,6 +83,18 @@ const checkRule = (spec: string, key: string, raw: unknown, own: ReadonlySet<str
|
|
|
80
83
|
if (!valid) {
|
|
81
84
|
throw new PluginContractError(spec, `${where} options must map names to number, string, boolean or string[]`);
|
|
82
85
|
}
|
|
86
|
+
// preset and profile keep their built-in meaning for every rule: the
|
|
87
|
+
// config checks the value against the built-in names, and --preset and
|
|
88
|
+
// --profile overwrite it. A rule may declare one to follow the run's
|
|
89
|
+
// setting, as a string, but cannot give the name another meaning.
|
|
90
|
+
for (const name of RESERVED_OPTIONS) {
|
|
91
|
+
if (name in options && (options as Record<string, unknown>)[name] !== "string") {
|
|
92
|
+
throw new PluginContractError(
|
|
93
|
+
spec,
|
|
94
|
+
`${where} declares option "${name}" as ${String((options as Record<string, unknown>)[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`,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
83
98
|
}
|
|
84
99
|
return raw as unknown as PluginRule;
|
|
85
100
|
};
|
|
@@ -148,7 +163,23 @@ export const loadLocal = async (sources: PluginSource[], runtime?: RuntimeInfo):
|
|
|
148
163
|
const categories: Record<string, string> = {};
|
|
149
164
|
const owner: Record<string, string> = {};
|
|
150
165
|
const loaded: { key: string; source: PluginSource; raw: unknown }[] = [];
|
|
166
|
+
// A rule's key is its file's base name, so two files with one base name
|
|
167
|
+
// (house-x.js and house-x.mjs) would claim one rule. Neither is imported,
|
|
168
|
+
// and the failure names both, rather than one silently replacing the other.
|
|
169
|
+
const keyOf = (source: PluginSource): string => basename(source.path, extname(source.path));
|
|
170
|
+
const byKey = new Map<string, PluginSource[]>();
|
|
171
|
+
for (const source of sources) byKey.set(keyOf(source), [...(byKey.get(keyOf(source)) ?? []), source]);
|
|
172
|
+
for (const [key, group] of byKey) {
|
|
173
|
+
if (group.length < 2) continue;
|
|
174
|
+
fail(
|
|
175
|
+
new PluginContractError(
|
|
176
|
+
(group[0] as PluginSource).spec,
|
|
177
|
+
`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`,
|
|
178
|
+
),
|
|
179
|
+
);
|
|
180
|
+
}
|
|
151
181
|
for (const source of sources) {
|
|
182
|
+
if ((byKey.get(keyOf(source))?.length ?? 0) > 1) continue;
|
|
152
183
|
try {
|
|
153
184
|
const mod = await importModule(source, runtime);
|
|
154
185
|
if (mod.default === undefined) {
|
|
@@ -166,7 +197,7 @@ export const loadLocal = async (sources: PluginSource[], runtime?: RuntimeInfo):
|
|
|
166
197
|
categories[word] = description;
|
|
167
198
|
owner[word] = source.spec;
|
|
168
199
|
}
|
|
169
|
-
loaded.push({ key:
|
|
200
|
+
loaded.push({ key: keyOf(source), source, raw: mod.default });
|
|
170
201
|
} catch (err) {
|
|
171
202
|
fail(err);
|
|
172
203
|
}
|
package/src/project.ts
CHANGED
|
@@ -43,6 +43,8 @@ export interface SetupOptions {
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
export interface Setup {
|
|
46
|
+
// The project root the config and plugins were found from.
|
|
47
|
+
root: string;
|
|
46
48
|
registry: Registry;
|
|
47
49
|
skipped: string[];
|
|
48
50
|
// Plugins left out because they failed. Empty unless the run was lenient or collecting.
|
|
@@ -84,5 +86,5 @@ export const setUp = async ({
|
|
|
84
86
|
// rather than failing the run. A built-in id never is.
|
|
85
87
|
ignoreUnknownPluginIds: mode !== "strict",
|
|
86
88
|
});
|
|
87
|
-
return { registry, skipped: plugins.skipped, failures: plugins.failures };
|
|
89
|
+
return { root, registry, skipped: plugins.skipped, failures: plugins.failures };
|
|
88
90
|
};
|
|
@@ -3,6 +3,11 @@ import { RULE } from "./types.ts";
|
|
|
3
3
|
|
|
4
4
|
export const MAX_NESTING = 3;
|
|
5
5
|
|
|
6
|
+
// Reads max_subgraph_depth only. A mindmap, treemap, treeView or ishikawa
|
|
7
|
+
// diagram reports its depth as max_tree_depth instead, because it draws no
|
|
8
|
+
// subgraphs and flattening them is no remedy for a deep tree. Its depth still
|
|
9
|
+
// weighs on the visual complexity score (calculateComplexity in metrics.ts).
|
|
10
|
+
|
|
6
11
|
export const complexitySubgraphNestingTooDeep = complexityRule({
|
|
7
12
|
id: RULE.NESTING,
|
|
8
13
|
summary: "subgraphs nested three or more levels deep",
|
|
@@ -20,7 +20,7 @@ export const parseFenceUnterminated: Rule = {
|
|
|
20
20
|
d,
|
|
21
21
|
RULE.FENCE_UNTERMINATED,
|
|
22
22
|
`mermaid fence opened at line ${d.startLine} is never closed`,
|
|
23
|
-
"Add a closing fence after the last line of the diagram.",
|
|
23
|
+
"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.",
|
|
24
24
|
),
|
|
25
25
|
),
|
|
26
26
|
fix: (ctx) => {
|