@jpeakai/mermaidjs-lint 0.7.1 → 0.7.2
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 +20 -21
- package/dist/bin.js +691 -578
- package/dist/index.js +691 -578
- package/dist/testing.js +471 -411
- package/dist/types/parse/contrast.d.ts +6 -1
- package/dist/types/parse/graph.d.ts +16 -0
- package/dist/types/rules/shared.d.ts +5 -1
- package/package.json +1 -1
- package/src/check.ts +13 -11
- package/src/cli.ts +153 -90
- package/src/files.ts +23 -18
- package/src/fix/engine.ts +82 -57
- package/src/fix/verify.ts +13 -8
- package/src/model.ts +1 -1
- package/src/parse/color.ts +1 -1
- package/src/parse/contrast.ts +308 -228
- package/src/parse/graph.ts +98 -0
- package/src/parse/metrics.ts +1 -1
- package/src/parse/stats.ts +111 -82
- package/src/plugins/discover.ts +13 -15
- package/src/plugins/index.ts +66 -37
- package/src/plugins/load.ts +94 -61
- package/src/rules/complexity.ts +1 -1
- package/src/rules/contrast-text-below-aa.ts +34 -21
- package/src/rules/parse-diagram-unparseable.ts +1 -1
- package/src/rules/parse-fence-unterminated.ts +5 -7
- package/src/rules/registry.ts +38 -16
- package/src/rules/shared.ts +8 -3
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// The element-level structure of a diagram, read from mermaid's parser result:
|
|
2
|
+
// each node, each edge and each subgraph, with what contains what. The stats in
|
|
3
|
+
// stats.ts count the same result, and this keeps the elements themselves, so
|
|
4
|
+
// the docs site can draw what the parser returned beside the score it earns.
|
|
5
|
+
// Only the types the site draws are read, and any other type throws.
|
|
6
|
+
|
|
7
|
+
import { getMermaidCore } from "./stats.ts";
|
|
8
|
+
|
|
9
|
+
export interface GraphNode {
|
|
10
|
+
id: string;
|
|
11
|
+
label: string;
|
|
12
|
+
// The subgraph that holds this node, or null at the top level.
|
|
13
|
+
parent: string | null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface GraphEdge {
|
|
17
|
+
source: string;
|
|
18
|
+
target: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface DiagramGraph {
|
|
22
|
+
diagram_type: string;
|
|
23
|
+
nodes: GraphNode[];
|
|
24
|
+
edges: GraphEdge[];
|
|
25
|
+
// A subgraph holds nodes, or other subgraphs through their parent.
|
|
26
|
+
subgraphs: GraphNode[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type AnyDb = Record<string, unknown>;
|
|
30
|
+
|
|
31
|
+
interface FlowVertex {
|
|
32
|
+
id: string;
|
|
33
|
+
text?: string;
|
|
34
|
+
}
|
|
35
|
+
interface FlowEdge {
|
|
36
|
+
start: string;
|
|
37
|
+
end: string;
|
|
38
|
+
}
|
|
39
|
+
interface FlowSubgraph {
|
|
40
|
+
id: string;
|
|
41
|
+
title?: string;
|
|
42
|
+
nodes?: string[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Mermaid lists each member once, under its innermost subgraph, and a nested
|
|
46
|
+
// subgraph is listed by id as a member of the one around it.
|
|
47
|
+
const flowchartGraph = (type: string, db: AnyDb): DiagramGraph => {
|
|
48
|
+
const vertices = db.vertices instanceof Map ? ([...db.vertices.values()] as FlowVertex[]) : [];
|
|
49
|
+
const edges = (Array.isArray(db.edges) ? db.edges : []) as FlowEdge[];
|
|
50
|
+
const subGraphs = (Array.isArray(db.subGraphs) ? db.subGraphs : []) as FlowSubgraph[];
|
|
51
|
+
const parentOf = new Map<string, string>();
|
|
52
|
+
for (const sg of subGraphs) for (const member of sg.nodes ?? []) parentOf.set(member, sg.id);
|
|
53
|
+
return {
|
|
54
|
+
diagram_type: type,
|
|
55
|
+
nodes: vertices.map((v) => ({ id: v.id, label: v.text ?? v.id, parent: parentOf.get(v.id) ?? null })),
|
|
56
|
+
edges: edges.map((e) => ({ source: e.start, target: e.end })),
|
|
57
|
+
subgraphs: subGraphs.map((sg) => ({ id: sg.id, label: sg.title ?? sg.id, parent: parentOf.get(sg.id) ?? null })),
|
|
58
|
+
};
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
interface MindmapNode {
|
|
62
|
+
id: number;
|
|
63
|
+
descr?: string;
|
|
64
|
+
children?: MindmapNode[];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// A mindmap's labels may repeat, so each entry is named by the parser's own id.
|
|
68
|
+
const mindmapGraph = (type: string, db: AnyDb): DiagramGraph => {
|
|
69
|
+
const getRoot = db.getMindmap;
|
|
70
|
+
const root = typeof getRoot === "function" ? (getRoot.call(db) as MindmapNode | null) : null;
|
|
71
|
+
const nodes: GraphNode[] = [];
|
|
72
|
+
const edges: GraphEdge[] = [];
|
|
73
|
+
const walk = (node: MindmapNode, parent: string | null): void => {
|
|
74
|
+
const id = `n${node.id}`;
|
|
75
|
+
nodes.push({ id, label: node.descr ?? id, parent: null });
|
|
76
|
+
if (parent) edges.push({ source: parent, target: id });
|
|
77
|
+
for (const child of node.children ?? []) walk(child, id);
|
|
78
|
+
};
|
|
79
|
+
if (root) walk(root, null);
|
|
80
|
+
return { diagram_type: type, nodes, edges, subgraphs: [] };
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const READERS: Record<string, (type: string, db: AnyDb) => DiagramGraph> = {
|
|
84
|
+
"flowchart-v2": flowchartGraph,
|
|
85
|
+
flowchart: flowchartGraph,
|
|
86
|
+
graph: flowchartGraph,
|
|
87
|
+
mindmap: mindmapGraph,
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export const extractGraph = async (content: string): Promise<DiagramGraph> => {
|
|
91
|
+
const mermaid = await getMermaidCore();
|
|
92
|
+
await mermaid.parse(content);
|
|
93
|
+
const diagram = (await mermaid.mermaidAPI.getDiagramFromText(content)) as { db: AnyDb; type?: string };
|
|
94
|
+
const type = diagram.type ?? "unknown";
|
|
95
|
+
const read = READERS[type];
|
|
96
|
+
if (!read) throw new Error(`extractGraph reads ${Object.keys(READERS).join(", ")}, not ${type}`);
|
|
97
|
+
return read(type, diagram.db);
|
|
98
|
+
};
|
package/src/parse/metrics.ts
CHANGED
|
@@ -111,7 +111,7 @@ export const canonicalPreset = (name: string): string | null => {
|
|
|
111
111
|
export const configFromPreset = (name: string): ThresholdConfig => {
|
|
112
112
|
const canonical = canonicalPreset(name);
|
|
113
113
|
const preset = canonical ? PRESETS[canonical] : undefined;
|
|
114
|
-
if (!canonical
|
|
114
|
+
if (!(canonical && preset)) {
|
|
115
115
|
throw new Error(`Unknown preset '${name}'. Valid: ${PRESET_NAMES.join(", ")}`);
|
|
116
116
|
}
|
|
117
117
|
return {
|
package/src/parse/stats.ts
CHANGED
|
@@ -71,10 +71,18 @@ export interface DiagramStats {
|
|
|
71
71
|
|
|
72
72
|
type MermaidStats = DiagramStats;
|
|
73
73
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
74
|
+
type LangiumType =
|
|
75
|
+
| "architecture"
|
|
76
|
+
| "info"
|
|
77
|
+
| "pie"
|
|
78
|
+
| "gitGraph"
|
|
79
|
+
| "packet"
|
|
80
|
+
| "radar"
|
|
81
|
+
| "treemap"
|
|
82
|
+
| "treeView"
|
|
83
|
+
| "wardley";
|
|
84
|
+
|
|
85
|
+
const LANGIUM_TYPES: Record<string, LangiumType> = {
|
|
78
86
|
"architecture-beta": "architecture",
|
|
79
87
|
architecture: "architecture",
|
|
80
88
|
info: "info",
|
|
@@ -180,7 +188,7 @@ export const parserReason = (err: unknown): string => {
|
|
|
180
188
|
const lines = message.split("\n");
|
|
181
189
|
if (message.startsWith("No diagram type detected")) return (lines[0] ?? "").trim();
|
|
182
190
|
return lines
|
|
183
|
-
.filter((line, i) => !POINTER.test(line.trim())
|
|
191
|
+
.filter((line, i) => !(POINTER.test(line.trim()) || POINTER.test((lines[i + 1] ?? "").trim())))
|
|
184
192
|
.join(" ")
|
|
185
193
|
.replace(/\s+/g, " ")
|
|
186
194
|
.trim();
|
|
@@ -217,83 +225,7 @@ async function extractLangiumStats(content: string, keyword: string): Promise<Me
|
|
|
217
225
|
const mapped = LANGIUM_TYPES[keyword];
|
|
218
226
|
if (!mapped) throw new Error(`not a langium keyword: ${keyword}`);
|
|
219
227
|
const ast = (await langiumParseAny(mapped, content)) as Record<string, unknown>;
|
|
220
|
-
|
|
221
|
-
const nodeIds: string[] = [];
|
|
222
|
-
const subgraphNames: string[] = [];
|
|
223
|
-
let edges = 0;
|
|
224
|
-
let maxDepth = 0;
|
|
225
|
-
let treeDepth = 0;
|
|
226
|
-
|
|
227
|
-
switch (mapped) {
|
|
228
|
-
case "architecture": {
|
|
229
|
-
const services = (ast.services as Array<{ id?: string }> | undefined) ?? [];
|
|
230
|
-
const groups = (ast.groups as Array<{ id?: string; title?: string; in?: string }> | undefined) ?? [];
|
|
231
|
-
const edgeArr = (ast.edges as Array<unknown> | undefined) ?? [];
|
|
232
|
-
nodeIds.push(...services.map((s) => s.id ?? ""));
|
|
233
|
-
subgraphNames.push(...groups.map((g) => g.title ?? g.id ?? ""));
|
|
234
|
-
maxDepth = architectureDepth(groups);
|
|
235
|
-
edges = edgeArr.length;
|
|
236
|
-
break;
|
|
237
|
-
}
|
|
238
|
-
case "pie": {
|
|
239
|
-
const sections = (ast.sections as Array<{ label?: string }> | undefined) ?? [];
|
|
240
|
-
nodeIds.push(...sections.map((s, i) => s.label ?? `slice_${i}`));
|
|
241
|
-
break;
|
|
242
|
-
}
|
|
243
|
-
case "packet": {
|
|
244
|
-
const blocks = (ast.blocks as Array<unknown> | undefined) ?? [];
|
|
245
|
-
nodeIds.push(...blocks.map((_, i) => `block_${i}`));
|
|
246
|
-
break;
|
|
247
|
-
}
|
|
248
|
-
case "gitGraph": {
|
|
249
|
-
const statements = (ast.statements as Array<{ $type?: string; id?: string; name?: string }> | undefined) ?? [];
|
|
250
|
-
for (const s of statements) {
|
|
251
|
-
if (s.$type === "Commit") nodeIds.push(s.id ?? `commit_${nodeIds.length}`);
|
|
252
|
-
else if (s.$type === "Branch") subgraphNames.push(s.name ?? `branch_${subgraphNames.length}`);
|
|
253
|
-
else if (s.$type === "Merge") edges++;
|
|
254
|
-
}
|
|
255
|
-
break;
|
|
256
|
-
}
|
|
257
|
-
case "radar": {
|
|
258
|
-
const axes = (ast.axes as Array<{ name?: string }> | undefined) ?? [];
|
|
259
|
-
const curves = (ast.curves as Array<{ name?: string }> | undefined) ?? [];
|
|
260
|
-
nodeIds.push(...axes.map((a, i) => a.name ?? `axis_${i}`));
|
|
261
|
-
nodeIds.push(...curves.map((c, i) => c.name ?? `curve_${i}`));
|
|
262
|
-
edges = axes.length * curves.length;
|
|
263
|
-
break;
|
|
264
|
-
}
|
|
265
|
-
case "treeView": {
|
|
266
|
-
// AST shape: flat ast.nodes[] with { name, indent }. Tree structure
|
|
267
|
-
// derives from indent values; edges = nodes - roots.
|
|
268
|
-
const nodes = (ast.nodes as Array<{ name?: string; indent?: number }> | undefined) ?? [];
|
|
269
|
-
const stats = indentTreeStats(nodes.map((n, i) => ({ label: n.name ?? `node_${i}`, indent: n.indent ?? 0 })));
|
|
270
|
-
nodeIds.push(...stats.labels);
|
|
271
|
-
edges = stats.edges;
|
|
272
|
-
treeDepth = stats.depth;
|
|
273
|
-
break;
|
|
274
|
-
}
|
|
275
|
-
case "treemap": {
|
|
276
|
-
// AST shape: ast.TreemapRows[] with .indent and .item.{Section|Leaf}.name.
|
|
277
|
-
const rows = (ast.TreemapRows as Array<{ indent?: number; item?: { name?: string } }> | undefined) ?? [];
|
|
278
|
-
const stats = indentTreeStats(rows.map((r, i) => ({ label: r.item?.name ?? `row_${i}`, indent: r.indent ?? 0 })));
|
|
279
|
-
nodeIds.push(...stats.labels);
|
|
280
|
-
edges = stats.edges;
|
|
281
|
-
treeDepth = stats.depth;
|
|
282
|
-
break;
|
|
283
|
-
}
|
|
284
|
-
case "wardley": {
|
|
285
|
-
// AST shape: ast.anchors[], ast.components[], ast.links[] — NOT ast.edges.
|
|
286
|
-
const components = (ast.components as Array<{ name?: string }> | undefined) ?? [];
|
|
287
|
-
const anchors = (ast.anchors as Array<{ name?: string }> | undefined) ?? [];
|
|
288
|
-
const links = (ast.links as Array<unknown> | undefined) ?? [];
|
|
289
|
-
nodeIds.push(...anchors.map((a, i) => a.name ?? `anchor_${i}`));
|
|
290
|
-
nodeIds.push(...components.map((c, i) => c.name ?? `comp_${i}`));
|
|
291
|
-
edges = links.length;
|
|
292
|
-
break;
|
|
293
|
-
}
|
|
294
|
-
case "info":
|
|
295
|
-
break; // trivial diagram type, no structure
|
|
296
|
-
}
|
|
228
|
+
const { nodeIds, edges, subgraphNames, maxDepth, treeDepth = 0 } = LANGIUM_ADAPTERS[mapped](ast);
|
|
297
229
|
|
|
298
230
|
return {
|
|
299
231
|
nodes: new Set(nodeIds.filter(Boolean)).size,
|
|
@@ -309,6 +241,103 @@ async function extractLangiumStats(content: string, keyword: string): Promise<Me
|
|
|
309
241
|
};
|
|
310
242
|
}
|
|
311
243
|
|
|
244
|
+
// ─── Per-type Langium AST adapters ──
|
|
245
|
+
// Each adapter reads ONLY the typed AST its grammar produces, the way the
|
|
246
|
+
// mermaid-core adapters below read only their type's DB.
|
|
247
|
+
|
|
248
|
+
type LangiumAdapter = (ast: Record<string, unknown>) => Structure;
|
|
249
|
+
|
|
250
|
+
const LANGIUM_ADAPTERS: Record<LangiumType, LangiumAdapter> = {
|
|
251
|
+
architecture: adaptLangiumArchitecture,
|
|
252
|
+
info: adaptLangiumInfo,
|
|
253
|
+
pie: adaptLangiumPie,
|
|
254
|
+
gitGraph: adaptLangiumGitGraph,
|
|
255
|
+
packet: adaptLangiumPacket,
|
|
256
|
+
radar: adaptLangiumRadar,
|
|
257
|
+
treemap: adaptLangiumTreemap,
|
|
258
|
+
treeView: adaptLangiumTreeView,
|
|
259
|
+
wardley: adaptLangiumWardley,
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
function adaptLangiumArchitecture(ast: Record<string, unknown>): Structure {
|
|
263
|
+
const services = (ast.services as Array<{ id?: string }> | undefined) ?? [];
|
|
264
|
+
const groups = (ast.groups as Array<{ id?: string; title?: string; in?: string }> | undefined) ?? [];
|
|
265
|
+
const edgeArr = (ast.edges as Array<unknown> | undefined) ?? [];
|
|
266
|
+
return {
|
|
267
|
+
nodeIds: services.map((s) => s.id ?? ""),
|
|
268
|
+
edges: edgeArr.length,
|
|
269
|
+
subgraphNames: groups.map((g) => g.title ?? g.id ?? ""),
|
|
270
|
+
maxDepth: architectureDepth(groups),
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// A trivial diagram type, with no structure.
|
|
275
|
+
function adaptLangiumInfo(): Structure {
|
|
276
|
+
return { nodeIds: [], edges: 0, subgraphNames: [], maxDepth: 0 };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function adaptLangiumPie(ast: Record<string, unknown>): Structure {
|
|
280
|
+
const sections = (ast.sections as Array<{ label?: string }> | undefined) ?? [];
|
|
281
|
+
return { nodeIds: sections.map((s, i) => s.label ?? `slice_${i}`), edges: 0, subgraphNames: [], maxDepth: 0 };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function adaptLangiumPacket(ast: Record<string, unknown>): Structure {
|
|
285
|
+
const blocks = (ast.blocks as Array<unknown> | undefined) ?? [];
|
|
286
|
+
return { nodeIds: blocks.map((_, i) => `block_${i}`), edges: 0, subgraphNames: [], maxDepth: 0 };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function adaptLangiumGitGraph(ast: Record<string, unknown>): Structure {
|
|
290
|
+
const statements = (ast.statements as Array<{ $type?: string; id?: string; name?: string }> | undefined) ?? [];
|
|
291
|
+
const nodeIds: string[] = [];
|
|
292
|
+
const subgraphNames: string[] = [];
|
|
293
|
+
let edges = 0;
|
|
294
|
+
for (const s of statements) {
|
|
295
|
+
if (s.$type === "Commit") nodeIds.push(s.id ?? `commit_${nodeIds.length}`);
|
|
296
|
+
else if (s.$type === "Branch") subgraphNames.push(s.name ?? `branch_${subgraphNames.length}`);
|
|
297
|
+
else if (s.$type === "Merge") edges++;
|
|
298
|
+
}
|
|
299
|
+
return { nodeIds, edges, subgraphNames, maxDepth: 0 };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function adaptLangiumRadar(ast: Record<string, unknown>): Structure {
|
|
303
|
+
const axes = (ast.axes as Array<{ name?: string }> | undefined) ?? [];
|
|
304
|
+
const curves = (ast.curves as Array<{ name?: string }> | undefined) ?? [];
|
|
305
|
+
return {
|
|
306
|
+
nodeIds: [...axes.map((a, i) => a.name ?? `axis_${i}`), ...curves.map((c, i) => c.name ?? `curve_${i}`)],
|
|
307
|
+
edges: axes.length * curves.length,
|
|
308
|
+
subgraphNames: [],
|
|
309
|
+
maxDepth: 0,
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function adaptLangiumTreeView(ast: Record<string, unknown>): Structure {
|
|
314
|
+
// AST shape: flat ast.nodes[] with { name, indent }. Tree structure
|
|
315
|
+
// derives from indent values; edges = nodes - roots.
|
|
316
|
+
const nodes = (ast.nodes as Array<{ name?: string; indent?: number }> | undefined) ?? [];
|
|
317
|
+
const stats = indentTreeStats(nodes.map((n, i) => ({ label: n.name ?? `node_${i}`, indent: n.indent ?? 0 })));
|
|
318
|
+
return { nodeIds: stats.labels, edges: stats.edges, subgraphNames: [], maxDepth: 0, treeDepth: stats.depth };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function adaptLangiumTreemap(ast: Record<string, unknown>): Structure {
|
|
322
|
+
// AST shape: ast.TreemapRows[] with .indent and .item.{Section|Leaf}.name.
|
|
323
|
+
const rows = (ast.TreemapRows as Array<{ indent?: number; item?: { name?: string } }> | undefined) ?? [];
|
|
324
|
+
const stats = indentTreeStats(rows.map((r, i) => ({ label: r.item?.name ?? `row_${i}`, indent: r.indent ?? 0 })));
|
|
325
|
+
return { nodeIds: stats.labels, edges: stats.edges, subgraphNames: [], maxDepth: 0, treeDepth: stats.depth };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function adaptLangiumWardley(ast: Record<string, unknown>): Structure {
|
|
329
|
+
// AST shape: ast.anchors[], ast.components[], ast.links[] — NOT ast.edges.
|
|
330
|
+
const components = (ast.components as Array<{ name?: string }> | undefined) ?? [];
|
|
331
|
+
const anchors = (ast.anchors as Array<{ name?: string }> | undefined) ?? [];
|
|
332
|
+
const links = (ast.links as Array<unknown> | undefined) ?? [];
|
|
333
|
+
return {
|
|
334
|
+
nodeIds: [...anchors.map((a, i) => a.name ?? `anchor_${i}`), ...components.map((c, i) => c.name ?? `comp_${i}`)],
|
|
335
|
+
edges: links.length,
|
|
336
|
+
subgraphNames: [],
|
|
337
|
+
maxDepth: 0,
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
|
|
312
341
|
function architectureDepth(groups: Array<{ id?: string; in?: string }>): number {
|
|
313
342
|
const parentOf = new Map<string, string | undefined>();
|
|
314
343
|
for (const g of groups) parentOf.set(g.id ?? "", g.in);
|
package/src/plugins/discover.ts
CHANGED
|
@@ -79,23 +79,21 @@ const localSources = async (root: string): Promise<PluginSource[]> => {
|
|
|
79
79
|
|
|
80
80
|
// An approximation of Node resolution, read from a package's package.json:
|
|
81
81
|
// exports "." by node, import, default priority, then main, then index.js.
|
|
82
|
+
const CONDITIONS = ["node", "import", "default"];
|
|
83
|
+
|
|
84
|
+
// The first candidate that resolves to a target, in order.
|
|
85
|
+
const pickFirst = (candidates: unknown[]): string | null => {
|
|
86
|
+
for (const item of candidates) {
|
|
87
|
+
const found = pick(item);
|
|
88
|
+
if (found) return found;
|
|
89
|
+
}
|
|
90
|
+
return null;
|
|
91
|
+
};
|
|
92
|
+
|
|
82
93
|
const pick = (target: unknown): string | null => {
|
|
83
94
|
if (typeof target === "string") return target;
|
|
84
|
-
if (Array.isArray(target))
|
|
85
|
-
|
|
86
|
-
const found = pick(item);
|
|
87
|
-
if (found) return found;
|
|
88
|
-
}
|
|
89
|
-
return null;
|
|
90
|
-
}
|
|
91
|
-
if (isRecord(target)) {
|
|
92
|
-
for (const condition of ["node", "import", "default"]) {
|
|
93
|
-
if (condition in target) {
|
|
94
|
-
const found = pick(target[condition]);
|
|
95
|
-
if (found) return found;
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
}
|
|
95
|
+
if (Array.isArray(target)) return pickFirst(target);
|
|
96
|
+
if (isRecord(target)) return pickFirst(CONDITIONS.filter((c) => c in target).map((c) => target[c]));
|
|
99
97
|
return null;
|
|
100
98
|
};
|
|
101
99
|
|
package/src/plugins/index.ts
CHANGED
|
@@ -12,7 +12,7 @@ import { PluginConflictError, PluginLoadError } from "../errors.ts";
|
|
|
12
12
|
import { BUILTIN_CATEGORIES, type LoadedSource } from "../rules/registry.ts";
|
|
13
13
|
import type { Rule } from "../rules/types.ts";
|
|
14
14
|
import type { Plugin } from "./contract.ts";
|
|
15
|
-
import { discoverSources, LOCAL_RULES, type SourceKind } from "./discover.ts";
|
|
15
|
+
import { discoverSources, LOCAL_RULES, type PluginSource, type SourceKind } from "./discover.ts";
|
|
16
16
|
import { loadLocal, loadPackage, toRule } from "./load.ts";
|
|
17
17
|
|
|
18
18
|
export type PluginMode = "off" | "strict" | "lenient" | "collect";
|
|
@@ -33,27 +33,17 @@ export interface PluginSet {
|
|
|
33
33
|
failures: PluginFailure[];
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
|
|
37
|
-
const { sources: found, failures: unresolved } = await discoverSources(root, config, mode === "off");
|
|
38
|
-
const set: PluginSet = {
|
|
39
|
-
rules: [],
|
|
40
|
-
categories: new Map(BUILTIN_CATEGORIES),
|
|
41
|
-
sources: [],
|
|
42
|
-
skipped: [],
|
|
43
|
-
failures: [],
|
|
44
|
-
};
|
|
45
|
-
if (mode === "off") {
|
|
46
|
-
return { ...set, skipped: [...found.map((s) => s.spec), ...unresolved.map((u) => u.error.source)] };
|
|
47
|
-
}
|
|
36
|
+
type Settle = (error: PluginLoadError, kind: SourceKind) => void;
|
|
48
37
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
for (const { error, kind } of unresolved) settle(error, kind);
|
|
38
|
+
interface LoadedPlugin {
|
|
39
|
+
plugin: Plugin;
|
|
40
|
+
kind: SourceKind;
|
|
41
|
+
spec: string;
|
|
42
|
+
}
|
|
55
43
|
|
|
56
|
-
|
|
44
|
+
// Local files load as one plugin, then each package or config source on its own.
|
|
45
|
+
const loadAll = async (found: PluginSource[], settle: Settle): Promise<LoadedPlugin[]> => {
|
|
46
|
+
const loaded: LoadedPlugin[] = [];
|
|
57
47
|
const locals = found.filter((s) => s.kind === "local");
|
|
58
48
|
if (locals.length > 0) {
|
|
59
49
|
const local = await loadLocal(locals);
|
|
@@ -68,34 +58,73 @@ export const loadPlugins = async (root: string, config: Config, mode: PluginMode
|
|
|
68
58
|
settle(err, source.kind);
|
|
69
59
|
}
|
|
70
60
|
}
|
|
61
|
+
return loaded;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// Who already holds each namespace and each new category word.
|
|
65
|
+
interface Claims {
|
|
66
|
+
namespaces: Map<string, string>;
|
|
67
|
+
declaredBy: Map<string, string>;
|
|
68
|
+
}
|
|
71
69
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
const
|
|
76
|
-
const
|
|
70
|
+
// The reason a plugin cannot claim its namespace or categories, or null when
|
|
71
|
+
// it can.
|
|
72
|
+
const conflictOf = (plugin: Plugin, spec: string, claims: Claims): PluginConflictError | null => {
|
|
73
|
+
const { namespace } = plugin.meta;
|
|
74
|
+
const words = Object.keys(plugin.meta.categories ?? {});
|
|
75
|
+
const taken = claims.namespaces.get(namespace);
|
|
76
|
+
const clash = words.find((w) => claims.declaredBy.has(w));
|
|
77
|
+
if (!(taken || clash)) return null;
|
|
78
|
+
const other = (taken ?? claims.declaredBy.get(clash as string)) as string;
|
|
79
|
+
const reason = taken
|
|
80
|
+
? `namespace "${namespace}" is already used by ${other}`
|
|
81
|
+
: `category "${clash}" is already declared by ${other}`;
|
|
82
|
+
return new PluginConflictError(spec, other, reason);
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
// Namespaces and new categories are global. The first plugin to claim one
|
|
86
|
+
// keeps it, and a later claimant fails, naming both.
|
|
87
|
+
const acceptClaims = (loaded: LoadedPlugin[], set: PluginSet, settle: Settle): LoadedPlugin[] => {
|
|
88
|
+
const claims: Claims = { namespaces: new Map(), declaredBy: new Map() };
|
|
89
|
+
const accepted: LoadedPlugin[] = [];
|
|
77
90
|
for (const entry of loaded) {
|
|
78
91
|
const { plugin, spec, kind } = entry;
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
const clash = words.find((w) => declaredBy.has(w));
|
|
83
|
-
if (taken || clash) {
|
|
84
|
-
const other = (taken ?? declaredBy.get(clash as string)) as string;
|
|
85
|
-
const reason = taken
|
|
86
|
-
? `namespace "${namespace}" is already used by ${other}`
|
|
87
|
-
: `category "${clash}" is already declared by ${other}`;
|
|
88
|
-
settle(new PluginConflictError(spec, other, reason), kind);
|
|
92
|
+
const conflict = conflictOf(plugin, spec, claims);
|
|
93
|
+
if (conflict) {
|
|
94
|
+
settle(conflict, kind);
|
|
89
95
|
continue;
|
|
90
96
|
}
|
|
91
|
-
namespaces.set(namespace, spec);
|
|
97
|
+
claims.namespaces.set(plugin.meta.namespace, spec);
|
|
92
98
|
for (const [word, description] of Object.entries(plugin.meta.categories ?? {})) {
|
|
93
|
-
declaredBy.set(word, spec);
|
|
99
|
+
claims.declaredBy.set(word, spec);
|
|
94
100
|
set.categories.set(word, description);
|
|
95
101
|
}
|
|
96
102
|
accepted.push(entry);
|
|
97
103
|
}
|
|
104
|
+
return accepted;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
export const loadPlugins = async (root: string, config: Config, mode: PluginMode): Promise<PluginSet> => {
|
|
108
|
+
const { sources: found, failures: unresolved } = await discoverSources(root, config, mode === "off");
|
|
109
|
+
const set: PluginSet = {
|
|
110
|
+
rules: [],
|
|
111
|
+
categories: new Map(BUILTIN_CATEGORIES),
|
|
112
|
+
sources: [],
|
|
113
|
+
skipped: [],
|
|
114
|
+
failures: [],
|
|
115
|
+
};
|
|
116
|
+
if (mode === "off") {
|
|
117
|
+
return { ...set, skipped: [...found.map((s) => s.spec), ...unresolved.map((u) => u.error.source)] };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Strict stops at the first failure. The others record it and carry on.
|
|
121
|
+
const settle: Settle = (error, kind) => {
|
|
122
|
+
if (mode === "strict") throw error;
|
|
123
|
+
if (!set.failures.some((f) => f.source === error.source)) set.failures.push({ source: error.source, kind, error });
|
|
124
|
+
};
|
|
125
|
+
for (const { error, kind } of unresolved) settle(error, kind);
|
|
98
126
|
|
|
127
|
+
const accepted = acceptClaims(await loadAll(found, settle), set, settle);
|
|
99
128
|
for (const { plugin, kind, spec } of accepted) {
|
|
100
129
|
const rules = Object.entries(plugin.rules).map(([key, rule]) => toRule(plugin.meta.namespace, key, rule));
|
|
101
130
|
set.rules.push(...rules);
|