@jpeakai/mermaidjs-lint 0.6.3 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,30 +1,37 @@
1
- # jpai-mermaidjs-lint
1
+ # mermaidjs-lint
2
2
 
3
- Diagrams in documentation should be readable.
4
- Deterministic checks and verified fixes for Mermaid diagrams in markdown.
3
+ Catch Mermaid diagrams in your markdown that fail to parse, turn unreadable in light or dark themes, or grow too big to follow.
5
4
 
6
- **[Docs](https://jpeakai.github.io/jpai-mermaidjs-lint/)** | **[Rules](https://jpeakai.github.io/jpai-mermaidjs-lint/reference/rules/)** | **[Changelog](CHANGELOG.md)** | **[GitHub](https://github.com/jpeakai/jpai-mermaidjs-lint)** | **[npm](https://www.npmjs.com/package/@jpeakai/mermaidjs-lint)**
5
+ **[Docs](https://jpeakai.github.io/jpai-mermaidjs-lint/)** | **[Rules](https://jpeakai.github.io/jpai-mermaidjs-lint/reference/rules/)** | **[Changelog](CHANGELOG.md)** | **[npm](https://www.npmjs.com/package/@jpeakai/mermaidjs-lint)**
7
6
 
8
- The docs site describes the latest release on npm.
9
- This README, read on GitHub, describes the `main` branch.
10
- A change on `main` that is not yet released is listed under `Unreleased` in the [changelog](CHANGELOG.md).
11
-
12
- ## Usage
7
+ ## Lint your docs with one command
13
8
 
14
9
  ```sh
15
- bunx @jpeakai/mermaidjs-lint README.md docs/ # report findings, exit 1 if any
16
- npx -y @jpeakai/mermaidjs-lint --fix docs/ # apply every fix proven safe, report the rest
10
+ npx -y @jpeakai/mermaidjs-lint docs/
11
+ ```
12
+
13
+ ```text
14
+ docs/architecture.md:6 contrast-text-below-aa classDef warn: text 1.43:1 < 4.5 (#ffffff on #ffd400)
15
+ docs/login.md:3-7 parse-diagram-unparseable sequenceDiagram did not parse (parser: mermaid-core) (its line numbers count within the diagram, not the file): Parse error on line 3: Expecting 'TXT', got 'NEWLINE'
16
+ docs/pipeline.md:3-41 complexity-node-count-acceptable-exceeded 37 nodes > 35 acceptable threshold
17
+ 3 finding(s) in 3 file(s)
17
18
  ```
18
19
 
19
- Pass your own paths: every path you name must exist, or the run stops with a usage error and exit 2.
20
- A `.mmd` or `.mermaid` file is read as one diagram, and any other file you name is read as markdown.
21
- A directory is walked for `.mmd`, `.mermaid`, `.md` and `.markdown` files.
22
- The walk skips dot-folders and any folder named `node_modules`, `vendor`, `dist`, `build`, `tmp` or `__pycache__`, at any depth.
23
- Installed as a dev dependency, the command is `mermaidjs-lint`, and `--help` lists every flag.
20
+ The run exits 1 while any finding remains, so it works as a CI gate.
21
+
22
+ ## Fix unreadable label text and unclosed fences
23
+
24
+ Add `--fix` to apply the fixes that pass verification:
25
+
26
+ ```diff
27
+ - classDef warn fill:#ffd400,color:#ffffff
28
+ + classDef warn fill:#ffd400,color:#000000
29
+ ```
24
30
 
25
- [Get started](https://jpeakai.github.io/jpai-mermaidjs-lint/get-started/) walks through a first run, a fix, and adding the check to a project.
31
+ A fix is kept only if the markdown around the diagrams is unchanged and each diagram reparses with the same node ids, subgraphs and counts.
32
+ Edge endpoints, edge labels and node text are not compared, so review the diff.
26
33
 
27
- ## The four tenets
34
+ ## Four tenets define the tool
28
35
 
29
36
  Three tenets are guarantees about every diagram the tool passes, and the fourth is a commitment about the tool.
30
37
 
@@ -33,101 +40,28 @@ Three tenets are guarantees about every diagram the tool passes, and the fourth
33
40
  - **Simple enough to follow.** Node count and visual complexity are scored against a density preset, so an oversized diagram is flagged to be split.
34
41
  - **Extensible.** A plugin adds rules of your own, which run through the same config, fix engine and verification as the built-ins.
35
42
 
36
- | Tenet | Delivered by |
37
- |---|---|
38
- | Readable in light and dark | The contrast rules |
39
- | Renders | The parse rules |
40
- | Simple enough to follow | The complexity rules |
41
- | Extensible | Local rules and plugin packages |
43
+ ## Go further in the docs site
42
44
 
43
- The [concepts page](https://jpeakai.github.io/jpai-mermaidjs-lint/concepts/) states the limit of each tenet.
44
- `bunx @jpeakai/mermaidjs-lint --list-rules` prints every rule.
45
- The [rules page](https://jpeakai.github.io/jpai-mermaidjs-lint/reference/rules/) draws each one before and after `--fix`, and [RULES.md](RULES.md) holds the same examples as plain markdown.
46
-
47
- Every finding fails the run, and a rule you do not want is switched off in the config.
48
- Node, edge and subgraph counts come from mermaid's own parsers.
49
- Style directives are read line by line, so a style joined to other statements by a semicolon is not seen.
50
- Every fix is verified by parsing the document again, and a fix that fails the check is refused, so the finding stays for a human.
51
- The check holds the markdown outside the diagrams unchanged, and each diagram's type, node ids, subgraph names and counts, and whether it parses.
52
- It does not compare edge endpoints, edge labels or node text, so review the diff a fix writes.
53
-
54
- ## Requirements and compatibility
55
-
56
- - **Runtime:** Bun, or Node `>=20` as the `engines` field of `package.json` states.
57
- - **Mermaid:** the parse and contrast rules use the `mermaid` package at exactly `11.17.2`, and `@mermaid-js/parser`, both installed with this package. The pin holds sequence diagram colours, which change between mermaid releases.
58
- - **Your host's mermaid may differ.** GitHub and other hosts draw diagrams with their own mermaid version. A diagram type newer than the host's may parse here and still fail to draw there.
59
-
60
- `npm ls mermaid` in your project prints the mermaid version a run uses.
61
- [Reference](https://jpeakai.github.io/jpai-mermaidjs-lint/reference/#requirements-and-compatibility) lists which parser reads each diagram type.
62
-
63
- ## Configure and extend
64
-
65
- - [Configure the rules](https://jpeakai.github.io/jpai-mermaidjs-lint/guides/configure/): switch a rule off, change the complexity preset, set options.
66
- - [Lint a MkDocs Material site](https://jpeakai.github.io/jpai-mermaidjs-lint/guides/mkdocs/): score diagrams the way the theme renders them.
67
- - [Write a local rule](https://jpeakai.github.io/jpai-mermaidjs-lint/guides/local-rule/) or [publish a plugin package](https://jpeakai.github.io/jpai-mermaidjs-lint/guides/plugin-package/): add rules of your own, which load with no config.
68
- - [Troubleshooting](https://jpeakai.github.io/jpai-mermaidjs-lint/guides/troubleshooting/): a README that fails after adding `mkdocs.yml`, skipping a diagram, and other common questions.
45
+ - [Get started](https://jpeakai.github.io/jpai-mermaidjs-lint/get-started/): run it, fix a diagram, and add the gate to your project.
46
+ - [Gate diagrams in CI](https://jpeakai.github.io/jpai-mermaidjs-lint/guides/ci/) or [before each commit](https://jpeakai.github.io/jpai-mermaidjs-lint/guides/pre-commit/).
47
+ - [Configure the rules](https://jpeakai.github.io/jpai-mermaidjs-lint/guides/configure/), or [write a local rule](https://jpeakai.github.io/jpai-mermaidjs-lint/guides/local-rule/).
69
48
 
70
49
  ## Use it from an AI agent
71
50
 
72
- [`skills/mermaidjs-lint/`](skills/mermaidjs-lint/SKILL.md) is an agent skill that runs the linter, applies the safe fixes, then fixes what remains by judgement.
73
- It is not in the npm package, so copy it from this repository.
74
- The skill is the one file `SKILL.md`.
75
- Its `evals/` folder holds deliberately failing fixtures for this repository's own tests, so never copy it.
76
- For Claude Code, run these in your project:
51
+ The agent skill in [`skills/mermaidjs-lint/SKILL.md`](skills/mermaidjs-lint/SKILL.md) runs the tool, applies the safe fixes, and fixes the rest by judgement.
52
+ Copy it into a Claude Code project:
77
53
 
78
54
  ```sh
79
- mkdir -p .claude/skills/mermaidjs-lint
80
- curl -fsSL https://raw.githubusercontent.com/jpeakai/jpai-mermaidjs-lint/main/skills/mermaidjs-lint/SKILL.md \
81
- -o .claude/skills/mermaidjs-lint/SKILL.md
82
- ```
83
-
84
- Use `~/.claude/skills/mermaidjs-lint` instead to use it in every project.
85
- Another agent can follow `SKILL.md` as plain instructions.
86
- [Agent evals](https://jpeakai.github.io/jpai-mermaidjs-lint/gallery/agent-evals/) shows how well agents fixed the failing fixtures it lists, and which skill revision they used.
87
-
88
- ## Library use
89
-
90
- ```ts
91
- import { checkMarkdown, fixSource } from "@jpeakai/mermaidjs-lint";
92
-
93
- const findings = await checkMarkdown(src, "README.md");
94
- const fixed = await fixSource(src, "README.md");
55
+ curl -fsSL --create-dirs -o .claude/skills/mermaidjs-lint/SKILL.md \
56
+ https://raw.githubusercontent.com/jpeakai/jpai-mermaidjs-lint/main/skills/mermaidjs-lint/SKILL.md
95
57
  ```
96
58
 
97
- These calls run the built-in rules only, and read no config file and no plugin.
98
- The [Library API](https://jpeakai.github.io/jpai-mermaidjs-lint/reference/library-api/) page lists every export, and shows how to run with the config and plugins as the command does.
99
-
100
- ## Versioning
101
-
102
- The package follows semantic versioning, and while it is at `0.x` a minor release may break the public surface.
103
- [Versioning](https://jpeakai.github.io/jpai-mermaidjs-lint/reference/#versioning) lists what that surface is.
104
- [CHANGELOG.md](CHANGELOG.md) lists what changed in each release, and what is on `main` but not yet released.
105
-
106
- ## Security
107
-
108
- A plugin is code that runs on your machine, and plugins load on their own once a project depends on them.
109
- [SECURITY.md](SECURITY.md) covers the supported versions, the trust model and how to report a vulnerability privately.
110
-
111
- ## Support
112
-
113
- Report a bug or ask a question in [GitHub issues](https://github.com/jpeakai/jpai-mermaidjs-lint/issues).
114
- A bug report needs:
115
-
116
- - The version, from `mermaidjs-lint --version`, or from `npm ls @jpeakai/mermaidjs-lint` on 0.1.1 and earlier.
117
- - The command you ran.
118
- - The diagram, or the smallest file that shows the problem.
119
- - The output of the same command with `--json`.
120
-
121
- ## Origin
122
-
123
- The checks port an earlier set of diagram-checking scripts, which measured complexity and contrast for Mermaid diagrams.
124
- The package shape, the plugin mechanism and the fix verification follow [jpai-prose-gates](https://github.com/jpeakai/jpai-prose-gates).
125
- The reasoning behind each choice is recorded in [`adrs/`](adrs/index.md).
126
-
127
- ## Contributing
59
+ ## Runs on Bun or Node
128
60
 
129
- [CONTRIBUTING.md](CONTRIBUTING.md) covers setup and how a change is accepted.
61
+ It needs Bun, or Node `>=20`.
62
+ The package installs mermaid `11.17.2` to parse diagrams, and your host may draw with another version.
130
63
 
131
- ## Licence
64
+ ## Contribute
132
65
 
133
- MIT, as stated in [LICENSE](LICENSE).
66
+ [CONTRIBUTING.md](CONTRIBUTING.md) covers setup.
67
+ Licensed under [MIT](LICENSE).
package/dist/bin.js CHANGED
@@ -9,7 +9,7 @@ import { parseArgs } from "node:util";
9
9
  // package.json
10
10
  var package_default = {
11
11
  name: "@jpeakai/mermaidjs-lint",
12
- version: "0.6.3",
12
+ version: "0.7.1",
13
13
  type: "module",
14
14
  description: "Deterministic lint gates and verified fixers for Mermaid diagrams in markdown",
15
15
  license: "MIT",
@@ -1936,6 +1936,22 @@ var BUILTIN_CATEGORIES = new Map([
1936
1936
  ["contrast", "Whether labels and borders can be read against their fills"]
1937
1937
  ]);
1938
1938
  var HELPERS = { detectKeyword, wcagRatio, compositeOver };
1939
+ var selectionNames = (prefix) => prefix ? { enable: `${prefix}category`, disable: `${prefix}disable-category` } : { enable: "selection.enable", disable: "selection.disable" };
1940
+ var checkSelection = (selection, categories) => {
1941
+ const names = selectionNames(selection.prefix ?? "");
1942
+ const known = [...categories.keys()];
1943
+ for (const side of ["enable", "disable"]) {
1944
+ for (const word of selection[side] ?? []) {
1945
+ if (!categories.has(word)) {
1946
+ throw new FlagError(names[side], `${names[side]} names no loaded category "${word}"; the categories are ${known.join(", ")}`);
1947
+ }
1948
+ }
1949
+ }
1950
+ const both = (selection.enable ?? []).filter((word) => selection.disable?.includes(word));
1951
+ if (both.length > 0) {
1952
+ throw new FlagError(names.disable, `${names.enable} and ${names.disable} both name ${[...new Set(both)].join(", ")}; name each category once`);
1953
+ }
1954
+ };
1939
1955
  var typeOf = (value) => {
1940
1956
  if (typeof value === "number" && Number.isFinite(value))
1941
1957
  return "number";
@@ -1992,6 +2008,8 @@ var checkOptions = (where, rule, options) => {
1992
2008
  };
1993
2009
  var buildRegistry = (input) => {
1994
2010
  const { rules, fixOrder, categories, sources = [], config, ignoreUnknownPluginIds = false } = input;
2011
+ const selection = input.selection ?? {};
2012
+ checkSelection(selection, categories);
1995
2013
  const off = new Set;
1996
2014
  const options = new Map;
1997
2015
  const ignored = [];
@@ -2006,12 +2024,21 @@ var buildRegistry = (input) => {
2006
2024
  throw new UnknownRuleError(where, id, rules.map((r) => r.id));
2007
2025
  }
2008
2026
  checkOptions(where, rule, setting.options);
2027
+ options.set(rule.id, setting.options);
2009
2028
  if (setting.severity === "off")
2010
2029
  off.add(id);
2011
- else
2012
- options.set(rule.id, setting.options);
2030
+ }
2031
+ for (const rule of rules) {
2032
+ if (selection.enable?.includes(rule.category))
2033
+ off.delete(rule.id);
2034
+ if (selection.disable?.includes(rule.category))
2035
+ off.add(rule.id);
2013
2036
  }
2014
2037
  const active = rules.filter((r) => !off.has(r.id));
2038
+ if (active.length === 0 && (selection.disable?.length ?? 0) > 0) {
2039
+ const name = selectionNames(selection.prefix ?? "").disable;
2040
+ throw new FlagError(name, `${name} leaves no rule to run; disable fewer categories`);
2041
+ }
2015
2042
  return {
2016
2043
  rules: active,
2017
2044
  fixOrder: fixOrder.filter((r) => !off.has(r.id)),
@@ -2792,7 +2819,8 @@ var setUp = async ({
2792
2819
  configPath,
2793
2820
  noPlugins = false,
2794
2821
  lenient = false,
2795
- collect = false
2822
+ collect = false,
2823
+ selection
2796
2824
  }) => {
2797
2825
  const root = await findProjectRoot(cwd);
2798
2826
  let file;
@@ -2820,7 +2848,8 @@ var setUp = async ({
2820
2848
  categories: plugins.categories,
2821
2849
  sources: plugins.sources,
2822
2850
  config,
2823
- ignoreUnknownPluginIds: mode !== "strict"
2851
+ ignoreUnknownPluginIds: mode !== "strict",
2852
+ ...selection && { selection }
2824
2853
  });
2825
2854
  return { root, registry, skipped: plugins.skipped, failures: plugins.failures };
2826
2855
  };
@@ -2828,7 +2857,8 @@ var setUp = async ({
2828
2857
  // src/cli.ts
2829
2858
  var SYNOPSIS = `usage: mermaidjs-lint <path...> [--fix] [--json] [--preset NAME] [--profile NAME] [--config FILE]
2830
2859
  [--canvas-light COLOUR] [--canvas-dark COLOUR] [--no-plugins | --lenient-plugins]
2831
- mermaidjs-lint --list-rules [--config FILE] [--no-plugins | --lenient-plugins]
2860
+ [--category NAME]... [--disable-category NAME]...
2861
+ mermaidjs-lint --list-rules [--config FILE] [--no-plugins | --lenient-plugins] [category flags]
2832
2862
  mermaidjs-lint --validate-plugins [--json] [--config FILE]
2833
2863
  mermaidjs-lint --version | -h, --help`;
2834
2864
  var ruleRow = (r, width) => `${`${r.id}${r.fix ? "*" : ""}`.padEnd(width)} ${r.summary}`;
@@ -2858,6 +2888,10 @@ Examples of every rule, with fixes: https://jpeakai.github.io/jpai-mermaidjs-lin
2858
2888
  on it. Each is an opaque CSS colour such as #faf8f9, and a side not given keeps the config's, or GitHub's
2859
2889
  (#ffffff light, #0d1117 dark). A file scored under mkdocs-material refuses a canvas flag with a usage
2860
2890
  error, and sets a config canvas aside when auto picked the profile.
2891
+ --disable-category NAME turns off every rule in that category for this run, after the config is applied.
2892
+ --category NAME turns on every rule in that category for this run, even one the config turns off.
2893
+ Each may be repeated or take a comma-separated list, such as --disable-category parse,complexity.
2894
+ A category that is not loaded, one named by both flags, or a run left with no rule is a usage error.
2861
2895
  --json prints one JSON object instead of text lines.
2862
2896
  --fix applies every fix it can prove safe, then reports what remains.
2863
2897
  A fixer that is unsure leaves the text alone and the finding stays.
@@ -2869,7 +2903,7 @@ Plugins load on their own from .mermaidjs-lint/rules, from dependencies named me
2869
2903
  --lenient-plugins skips a plugin that fails to load and says so, instead of stopping the run.
2870
2904
  --validate-plugins imports and checks every plugin, reports every failure, and runs no check or fix.
2871
2905
  --list-rules prints every active rule by category, including plugin rules, and where each plugin came from.
2872
- It imports the plugins to list them, and takes no path or other flag.
2906
+ It imports the plugins to list them. It takes the category flags, to preview a run, and no path or other flag.
2873
2907
  --version prints the version, to quote in a bug report.
2874
2908
  Exits 0 when no findings remain, 1 when findings remain, a plugin fails to load or the run fails (a lenient run
2875
2909
  skips a broken plugin, so it alone does not exit 1), and 2 on a usage error.`;
@@ -2888,11 +2922,19 @@ var parse2 = (argv) => parseArgs({
2888
2922
  "lenient-plugins": { type: "boolean", default: false },
2889
2923
  "validate-plugins": { type: "boolean", default: false },
2890
2924
  "list-rules": { type: "boolean", default: false },
2891
- version: { type: "boolean", default: false }
2925
+ version: { type: "boolean", default: false },
2926
+ category: { type: "string", multiple: true },
2927
+ "disable-category": { type: "string", multiple: true }
2892
2928
  },
2893
2929
  allowPositionals: true,
2894
2930
  strict: true
2895
2931
  });
2932
+ var categoryWords = (flag, values) => (values ?? []).flatMap((value) => value.split(",").map((word) => {
2933
+ const trimmed = word.trim();
2934
+ if (!trimmed)
2935
+ throw new FlagError(flag, `${flag} needs a category name, not "${value}"`);
2936
+ return trimmed;
2937
+ }));
2896
2938
  var plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
2897
2939
  var namesOf = (sources) => sources.map((s) => `${s.namespace} (${plural(s.rules.length, "rule")})`).join(", ");
2898
2940
  var loadedLine = (sources, skipped) => {
@@ -3004,6 +3046,11 @@ var run = async (argv, cwd) => {
3004
3046
  if (validating && (values["no-plugins"] || values.fix || listing)) {
3005
3047
  throw new FlagError("--validate-plugins", "--validate-plugins takes no --no-plugins, --fix or --list-rules");
3006
3048
  }
3049
+ const enable = categoryWords("--category", values.category);
3050
+ const disable = categoryWords("--disable-category", values["disable-category"]);
3051
+ if (validating && enable.length + disable.length > 0) {
3052
+ throw new FlagError("--validate-plugins", "--validate-plugins checks every plugin, so it takes no category flag");
3053
+ }
3007
3054
  if (listing && !validating) {
3008
3055
  const ignored = [
3009
3056
  ...["fix", "json", "preset", "profile", "canvas-light", "canvas-dark"].filter((name) => values[name] !== undefined && values[name] !== false).map((name) => `--${name}`),
@@ -3023,7 +3070,8 @@ var run = async (argv, cwd) => {
3023
3070
  configPath: values.config,
3024
3071
  noPlugins: values["no-plugins"],
3025
3072
  lenient: values["lenient-plugins"],
3026
- collect: validating
3073
+ collect: validating,
3074
+ selection: { enable, disable, prefix: "--" }
3027
3075
  });
3028
3076
  if (validating) {
3029
3077
  const result = validate(registry, failures, Boolean(values.json));
package/dist/index.js CHANGED
@@ -1858,6 +1858,22 @@ var BUILTIN_CATEGORIES = new Map([
1858
1858
  ["contrast", "Whether labels and borders can be read against their fills"]
1859
1859
  ]);
1860
1860
  var HELPERS = { detectKeyword, wcagRatio, compositeOver };
1861
+ var selectionNames = (prefix) => prefix ? { enable: `${prefix}category`, disable: `${prefix}disable-category` } : { enable: "selection.enable", disable: "selection.disable" };
1862
+ var checkSelection = (selection, categories) => {
1863
+ const names = selectionNames(selection.prefix ?? "");
1864
+ const known = [...categories.keys()];
1865
+ for (const side of ["enable", "disable"]) {
1866
+ for (const word of selection[side] ?? []) {
1867
+ if (!categories.has(word)) {
1868
+ throw new FlagError(names[side], `${names[side]} names no loaded category "${word}"; the categories are ${known.join(", ")}`);
1869
+ }
1870
+ }
1871
+ }
1872
+ const both = (selection.enable ?? []).filter((word) => selection.disable?.includes(word));
1873
+ if (both.length > 0) {
1874
+ throw new FlagError(names.disable, `${names.enable} and ${names.disable} both name ${[...new Set(both)].join(", ")}; name each category once`);
1875
+ }
1876
+ };
1861
1877
  var typeOf = (value) => {
1862
1878
  if (typeof value === "number" && Number.isFinite(value))
1863
1879
  return "number";
@@ -1914,6 +1930,8 @@ var checkOptions = (where, rule, options) => {
1914
1930
  };
1915
1931
  var buildRegistry = (input) => {
1916
1932
  const { rules, fixOrder, categories, sources = [], config, ignoreUnknownPluginIds = false } = input;
1933
+ const selection = input.selection ?? {};
1934
+ checkSelection(selection, categories);
1917
1935
  const off = new Set;
1918
1936
  const options = new Map;
1919
1937
  const ignored = [];
@@ -1928,12 +1946,21 @@ var buildRegistry = (input) => {
1928
1946
  throw new UnknownRuleError(where, id, rules.map((r) => r.id));
1929
1947
  }
1930
1948
  checkOptions(where, rule, setting.options);
1949
+ options.set(rule.id, setting.options);
1931
1950
  if (setting.severity === "off")
1932
1951
  off.add(id);
1933
- else
1934
- options.set(rule.id, setting.options);
1952
+ }
1953
+ for (const rule of rules) {
1954
+ if (selection.enable?.includes(rule.category))
1955
+ off.delete(rule.id);
1956
+ if (selection.disable?.includes(rule.category))
1957
+ off.add(rule.id);
1935
1958
  }
1936
1959
  const active = rules.filter((r) => !off.has(r.id));
1960
+ if (active.length === 0 && (selection.disable?.length ?? 0) > 0) {
1961
+ const name = selectionNames(selection.prefix ?? "").disable;
1962
+ throw new FlagError(name, `${name} leaves no rule to run; disable fewer categories`);
1963
+ }
1937
1964
  return {
1938
1965
  rules: active,
1939
1966
  fixOrder: fixOrder.filter((r) => !off.has(r.id)),
@@ -2023,7 +2050,7 @@ import { parseArgs } from "node:util";
2023
2050
  // package.json
2024
2051
  var package_default = {
2025
2052
  name: "@jpeakai/mermaidjs-lint",
2026
- version: "0.6.3",
2053
+ version: "0.7.1",
2027
2054
  type: "module",
2028
2055
  description: "Deterministic lint gates and verified fixers for Mermaid diagrams in markdown",
2029
2056
  license: "MIT",
@@ -2792,7 +2819,8 @@ var setUp = async ({
2792
2819
  configPath,
2793
2820
  noPlugins = false,
2794
2821
  lenient = false,
2795
- collect = false
2822
+ collect = false,
2823
+ selection
2796
2824
  }) => {
2797
2825
  const root = await findProjectRoot(cwd);
2798
2826
  let file;
@@ -2820,7 +2848,8 @@ var setUp = async ({
2820
2848
  categories: plugins.categories,
2821
2849
  sources: plugins.sources,
2822
2850
  config,
2823
- ignoreUnknownPluginIds: mode !== "strict"
2851
+ ignoreUnknownPluginIds: mode !== "strict",
2852
+ ...selection && { selection }
2824
2853
  });
2825
2854
  return { root, registry, skipped: plugins.skipped, failures: plugins.failures };
2826
2855
  };
@@ -2828,7 +2857,8 @@ var setUp = async ({
2828
2857
  // src/cli.ts
2829
2858
  var SYNOPSIS = `usage: mermaidjs-lint <path...> [--fix] [--json] [--preset NAME] [--profile NAME] [--config FILE]
2830
2859
  [--canvas-light COLOUR] [--canvas-dark COLOUR] [--no-plugins | --lenient-plugins]
2831
- mermaidjs-lint --list-rules [--config FILE] [--no-plugins | --lenient-plugins]
2860
+ [--category NAME]... [--disable-category NAME]...
2861
+ mermaidjs-lint --list-rules [--config FILE] [--no-plugins | --lenient-plugins] [category flags]
2832
2862
  mermaidjs-lint --validate-plugins [--json] [--config FILE]
2833
2863
  mermaidjs-lint --version | -h, --help`;
2834
2864
  var ruleRow = (r, width) => `${`${r.id}${r.fix ? "*" : ""}`.padEnd(width)} ${r.summary}`;
@@ -2858,6 +2888,10 @@ Examples of every rule, with fixes: https://jpeakai.github.io/jpai-mermaidjs-lin
2858
2888
  on it. Each is an opaque CSS colour such as #faf8f9, and a side not given keeps the config's, or GitHub's
2859
2889
  (#ffffff light, #0d1117 dark). A file scored under mkdocs-material refuses a canvas flag with a usage
2860
2890
  error, and sets a config canvas aside when auto picked the profile.
2891
+ --disable-category NAME turns off every rule in that category for this run, after the config is applied.
2892
+ --category NAME turns on every rule in that category for this run, even one the config turns off.
2893
+ Each may be repeated or take a comma-separated list, such as --disable-category parse,complexity.
2894
+ A category that is not loaded, one named by both flags, or a run left with no rule is a usage error.
2861
2895
  --json prints one JSON object instead of text lines.
2862
2896
  --fix applies every fix it can prove safe, then reports what remains.
2863
2897
  A fixer that is unsure leaves the text alone and the finding stays.
@@ -2869,7 +2903,7 @@ Plugins load on their own from .mermaidjs-lint/rules, from dependencies named me
2869
2903
  --lenient-plugins skips a plugin that fails to load and says so, instead of stopping the run.
2870
2904
  --validate-plugins imports and checks every plugin, reports every failure, and runs no check or fix.
2871
2905
  --list-rules prints every active rule by category, including plugin rules, and where each plugin came from.
2872
- It imports the plugins to list them, and takes no path or other flag.
2906
+ It imports the plugins to list them. It takes the category flags, to preview a run, and no path or other flag.
2873
2907
  --version prints the version, to quote in a bug report.
2874
2908
  Exits 0 when no findings remain, 1 when findings remain, a plugin fails to load or the run fails (a lenient run
2875
2909
  skips a broken plugin, so it alone does not exit 1), and 2 on a usage error.`;
@@ -2888,11 +2922,19 @@ var parse2 = (argv) => parseArgs({
2888
2922
  "lenient-plugins": { type: "boolean", default: false },
2889
2923
  "validate-plugins": { type: "boolean", default: false },
2890
2924
  "list-rules": { type: "boolean", default: false },
2891
- version: { type: "boolean", default: false }
2925
+ version: { type: "boolean", default: false },
2926
+ category: { type: "string", multiple: true },
2927
+ "disable-category": { type: "string", multiple: true }
2892
2928
  },
2893
2929
  allowPositionals: true,
2894
2930
  strict: true
2895
2931
  });
2932
+ var categoryWords = (flag, values) => (values ?? []).flatMap((value) => value.split(",").map((word) => {
2933
+ const trimmed = word.trim();
2934
+ if (!trimmed)
2935
+ throw new FlagError(flag, `${flag} needs a category name, not "${value}"`);
2936
+ return trimmed;
2937
+ }));
2896
2938
  var plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
2897
2939
  var namesOf = (sources) => sources.map((s) => `${s.namespace} (${plural(s.rules.length, "rule")})`).join(", ");
2898
2940
  var loadedLine = (sources, skipped) => {
@@ -3004,6 +3046,11 @@ var run = async (argv, cwd) => {
3004
3046
  if (validating && (values["no-plugins"] || values.fix || listing)) {
3005
3047
  throw new FlagError("--validate-plugins", "--validate-plugins takes no --no-plugins, --fix or --list-rules");
3006
3048
  }
3049
+ const enable = categoryWords("--category", values.category);
3050
+ const disable = categoryWords("--disable-category", values["disable-category"]);
3051
+ if (validating && enable.length + disable.length > 0) {
3052
+ throw new FlagError("--validate-plugins", "--validate-plugins checks every plugin, so it takes no category flag");
3053
+ }
3007
3054
  if (listing && !validating) {
3008
3055
  const ignored = [
3009
3056
  ...["fix", "json", "preset", "profile", "canvas-light", "canvas-dark"].filter((name) => values[name] !== undefined && values[name] !== false).map((name) => `--${name}`),
@@ -3023,7 +3070,8 @@ var run = async (argv, cwd) => {
3023
3070
  configPath: values.config,
3024
3071
  noPlugins: values["no-plugins"],
3025
3072
  lenient: values["lenient-plugins"],
3026
- collect: validating
3073
+ collect: validating,
3074
+ selection: { enable, disable, prefix: "--" }
3027
3075
  });
3028
3076
  if (validating) {
3029
3077
  const result = validate(registry, failures, Boolean(values.json));
package/dist/testing.js CHANGED
@@ -1755,6 +1755,22 @@ var BUILTIN_CATEGORIES = new Map([
1755
1755
  ["contrast", "Whether labels and borders can be read against their fills"]
1756
1756
  ]);
1757
1757
  var HELPERS = { detectKeyword, wcagRatio, compositeOver };
1758
+ var selectionNames = (prefix) => prefix ? { enable: `${prefix}category`, disable: `${prefix}disable-category` } : { enable: "selection.enable", disable: "selection.disable" };
1759
+ var checkSelection = (selection, categories) => {
1760
+ const names = selectionNames(selection.prefix ?? "");
1761
+ const known = [...categories.keys()];
1762
+ for (const side of ["enable", "disable"]) {
1763
+ for (const word of selection[side] ?? []) {
1764
+ if (!categories.has(word)) {
1765
+ throw new FlagError(names[side], `${names[side]} names no loaded category "${word}"; the categories are ${known.join(", ")}`);
1766
+ }
1767
+ }
1768
+ }
1769
+ const both = (selection.enable ?? []).filter((word) => selection.disable?.includes(word));
1770
+ if (both.length > 0) {
1771
+ throw new FlagError(names.disable, `${names.enable} and ${names.disable} both name ${[...new Set(both)].join(", ")}; name each category once`);
1772
+ }
1773
+ };
1758
1774
  var typeOf = (value) => {
1759
1775
  if (typeof value === "number" && Number.isFinite(value))
1760
1776
  return "number";
@@ -1811,6 +1827,8 @@ var checkOptions = (where, rule, options) => {
1811
1827
  };
1812
1828
  var buildRegistry = (input) => {
1813
1829
  const { rules, fixOrder, categories, sources = [], config, ignoreUnknownPluginIds = false } = input;
1830
+ const selection = input.selection ?? {};
1831
+ checkSelection(selection, categories);
1814
1832
  const off = new Set;
1815
1833
  const options = new Map;
1816
1834
  const ignored = [];
@@ -1825,12 +1843,21 @@ var buildRegistry = (input) => {
1825
1843
  throw new UnknownRuleError(where, id, rules.map((r) => r.id));
1826
1844
  }
1827
1845
  checkOptions(where, rule, setting.options);
1846
+ options.set(rule.id, setting.options);
1828
1847
  if (setting.severity === "off")
1829
1848
  off.add(id);
1830
- else
1831
- options.set(rule.id, setting.options);
1849
+ }
1850
+ for (const rule of rules) {
1851
+ if (selection.enable?.includes(rule.category))
1852
+ off.delete(rule.id);
1853
+ if (selection.disable?.includes(rule.category))
1854
+ off.add(rule.id);
1832
1855
  }
1833
1856
  const active = rules.filter((r) => !off.has(r.id));
1857
+ if (active.length === 0 && (selection.disable?.length ?? 0) > 0) {
1858
+ const name = selectionNames(selection.prefix ?? "").disable;
1859
+ throw new FlagError(name, `${name} leaves no rule to run; disable fewer categories`);
1860
+ }
1834
1861
  return {
1835
1862
  rules: active,
1836
1863
  fixOrder: fixOrder.filter((r) => !off.has(r.id)),
@@ -14,6 +14,6 @@ export type { SourceKind } from "./plugins/discover.ts";
14
14
  export type { PluginFailure } from "./plugins/index.ts";
15
15
  export { findProjectRoot, type Setup, type SetupOptions, setUp } from "./project.ts";
16
16
  export { FIX_ORDER, RULES } from "./rules/index.ts";
17
- export { BUILTIN, BUILTIN_CATEGORIES, buildRegistry, type LoadedSource, type Registry, type RegistryInput, } from "./rules/registry.ts";
17
+ export { BUILTIN, BUILTIN_CATEGORIES, buildRegistry, type CategorySelection, type LoadedSource, type Registry, type RegistryInput, } from "./rules/registry.ts";
18
18
  export type { Category, CheckContext, CoreRuleId, Edit, Expectation, Finding, FixContext, Helpers, OptionSpec, OptionType, PluginRuleId, Rule, RuleContext, RuleId, RuleOptions, } from "./rules/types.ts";
19
19
  export { CATEGORIES, RULE } from "./rules/types.ts";
@@ -1,5 +1,5 @@
1
1
  import { type PluginFailure } from "./plugins/index.ts";
2
- import { type Registry } from "./rules/registry.ts";
2
+ import { type CategorySelection, type Registry } from "./rules/registry.ts";
3
3
  export declare const LOCAL_DIR = ".mermaidjs-lint";
4
4
  export declare const findProjectRoot: (cwd: string) => Promise<string>;
5
5
  export interface SetupOptions {
@@ -8,6 +8,7 @@ export interface SetupOptions {
8
8
  noPlugins?: boolean;
9
9
  lenient?: boolean;
10
10
  collect?: boolean;
11
+ selection?: CategorySelection;
11
12
  }
12
13
  export interface Setup {
13
14
  root: string;
@@ -15,4 +16,4 @@ export interface Setup {
15
16
  skipped: string[];
16
17
  failures: PluginFailure[];
17
18
  }
18
- export declare const setUp: ({ cwd, configPath, noPlugins, lenient, collect, }: SetupOptions) => Promise<Setup>;
19
+ export declare const setUp: ({ cwd, configPath, noPlugins, lenient, collect, selection, }: SetupOptions) => Promise<Setup>;
@@ -24,6 +24,12 @@ export interface RegistryInput {
24
24
  sources?: LoadedSource[];
25
25
  config?: Config;
26
26
  ignoreUnknownPluginIds?: boolean;
27
+ selection?: CategorySelection;
28
+ }
29
+ export interface CategorySelection {
30
+ enable?: readonly string[];
31
+ disable?: readonly string[];
32
+ prefix?: string;
27
33
  }
28
34
  export declare const buildRegistry: (input: RegistryInput) => Registry;
29
35
  export declare const BUILTIN: Registry;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jpeakai/mermaidjs-lint",
3
- "version": "0.6.3",
3
+ "version": "0.7.1",
4
4
  "type": "module",
5
5
  "description": "Deterministic lint gates and verified fixers for Mermaid diagrams in markdown",
6
6
  "license": "MIT",
package/src/cli.ts CHANGED
@@ -22,7 +22,8 @@ import { CATEGORIES, type Finding, type Rule } from "./rules/types.ts";
22
22
  // buried, and first in --help.
23
23
  const SYNOPSIS = `usage: mermaidjs-lint <path...> [--fix] [--json] [--preset NAME] [--profile NAME] [--config FILE]
24
24
  [--canvas-light COLOUR] [--canvas-dark COLOUR] [--no-plugins | --lenient-plugins]
25
- mermaidjs-lint --list-rules [--config FILE] [--no-plugins | --lenient-plugins]
25
+ [--category NAME]... [--disable-category NAME]...
26
+ mermaidjs-lint --list-rules [--config FILE] [--no-plugins | --lenient-plugins] [category flags]
26
27
  mermaidjs-lint --validate-plugins [--json] [--config FILE]
27
28
  mermaidjs-lint --version | -h, --help`;
28
29
 
@@ -57,6 +58,10 @@ Examples of every rule, with fixes: https://jpeakai.github.io/jpai-mermaidjs-lin
57
58
  on it. Each is an opaque CSS colour such as #faf8f9, and a side not given keeps the config's, or GitHub's
58
59
  (#ffffff light, #0d1117 dark). A file scored under mkdocs-material refuses a canvas flag with a usage
59
60
  error, and sets a config canvas aside when auto picked the profile.
61
+ --disable-category NAME turns off every rule in that category for this run, after the config is applied.
62
+ --category NAME turns on every rule in that category for this run, even one the config turns off.
63
+ Each may be repeated or take a comma-separated list, such as --disable-category parse,complexity.
64
+ A category that is not loaded, one named by both flags, or a run left with no rule is a usage error.
60
65
  --json prints one JSON object instead of text lines.
61
66
  --fix applies every fix it can prove safe, then reports what remains.
62
67
  A fixer that is unsure leaves the text alone and the finding stays.
@@ -68,7 +73,7 @@ Plugins load on their own from .mermaidjs-lint/rules, from dependencies named me
68
73
  --lenient-plugins skips a plugin that fails to load and says so, instead of stopping the run.
69
74
  --validate-plugins imports and checks every plugin, reports every failure, and runs no check or fix.
70
75
  --list-rules prints every active rule by category, including plugin rules, and where each plugin came from.
71
- It imports the plugins to list them, and takes no path or other flag.
76
+ It imports the plugins to list them. It takes the category flags, to preview a run, and no path or other flag.
72
77
  --version prints the version, to quote in a bug report.
73
78
  Exits 0 when no findings remain, 1 when findings remain, a plugin fails to load or the run fails (a lenient run
74
79
  skips a broken plugin, so it alone does not exit 1), and 2 on a usage error.`;
@@ -88,6 +93,8 @@ interface Parsed {
88
93
  "validate-plugins"?: boolean;
89
94
  "list-rules"?: boolean;
90
95
  version?: boolean;
96
+ category?: string[];
97
+ "disable-category"?: string[];
91
98
  };
92
99
  positionals: string[];
93
100
  }
@@ -109,11 +116,25 @@ const parse = (argv: string[]): Parsed =>
109
116
  "validate-plugins": { type: "boolean", default: false },
110
117
  "list-rules": { type: "boolean", default: false },
111
118
  version: { type: "boolean", default: false },
119
+ category: { type: "string", multiple: true },
120
+ "disable-category": { type: "string", multiple: true },
112
121
  },
113
122
  allowPositionals: true,
114
123
  strict: true,
115
124
  });
116
125
 
126
+ // A category flag may be repeated or take a comma-separated list. A category
127
+ // word never holds a comma, so the split cannot change a name. An empty item,
128
+ // as in "parse,", is refused rather than dropped.
129
+ const categoryWords = (flag: string, values: string[] | undefined): string[] =>
130
+ (values ?? []).flatMap((value) =>
131
+ value.split(",").map((word) => {
132
+ const trimmed = word.trim();
133
+ if (!trimmed) throw new FlagError(flag, `${flag} needs a category name, not "${value}"`);
134
+ return trimmed;
135
+ }),
136
+ );
137
+
117
138
  const plural = (n: number, word: string): string => `${n} ${word}${n === 1 ? "" : "s"}`;
118
139
 
119
140
  const namesOf = (sources: LoadedSource[]): string =>
@@ -246,6 +267,13 @@ const run = async (argv: string[], cwd: string): Promise<number> => {
246
267
  if (validating && (values["no-plugins"] || values.fix || listing)) {
247
268
  throw new FlagError("--validate-plugins", "--validate-plugins takes no --no-plugins, --fix or --list-rules");
248
269
  }
270
+ const enable = categoryWords("--category", values.category);
271
+ const disable = categoryWords("--disable-category", values["disable-category"]);
272
+ // Validation loads every plugin whatever the category flags say, so it
273
+ // refuses them rather than ignore them.
274
+ if (validating && enable.length + disable.length > 0) {
275
+ throw new FlagError("--validate-plugins", "--validate-plugins checks every plugin, so it takes no category flag");
276
+ }
249
277
  // --list-rules prints the rules the config and the plugin flags leave
250
278
  // active. Any other flag or a path would be silently ignored, so it is refused.
251
279
  if (listing && !validating) {
@@ -273,6 +301,7 @@ const run = async (argv: string[], cwd: string): Promise<number> => {
273
301
  noPlugins: values["no-plugins"],
274
302
  lenient: values["lenient-plugins"],
275
303
  collect: validating,
304
+ selection: { enable, disable, prefix: "--" },
276
305
  });
277
306
 
278
307
  if (validating) {
package/src/index.ts CHANGED
@@ -20,6 +20,7 @@ export {
20
20
  BUILTIN,
21
21
  BUILTIN_CATEGORIES,
22
22
  buildRegistry,
23
+ type CategorySelection,
23
24
  type LoadedSource,
24
25
  type Registry,
25
26
  type RegistryInput,
package/src/project.ts CHANGED
@@ -9,7 +9,7 @@ import { CONFIG_FILES, findConfig, loadConfig, NO_CONFIG } from "./config.ts";
9
9
  import { ConfigCodeError, ConfigNotFoundError } from "./errors.ts";
10
10
  import { loadPlugins, type PluginFailure, type PluginMode } from "./plugins/index.ts";
11
11
  import { FIX_ORDER, RULES } from "./rules/index.ts";
12
- import { buildRegistry, type Registry } from "./rules/registry.ts";
12
+ import { buildRegistry, type CategorySelection, type Registry } from "./rules/registry.ts";
13
13
 
14
14
  export const LOCAL_DIR = ".mermaidjs-lint";
15
15
 
@@ -40,6 +40,8 @@ export interface SetupOptions {
40
40
  lenient?: boolean;
41
41
  // Load every plugin and report every failure, without stopping at the first.
42
42
  collect?: boolean;
43
+ // Categories switched on or off for this run, over the config.
44
+ selection?: CategorySelection;
43
45
  }
44
46
 
45
47
  export interface Setup {
@@ -57,6 +59,7 @@ export const setUp = async ({
57
59
  noPlugins = false,
58
60
  lenient = false,
59
61
  collect = false,
62
+ selection,
60
63
  }: SetupOptions): Promise<Setup> => {
61
64
  const root = await findProjectRoot(cwd);
62
65
  let file: string | null;
@@ -85,6 +88,7 @@ export const setUp = async ({
85
88
  // by choice (off) or by failure (lenient, collect), so it is set aside
86
89
  // rather than failing the run. A built-in id never is.
87
90
  ignoreUnknownPluginIds: mode !== "strict",
91
+ ...(selection && { selection }),
88
92
  });
89
93
  return { root, registry, skipped: plugins.skipped, failures: plugins.failures };
90
94
  };
@@ -1,11 +1,11 @@
1
1
  // The registry: the rules a run actually uses. It starts from the built-in
2
- // catalogue, adds whatever plugins loaded, then applies the config, so a rule
3
- // that is off loses its check and its fixer together. Every check and fix reads
2
+ // catalogue, adds whatever plugins loaded, applies the config, then the run's
3
+ // category choice, so a rule that is off loses its check and its fixer together. Every check and fix reads
4
4
  // the registry, which is what lets a plugin rule behave as a built-in. The CLI
5
5
  // help reads the built-in catalogue directly.
6
6
 
7
7
  import type { Config } from "../config.ts";
8
- import { RuleOptionError, UnknownRuleError } from "../errors.ts";
8
+ import { FlagError, RuleOptionError, UnknownRuleError } from "../errors.ts";
9
9
  import { compositeOver, wcagRatio } from "../parse/color.ts";
10
10
  import { canvasColourProblem, PROFILES, THEMES, type Theme } from "../parse/contrast.ts";
11
11
  import { canonicalPreset } from "../parse/metrics.ts";
@@ -49,8 +49,49 @@ export interface RegistryInput {
49
49
  // A lenient or collect (validating) run drops a setting for an unloaded
50
50
  // plugin rule instead of failing. A built-in id always fails.
51
51
  ignoreUnknownPluginIds?: boolean;
52
+ // Categories switched on or off for this run, over the config.
53
+ selection?: CategorySelection;
52
54
  }
53
55
 
56
+ // The run's category choice. `enable` turns a category's rules on even when
57
+ // the config turns them off, and `disable` turns them off. `prefix` is "--"
58
+ // when the command passes its flags, so a message names the flag the user typed.
59
+ export interface CategorySelection {
60
+ enable?: readonly string[];
61
+ disable?: readonly string[];
62
+ prefix?: string;
63
+ }
64
+
65
+ const selectionNames = (prefix: string): { enable: string; disable: string } =>
66
+ prefix
67
+ ? { enable: `${prefix}category`, disable: `${prefix}disable-category` }
68
+ : { enable: "selection.enable", disable: "selection.disable" };
69
+
70
+ // A category the run does not know, or one named both on and off, is a usage
71
+ // error, because either would leave the run checking something other than
72
+ // what was asked.
73
+ const checkSelection = (selection: CategorySelection, categories: ReadonlyMap<string, string>): void => {
74
+ const names = selectionNames(selection.prefix ?? "");
75
+ const known = [...categories.keys()];
76
+ for (const side of ["enable", "disable"] as const) {
77
+ for (const word of selection[side] ?? []) {
78
+ if (!categories.has(word)) {
79
+ throw new FlagError(
80
+ names[side],
81
+ `${names[side]} names no loaded category "${word}"; the categories are ${known.join(", ")}`,
82
+ );
83
+ }
84
+ }
85
+ }
86
+ const both = (selection.enable ?? []).filter((word) => selection.disable?.includes(word));
87
+ if (both.length > 0) {
88
+ throw new FlagError(
89
+ names.disable,
90
+ `${names.enable} and ${names.disable} both name ${[...new Set(both)].join(", ")}; name each category once`,
91
+ );
92
+ }
93
+ };
94
+
54
95
  const typeOf = (value: unknown): OptionType | null => {
55
96
  if (typeof value === "number" && Number.isFinite(value)) return "number";
56
97
  if (typeof value === "string") return "string";
@@ -123,6 +164,8 @@ const checkOptions = (where: string, rule: Rule, options: RuleOptions): void =>
123
164
 
124
165
  export const buildRegistry = (input: RegistryInput): Registry => {
125
166
  const { rules, fixOrder, categories, sources = [], config, ignoreUnknownPluginIds = false } = input;
167
+ const selection = input.selection ?? {};
168
+ checkSelection(selection, categories);
126
169
  const off = new Set<string>();
127
170
  const options = new Map<RuleId, RuleOptions>();
128
171
  const ignored: string[] = [];
@@ -144,10 +187,23 @@ export const buildRegistry = (input: RegistryInput): Registry => {
144
187
  );
145
188
  }
146
189
  checkOptions(where, rule, setting.options);
190
+ // A rule that is off keeps its options, so a category flag that turns it
191
+ // back on runs it as the config describes it.
192
+ options.set(rule.id, setting.options);
147
193
  if (setting.severity === "off") off.add(id);
148
- else options.set(rule.id, setting.options);
194
+ }
195
+ // Every rule starts on, the config turns some off, and then the category
196
+ // flags have the last word for this run (MJL-0020).
197
+ for (const rule of rules) {
198
+ if (selection.enable?.includes(rule.category)) off.delete(rule.id);
199
+ if (selection.disable?.includes(rule.category)) off.add(rule.id);
149
200
  }
150
201
  const active = rules.filter((r) => !off.has(r.id));
202
+ // Category flags that leave nothing to run would pass every file unchecked.
203
+ if (active.length === 0 && (selection.disable?.length ?? 0) > 0) {
204
+ const name = selectionNames(selection.prefix ?? "").disable;
205
+ throw new FlagError(name, `${name} leaves no rule to run; disable fewer categories`);
206
+ }
151
207
  return {
152
208
  rules: active,
153
209
  fixOrder: fixOrder.filter((r) => !off.has(r.id)),