@jpeakai/mermaidjs-lint 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -5
- package/dist/bin.js +174 -55
- package/dist/index.js +177 -55
- package/dist/testing.js +59 -23
- 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 +24 -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 +6 -1
- package/src/index.ts +5 -3
- package/src/parse/stats.ts +39 -11
- 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/parse/stats.ts
CHANGED
|
@@ -599,17 +599,20 @@ function adaptMindmap(db: AnyDb): Structure {
|
|
|
599
599
|
return { nodeIds: labels, edges: Math.max(0, labels.length - 1), subgraphNames: [], maxDepth };
|
|
600
600
|
}
|
|
601
601
|
|
|
602
|
+
type QuadrantText = { text?: string } | string | undefined;
|
|
602
603
|
interface QuadrantData {
|
|
603
|
-
quadrants?: Array<{ text?:
|
|
604
|
+
quadrants?: Array<{ text?: QuadrantText }>;
|
|
605
|
+
points?: Array<{ text?: QuadrantText }>;
|
|
604
606
|
}
|
|
607
|
+
// A node is every plotted point, plus every quadrant that carries a label. The
|
|
608
|
+
// four quadrant boxes are always drawn, so an unlabelled one adds nothing to
|
|
609
|
+
// read, while every point is a mark the reader must place.
|
|
605
610
|
function adaptQuadrantChart(db: AnyDb): Structure {
|
|
606
611
|
const data = callGetter<QuadrantData | null>(db, "getQuadrantData");
|
|
607
|
-
const
|
|
608
|
-
const
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
});
|
|
612
|
-
return { nodeIds: labels, edges: 0, subgraphNames: [], maxDepth: 0 };
|
|
612
|
+
const textOf = (t: QuadrantText): string => (typeof t === "string" ? t : (t?.text ?? ""));
|
|
613
|
+
const quadrants = (data?.quadrants ?? []).map((q) => textOf(q.text)).filter((t) => t.trim() !== "");
|
|
614
|
+
const points = (data?.points ?? []).map((p, i) => textOf(p.text) || `point_${i}`);
|
|
615
|
+
return { nodeIds: [...quadrants, ...points], edges: 0, subgraphNames: [], maxDepth: 0 };
|
|
613
616
|
}
|
|
614
617
|
|
|
615
618
|
function adaptRequirement(db: AnyDb): Structure {
|
|
@@ -639,36 +642,56 @@ function adaptSankey(db: AnyDb): Structure {
|
|
|
639
642
|
interface StateNode {
|
|
640
643
|
id?: string;
|
|
641
644
|
type?: string;
|
|
645
|
+
isGroup?: boolean;
|
|
646
|
+
parentId?: string;
|
|
642
647
|
}
|
|
643
648
|
interface StateEdge {
|
|
644
649
|
id1?: string;
|
|
645
650
|
id2?: string;
|
|
646
651
|
}
|
|
652
|
+
// A composite state (`state A { ... }`) is a group that holds other states,
|
|
653
|
+
// so it counts as a subgraph too, and its nesting depth is read from each
|
|
654
|
+
// state's parentId chain. It stays a node as well, because it is drawn as a
|
|
655
|
+
// labelled state.
|
|
647
656
|
function adaptState(db: AnyDb): Structure {
|
|
648
657
|
// stateDiagram db exposes data on direct properties.
|
|
649
658
|
const nodes = (db.nodes as StateNode[] | undefined) ?? [];
|
|
650
659
|
const edges = (db.edges as StateEdge[] | undefined) ?? [];
|
|
660
|
+
const groups = nodes.filter((n) => n.isGroup === true);
|
|
661
|
+
const parentOf = new Map(nodes.map((n) => [n.id ?? "", n.parentId]));
|
|
662
|
+
let maxDepth = 0;
|
|
663
|
+
for (const g of groups) {
|
|
664
|
+
let depth = 1;
|
|
665
|
+
const seen = new Set<string>();
|
|
666
|
+
for (let cur = g.parentId; cur && !seen.has(cur); cur = parentOf.get(cur)) {
|
|
667
|
+
seen.add(cur);
|
|
668
|
+
depth++;
|
|
669
|
+
}
|
|
670
|
+
maxDepth = Math.max(maxDepth, depth);
|
|
671
|
+
}
|
|
651
672
|
return {
|
|
652
673
|
nodeIds: nodes.map((n) => n.id ?? ""),
|
|
653
674
|
edges: edges.length,
|
|
654
|
-
subgraphNames:
|
|
655
|
-
maxDepth
|
|
675
|
+
subgraphNames: groups.map((g) => g.id ?? ""),
|
|
676
|
+
maxDepth,
|
|
656
677
|
};
|
|
657
678
|
}
|
|
658
679
|
|
|
659
680
|
interface TimelineTask {
|
|
660
681
|
task?: string;
|
|
661
682
|
section?: string;
|
|
683
|
+
events?: string[];
|
|
662
684
|
}
|
|
663
685
|
interface TimelineSection {
|
|
664
686
|
sectionName?: string;
|
|
665
687
|
section?: string;
|
|
666
688
|
}
|
|
667
689
|
function adaptTimeline(db: AnyDb): Structure {
|
|
690
|
+
// Every period and every event under it is a box on the page.
|
|
668
691
|
const tasks = callGetter<TimelineTask[]>(db, "getTasks") ?? [];
|
|
669
692
|
const sections = callGetter<TimelineSection[]>(db, "getSections") ?? [];
|
|
670
693
|
return {
|
|
671
|
-
nodeIds: tasks.
|
|
694
|
+
nodeIds: tasks.flatMap((t) => [t.task ?? "", ...(t.events ?? []).map((e) => `${t.task ?? ""}: ${e}`)]),
|
|
672
695
|
edges: 0,
|
|
673
696
|
subgraphNames: sections.map((s) => s.sectionName ?? s.section ?? ""),
|
|
674
697
|
maxDepth: sections.length > 0 ? 1 : 0,
|
|
@@ -714,11 +737,16 @@ function adaptVenn(db: AnyDb): Structure {
|
|
|
714
737
|
|
|
715
738
|
interface XYChartData {
|
|
716
739
|
xAxis?: { categories?: string[] };
|
|
740
|
+
plots?: Array<{ data?: unknown[] }>;
|
|
717
741
|
}
|
|
742
|
+
// A node is an x position: a category, or, on a numeric x-axis, a point of the
|
|
743
|
+
// longest series.
|
|
718
744
|
function adaptXychart(db: AnyDb): Structure {
|
|
719
745
|
const data = callGetter<XYChartData | null>(db, "getXYChartData");
|
|
720
746
|
const categories = data?.xAxis?.categories ?? [];
|
|
721
|
-
return { nodeIds: categories, edges: 0, subgraphNames: [], maxDepth: 0 };
|
|
747
|
+
if (categories.length > 0) return { nodeIds: categories, edges: 0, subgraphNames: [], maxDepth: 0 };
|
|
748
|
+
const longest = Math.max(0, ...(data?.plots ?? []).map((p) => p.data?.length ?? 0));
|
|
749
|
+
return { nodeIds: Array.from({ length: longest }, (_, i) => `x_${i}`), edges: 0, subgraphNames: [], maxDepth: 0 };
|
|
722
750
|
}
|
|
723
751
|
|
|
724
752
|
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
|
};
|