@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
package/src/plugins/load.ts
CHANGED
|
@@ -52,19 +52,14 @@ const OPTION_TYPES = new Set<OptionType>(["number", "string", "boolean", "string
|
|
|
52
52
|
// Option names every rule shares with the built-ins. See checkRule.
|
|
53
53
|
const RESERVED_OPTIONS = ["preset", "profile"] as const;
|
|
54
54
|
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
const
|
|
55
|
+
// A rule's category is one word, built in or declared by its plugin, and its
|
|
56
|
+
// key starts with it.
|
|
57
|
+
const checkRuleCategory = (spec: string, key: string, category: unknown, own: ReadonlySet<string>): void => {
|
|
58
58
|
const where = `rule "${key}"`;
|
|
59
|
-
if (!isRecord(raw)) throw new PluginContractError(spec, `${where} must be an object`);
|
|
60
|
-
if (!RULE_KEY.test(key)) {
|
|
61
|
-
throw new PluginContractError(spec, `${where} is not a valid key; use lower-case words joined by hyphens`);
|
|
62
|
-
}
|
|
63
|
-
const { category, summary, options, check, fix } = raw;
|
|
64
59
|
if (typeof category !== "string" || !CATEGORY_WORD.test(category)) {
|
|
65
60
|
throw new PluginContractError(spec, `${where} needs a category of one lower-case word`);
|
|
66
61
|
}
|
|
67
|
-
if (!BUILTIN_CATEGORIES.has(category)
|
|
62
|
+
if (!(BUILTIN_CATEGORIES.has(category) || own.has(category))) {
|
|
68
63
|
throw new PluginContractError(
|
|
69
64
|
spec,
|
|
70
65
|
`${where} uses the category "${category}", which this plugin does not declare in meta.categories`,
|
|
@@ -73,32 +68,47 @@ const checkRule = (spec: string, key: string, raw: unknown, own: ReadonlySet<str
|
|
|
73
68
|
if (!key.startsWith(`${category}-`)) {
|
|
74
69
|
throw new PluginContractError(spec, `${where} must start with its category, as "${category}-short-name"`);
|
|
75
70
|
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const checkRuleOptions = (spec: string, where: string, options: unknown): void => {
|
|
74
|
+
if (options === undefined) return;
|
|
75
|
+
const valid = isRecord(options) && Object.values(options).every((t) => OPTION_TYPES.has(t as OptionType));
|
|
76
|
+
if (!valid) {
|
|
77
|
+
throw new PluginContractError(
|
|
78
|
+
spec,
|
|
79
|
+
`${where} options must map names to number, string, boolean, string[] or canvas`,
|
|
80
|
+
);
|
|
80
81
|
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
82
|
+
// preset and profile keep their built-in meaning for every rule: the
|
|
83
|
+
// config checks the value against the built-in names, and --preset and
|
|
84
|
+
// --profile overwrite it. A rule may declare one to follow the run's
|
|
85
|
+
// setting, as a string, but cannot give the name another meaning.
|
|
86
|
+
for (const name of RESERVED_OPTIONS) {
|
|
87
|
+
if (name in options && options[name] !== "string") {
|
|
84
88
|
throw new PluginContractError(
|
|
85
89
|
spec,
|
|
86
|
-
`${where} options
|
|
90
|
+
`${where} declares option "${name}" as ${String(options[name])}, but "${name}" is reserved for the built-in ${name}, which is a string set by --${name}; declare it as "string" to follow the run's ${name}, or pick another name`,
|
|
87
91
|
);
|
|
88
92
|
}
|
|
89
|
-
// preset and profile keep their built-in meaning for every rule: the
|
|
90
|
-
// config checks the value against the built-in names, and --preset and
|
|
91
|
-
// --profile overwrite it. A rule may declare one to follow the run's
|
|
92
|
-
// setting, as a string, but cannot give the name another meaning.
|
|
93
|
-
for (const name of RESERVED_OPTIONS) {
|
|
94
|
-
if (name in options && (options as Record<string, unknown>)[name] !== "string") {
|
|
95
|
-
throw new PluginContractError(
|
|
96
|
-
spec,
|
|
97
|
-
`${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`,
|
|
98
|
-
);
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
93
|
}
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
// Checks one rule's declaration and returns it, so a bad rule fails at load
|
|
97
|
+
// rather than the first time it runs.
|
|
98
|
+
const checkRule = (spec: string, key: string, raw: unknown, own: ReadonlySet<string>): PluginRule => {
|
|
99
|
+
const where = `rule "${key}"`;
|
|
100
|
+
if (!isRecord(raw)) throw new PluginContractError(spec, `${where} must be an object`);
|
|
101
|
+
if (!RULE_KEY.test(key)) {
|
|
102
|
+
throw new PluginContractError(spec, `${where} is not a valid key; use lower-case words joined by hyphens`);
|
|
103
|
+
}
|
|
104
|
+
const { category, summary, options, check, fix } = raw;
|
|
105
|
+
checkRuleCategory(spec, key, category, own);
|
|
106
|
+
if (typeof summary !== "string" || summary === "") throw new PluginContractError(spec, `${where} needs a summary`);
|
|
107
|
+
if (typeof check !== "function") throw new PluginContractError(spec, `${where} needs a check function`);
|
|
108
|
+
if (fix !== undefined && typeof fix !== "function") {
|
|
109
|
+
throw new PluginContractError(spec, `${where} fix must be a function`);
|
|
110
|
+
}
|
|
111
|
+
checkRuleOptions(spec, where, options);
|
|
102
112
|
return raw as unknown as PluginRule;
|
|
103
113
|
};
|
|
104
114
|
|
|
@@ -120,7 +130,7 @@ const checkCategories = (spec: string, raw: unknown): Record<string, string> =>
|
|
|
120
130
|
// A plugin object is checked here, apart from how it was imported, so a test
|
|
121
131
|
// can hold the same plugin to the same contract the loader does.
|
|
122
132
|
export const parsePlugin = (spec: string, raw: unknown, reserved: ReadonlySet<string>): Plugin => {
|
|
123
|
-
if (!isRecord(raw)
|
|
133
|
+
if (!(isRecord(raw) && isRecord(raw.meta) && isRecord(raw.rules))) {
|
|
124
134
|
throw new PluginContractError(spec, "the default export must be { meta, rules }");
|
|
125
135
|
}
|
|
126
136
|
const { name, namespace, apiVersion } = raw.meta;
|
|
@@ -157,50 +167,73 @@ export interface LocalLoad {
|
|
|
157
167
|
// as one plugin under the fixed namespace `local`, and a file may export
|
|
158
168
|
// `categories` to declare the word its rule uses. Each file fails on its own,
|
|
159
169
|
// so one bad file does not hide the others, and the caller gets every failure.
|
|
170
|
+
// A rule's key is its file's base name.
|
|
171
|
+
const keyOf = (source: PluginSource): string => basename(source.path, extname(source.path));
|
|
172
|
+
|
|
173
|
+
const groupByKey = (sources: PluginSource[]): Map<string, PluginSource[]> => {
|
|
174
|
+
const byKey = new Map<string, PluginSource[]>();
|
|
175
|
+
for (const source of sources) byKey.set(keyOf(source), [...(byKey.get(keyOf(source)) ?? []), source]);
|
|
176
|
+
return byKey;
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const duplicateKeyError = (key: string, group: PluginSource[]): PluginContractError =>
|
|
180
|
+
new PluginContractError(
|
|
181
|
+
(group[0] as PluginSource).spec,
|
|
182
|
+
`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`,
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
// The categories the local files declare so far, and the file that first
|
|
186
|
+
// declared each word.
|
|
187
|
+
interface LocalCategories {
|
|
188
|
+
categories: Record<string, string>;
|
|
189
|
+
owner: Record<string, string>;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const mergeCategories = (seen: LocalCategories, spec: string, declared: Record<string, string>): void => {
|
|
193
|
+
for (const [word, description] of Object.entries(declared)) {
|
|
194
|
+
if (seen.categories[word] !== undefined && seen.categories[word] !== description) {
|
|
195
|
+
throw new PluginConflictError(
|
|
196
|
+
spec,
|
|
197
|
+
seen.owner[word] as string,
|
|
198
|
+
`declares category "${word}" with a different description than another local file`,
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
seen.categories[word] = description;
|
|
202
|
+
seen.owner[word] = spec;
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
// Imports one local file and returns its rule, unchecked until every file's
|
|
207
|
+
// categories are known.
|
|
208
|
+
const importLocal = async (source: PluginSource, seen: LocalCategories, runtime?: RuntimeInfo): Promise<unknown> => {
|
|
209
|
+
const mod = await importModule(source, runtime);
|
|
210
|
+
if (mod.default === undefined) {
|
|
211
|
+
throw new PluginContractError(source.spec, "has no default export; export one rule");
|
|
212
|
+
}
|
|
213
|
+
mergeCategories(seen, source.spec, checkCategories(source.spec, mod.categories));
|
|
214
|
+
return mod.default;
|
|
215
|
+
};
|
|
216
|
+
|
|
160
217
|
export const loadLocal = async (sources: PluginSource[], runtime?: RuntimeInfo): Promise<LocalLoad> => {
|
|
161
218
|
const failures: PluginLoadError[] = [];
|
|
162
219
|
const fail = (err: unknown): void => {
|
|
163
220
|
if (!(err instanceof PluginLoadError)) throw err;
|
|
164
221
|
failures.push(err);
|
|
165
222
|
};
|
|
166
|
-
const categories:
|
|
167
|
-
const
|
|
223
|
+
const seen: LocalCategories = { categories: {}, owner: {} };
|
|
224
|
+
const { categories } = seen;
|
|
168
225
|
const loaded: { key: string; source: PluginSource; raw: unknown }[] = [];
|
|
169
226
|
// A rule's key is its file's base name, so two files with one base name
|
|
170
227
|
// (house-x.js and house-x.mjs) would claim one rule. Neither is imported,
|
|
171
228
|
// and the failure names both, rather than one silently replacing the other.
|
|
172
|
-
const
|
|
173
|
-
const byKey = new Map<string, PluginSource[]>();
|
|
174
|
-
for (const source of sources) byKey.set(keyOf(source), [...(byKey.get(keyOf(source)) ?? []), source]);
|
|
229
|
+
const byKey = groupByKey(sources);
|
|
175
230
|
for (const [key, group] of byKey) {
|
|
176
|
-
if (group.length
|
|
177
|
-
fail(
|
|
178
|
-
new PluginContractError(
|
|
179
|
-
(group[0] as PluginSource).spec,
|
|
180
|
-
`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`,
|
|
181
|
-
),
|
|
182
|
-
);
|
|
231
|
+
if (group.length > 1) fail(duplicateKeyError(key, group));
|
|
183
232
|
}
|
|
184
233
|
for (const source of sources) {
|
|
185
234
|
if ((byKey.get(keyOf(source))?.length ?? 0) > 1) continue;
|
|
186
235
|
try {
|
|
187
|
-
|
|
188
|
-
if (mod.default === undefined) {
|
|
189
|
-
throw new PluginContractError(source.spec, "has no default export; export one rule");
|
|
190
|
-
}
|
|
191
|
-
const declared = checkCategories(source.spec, mod.categories);
|
|
192
|
-
for (const [word, description] of Object.entries(declared)) {
|
|
193
|
-
if (categories[word] !== undefined && categories[word] !== description) {
|
|
194
|
-
throw new PluginConflictError(
|
|
195
|
-
source.spec,
|
|
196
|
-
owner[word] as string,
|
|
197
|
-
`declares category "${word}" with a different description than another local file`,
|
|
198
|
-
);
|
|
199
|
-
}
|
|
200
|
-
categories[word] = description;
|
|
201
|
-
owner[word] = source.spec;
|
|
202
|
-
}
|
|
203
|
-
loaded.push({ key: keyOf(source), source, raw: mod.default });
|
|
236
|
+
loaded.push({ key: keyOf(source), source, raw: await importLocal(source, seen, runtime) });
|
|
204
237
|
} catch (err) {
|
|
205
238
|
fail(err);
|
|
206
239
|
}
|
|
@@ -250,7 +283,7 @@ export const toRule = (namespace: string, key: string, rule: PluginRule): Rule =
|
|
|
250
283
|
} catch (err) {
|
|
251
284
|
return report(`threw: ${message(err)}`);
|
|
252
285
|
}
|
|
253
|
-
if (!Array.isArray(found)
|
|
286
|
+
if (!(Array.isArray(found) && found.every(validFinding))) {
|
|
254
287
|
return report("returned something other than a list of { line, message } findings");
|
|
255
288
|
}
|
|
256
289
|
return found.map((f) => ({
|
|
@@ -273,7 +306,7 @@ export const toRule = (namespace: string, key: string, rule: PluginRule): Rule =
|
|
|
273
306
|
if (!Array.isArray(edits))
|
|
274
307
|
throw new PluginFixerError(id, "fixer returned something other than a list of edits");
|
|
275
308
|
return edits.map((e: Edit) => {
|
|
276
|
-
if (!isRecord(e)
|
|
309
|
+
if (!(isRecord(e) && isRecord(e.expect) && EXPECTATIONS.has(e.expect.kind as string))) {
|
|
277
310
|
throw new PluginFixerError(id, "fixer returned an edit with no valid expectation");
|
|
278
311
|
}
|
|
279
312
|
return { ...e, rule: id };
|
package/src/rules/complexity.ts
CHANGED
|
@@ -28,7 +28,7 @@ export const complexityRule = (spec: {
|
|
|
28
28
|
const measured = await measure(diagram, config);
|
|
29
29
|
if (!measured) continue;
|
|
30
30
|
const hit = spec.hit(measured, config);
|
|
31
|
-
if (hit) found.push(finding(ctx, diagram, spec.id, hit
|
|
31
|
+
if (hit) found.push(finding(ctx, diagram, spec.id, hit));
|
|
32
32
|
}
|
|
33
33
|
return found;
|
|
34
34
|
},
|
|
@@ -1,12 +1,44 @@
|
|
|
1
|
+
import type { Diagram } from "../model.ts";
|
|
1
2
|
import { compositeOver, wcagRatio } from "../parse/color.ts";
|
|
3
|
+
import type { ContrastPair, ContrastSource, Profile } from "../parse/contrast.ts";
|
|
2
4
|
import { CONTRAST_OPTIONS, contrastOf, fileLine, settingsOf } from "./shared.ts";
|
|
3
|
-
import { type Edit, type Finding, RULE, type Rule } from "./types.ts";
|
|
5
|
+
import { type Edit, type Finding, RULE, type Rule, type RuleContext } from "./types.ts";
|
|
4
6
|
|
|
5
7
|
const AA_NORMAL = 4.5;
|
|
6
8
|
const CANDIDATES = ["#000000", "#ffffff"] as const;
|
|
7
9
|
|
|
8
10
|
const ratioOn = (fg: string, bg: string): number => wcagRatio(compositeOver(fg, bg), bg);
|
|
9
11
|
|
|
12
|
+
// How to fix failing text, by who controls it and what the colour is.
|
|
13
|
+
const remediationOf = (profile: Profile, kind: ContrastSource["kind"]): string => {
|
|
14
|
+
if (profile === "mkdocs-material") {
|
|
15
|
+
return "The host theme forces the label text, so change the fill until the host text reads on it in both themes.";
|
|
16
|
+
}
|
|
17
|
+
if (kind === "themeVariables") {
|
|
18
|
+
return "A sequence diagram colour is a theme variable, and text with no box of its own sits on the page, so set the variable to a colour that reads on its background in both themes, or remove it and let the theme set it.";
|
|
19
|
+
}
|
|
20
|
+
if (kind === "linkStyle") {
|
|
21
|
+
return "An edge label sits on Mermaid's own label box, light in one theme and grey in the other, and no one colour reads on both, so drop color: from the linkStyle and let the theme set the label text.";
|
|
22
|
+
}
|
|
23
|
+
return "Darken or lighten the fill, or change color:, until the ratio reaches 4.5:1 in every theme.";
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// The finding for one failing text pair.
|
|
27
|
+
const textFinding = (ctx: RuleContext, diagram: Diagram, p: ContrastPair, profile: Profile): Finding => {
|
|
28
|
+
const where = [p.theme, p.row].filter(Boolean).join("/");
|
|
29
|
+
const line = fileLine(diagram, p.line);
|
|
30
|
+
return {
|
|
31
|
+
file: ctx.file,
|
|
32
|
+
line,
|
|
33
|
+
endLine: line,
|
|
34
|
+
rule: RULE.TEXT,
|
|
35
|
+
message:
|
|
36
|
+
`${p.directive.kind} ${p.selector}: text ${p.assessment.ratio.toFixed(2)}:1 < ${AA_NORMAL} ` +
|
|
37
|
+
`(${p.foreground} on ${p.background}${where ? `, ${where}` : ""})`,
|
|
38
|
+
remediation: remediationOf(profile, p.directive.kind),
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
|
|
10
42
|
// Label text must reach 4.5:1 against its fill, in every theme the fill is
|
|
11
43
|
// scored in. The fix swaps a declared color: for black or white, whichever
|
|
12
44
|
// reads better, and only when that clears 4.5:1 on every background the
|
|
@@ -25,26 +57,7 @@ export const contrastTextBelowAa: Rule = {
|
|
|
25
57
|
const found: Finding[] = [];
|
|
26
58
|
for (const diagram of ctx.doc.diagrams) {
|
|
27
59
|
for (const p of (await contrastOf(diagram, settings)).pairs) {
|
|
28
|
-
if (p.kind
|
|
29
|
-
const where = [p.theme, p.row].filter(Boolean).join("/");
|
|
30
|
-
const line = fileLine(diagram, p.line);
|
|
31
|
-
found.push({
|
|
32
|
-
file: ctx.file,
|
|
33
|
-
line,
|
|
34
|
-
endLine: line,
|
|
35
|
-
rule: RULE.TEXT,
|
|
36
|
-
message:
|
|
37
|
-
`${p.directive.kind} ${p.selector}: text ${p.assessment.ratio.toFixed(2)}:1 < ${AA_NORMAL} ` +
|
|
38
|
-
`(${p.foreground} on ${p.background}${where ? `, ${where}` : ""})`,
|
|
39
|
-
remediation:
|
|
40
|
-
settings.profile === "mkdocs-material"
|
|
41
|
-
? "The host theme forces the label text, so change the fill until the host text reads on it in both themes."
|
|
42
|
-
: p.directive.kind === "themeVariables"
|
|
43
|
-
? "A sequence diagram colour is a theme variable, and text with no box of its own sits on the page, so set the variable to a colour that reads on its background in both themes, or remove it and let the theme set it."
|
|
44
|
-
: p.directive.kind === "linkStyle"
|
|
45
|
-
? "An edge label sits on Mermaid's own label box, light in one theme and grey in the other, and no one colour reads on both, so drop color: from the linkStyle and let the theme set the label text."
|
|
46
|
-
: "Darken or lighten the fill, or change color:, until the ratio reaches 4.5:1 in every theme.",
|
|
47
|
-
});
|
|
60
|
+
if (p.kind === "text" && !p.passes) found.push(textFinding(ctx, diagram, p, settings.profile));
|
|
48
61
|
}
|
|
49
62
|
}
|
|
50
63
|
return found;
|
|
@@ -38,7 +38,7 @@ export const parseDiagramUnparseable: Rule = {
|
|
|
38
38
|
const remediation = EXTERNAL_TYPES.has(stats.diagram_type)
|
|
39
39
|
? `No fixer can help: mermaidjs-lint has no ${stats.diagram_type} parser. Redraw it as a type mermaid core includes, such as sequenceDiagram, or turn this rule off in the config.`
|
|
40
40
|
: "Verify the diagram renders in mermaid.js and fix its syntax. Complexity rules are skipped for this diagram while it cannot be parsed.";
|
|
41
|
-
found.push(finding(ctx, diagram, RULE.UNPARSEABLE, messageOf(stats), remediation));
|
|
41
|
+
found.push(finding(ctx, diagram, RULE.UNPARSEABLE, { message: messageOf(stats), remediation }));
|
|
42
42
|
}
|
|
43
43
|
return found;
|
|
44
44
|
},
|
|
@@ -15,13 +15,11 @@ export const parseFenceUnterminated: Rule = {
|
|
|
15
15
|
ctx.doc.diagrams
|
|
16
16
|
.filter((d) => !d.terminated)
|
|
17
17
|
.map((d) =>
|
|
18
|
-
finding(
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
),
|
|
18
|
+
finding(ctx, d, RULE.FENCE_UNTERMINATED, {
|
|
19
|
+
message: `mermaid fence opened at line ${d.startLine} is never closed`,
|
|
20
|
+
remediation:
|
|
21
|
+
"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.",
|
|
22
|
+
}),
|
|
25
23
|
),
|
|
26
24
|
fix: (ctx) => {
|
|
27
25
|
const edits: Edit[] = [];
|
package/src/rules/registry.ts
CHANGED
|
@@ -127,19 +127,23 @@ const checkCanvas = (where: string, rule: Rule, name: string, value: Record<Them
|
|
|
127
127
|
}
|
|
128
128
|
};
|
|
129
129
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
}
|
|
138
|
-
if (typeOf(value) !== want) {
|
|
139
|
-
throw new RuleOptionError(where, rule.id, name, `option "${name}" of "${rule.id}" must be ${describeType(want)}`);
|
|
140
|
-
}
|
|
141
|
-
if (want === "canvas") checkCanvas(where, rule, name, value as Record<Theme, unknown>);
|
|
130
|
+
// One option the rule must declare, holding a value of the declared type.
|
|
131
|
+
const checkOption = (where: string, rule: Rule, name: string, value: unknown): void => {
|
|
132
|
+
const want = rule.options?.[name];
|
|
133
|
+
if (!want) {
|
|
134
|
+
const accepted = Object.keys(rule.options ?? {});
|
|
135
|
+
const list = accepted.length > 0 ? `it accepts ${accepted.join(", ")}` : "it takes no options";
|
|
136
|
+
throw new RuleOptionError(where, rule.id, name, `rule "${rule.id}" has no option "${name}"; ${list}`);
|
|
142
137
|
}
|
|
138
|
+
if (typeOf(value) !== want) {
|
|
139
|
+
throw new RuleOptionError(where, rule.id, name, `option "${name}" of "${rule.id}" must be ${describeType(want)}`);
|
|
140
|
+
}
|
|
141
|
+
if (want === "canvas") checkCanvas(where, rule, name, value as Record<Theme, unknown>);
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
// The options every rule shares with the built-ins, checked against each other
|
|
145
|
+
// and the built-in names.
|
|
146
|
+
const checkSharedOptions = (where: string, rule: Rule, options: RuleOptions): void => {
|
|
143
147
|
// Material draws its own page, so a canvas has nothing to set there.
|
|
144
148
|
if (options.profile === "mkdocs-material" && options.canvas !== undefined && rule.options?.canvas === "canvas") {
|
|
145
149
|
throw new RuleOptionError(
|
|
@@ -162,10 +166,20 @@ const checkOptions = (where: string, rule: Rule, options: RuleOptions): void =>
|
|
|
162
166
|
}
|
|
163
167
|
};
|
|
164
168
|
|
|
165
|
-
|
|
166
|
-
const
|
|
167
|
-
|
|
168
|
-
|
|
169
|
+
const checkOptions = (where: string, rule: Rule, options: RuleOptions): void => {
|
|
170
|
+
for (const [name, value] of Object.entries(options)) checkOption(where, rule, name, value);
|
|
171
|
+
checkSharedOptions(where, rule, options);
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
// What the config makes of the rules: which are off, each rule's options, and
|
|
175
|
+
// the settings dropped for a plugin that did not load.
|
|
176
|
+
interface Configured {
|
|
177
|
+
off: Set<string>;
|
|
178
|
+
options: Map<RuleId, RuleOptions>;
|
|
179
|
+
ignored: string[];
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const applyConfig = (rules: Rule[], config: Config | undefined, ignoreUnknownPluginIds: boolean): Configured => {
|
|
169
183
|
const off = new Set<string>();
|
|
170
184
|
const options = new Map<RuleId, RuleOptions>();
|
|
171
185
|
const ignored: string[] = [];
|
|
@@ -192,6 +206,14 @@ export const buildRegistry = (input: RegistryInput): Registry => {
|
|
|
192
206
|
options.set(rule.id, setting.options);
|
|
193
207
|
if (setting.severity === "off") off.add(id);
|
|
194
208
|
}
|
|
209
|
+
return { off, options, ignored };
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
export const buildRegistry = (input: RegistryInput): Registry => {
|
|
213
|
+
const { rules, fixOrder, categories, sources = [], config, ignoreUnknownPluginIds = false } = input;
|
|
214
|
+
const selection = input.selection ?? {};
|
|
215
|
+
checkSelection(selection, categories);
|
|
216
|
+
const { off, options, ignored } = applyConfig(rules, config, ignoreUnknownPluginIds);
|
|
195
217
|
// Every rule starts on, the config turns some off, and then the category
|
|
196
218
|
// flags have the last word for this run (MJL-0020).
|
|
197
219
|
for (const rule of rules) {
|
package/src/rules/shared.ts
CHANGED
|
@@ -55,12 +55,17 @@ export const measure = async (diagram: Diagram, config: ThresholdConfig): Promis
|
|
|
55
55
|
return { stats, metrics: calculateComplexity(stats, config) };
|
|
56
56
|
};
|
|
57
57
|
|
|
58
|
+
// A finding's text: what is wrong, and how to fix it when there is a way.
|
|
59
|
+
export interface FindingText {
|
|
60
|
+
message: string;
|
|
61
|
+
remediation?: string | undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
58
64
|
export const finding = (
|
|
59
65
|
ctx: RuleContext,
|
|
60
66
|
diagram: Diagram,
|
|
61
67
|
rule: RuleId,
|
|
62
|
-
message:
|
|
63
|
-
remediation?: string,
|
|
68
|
+
{ message, remediation }: FindingText,
|
|
64
69
|
): Finding => ({
|
|
65
70
|
file: ctx.file,
|
|
66
71
|
line: diagram.startLine,
|
|
@@ -129,7 +134,7 @@ export const contrastOf = (diagram: Diagram, settings: ContrastSettings): Promis
|
|
|
129
134
|
}
|
|
130
135
|
let result = bySettings.get(key);
|
|
131
136
|
if (!result) {
|
|
132
|
-
result = scoreDiagram(diagram
|
|
137
|
+
result = scoreDiagram(diagram, profile, canvas);
|
|
133
138
|
bySettings.set(key, result);
|
|
134
139
|
}
|
|
135
140
|
return result;
|