paperlint 13.6.0 → 14.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -35,12 +35,14 @@
35
35
  *
36
36
  * {
37
37
  * "study": "demo-gold", file-name stem
38
+ * "kind": "labels", approval | witness | labels: which card fields are required
38
39
  * "title": "Label these", 2-4 words; the page title
39
40
  * "intro": "One or two sentences.", optional
40
41
  * "seed": 7, fixes the order; change it and every id changes
41
42
  * "groups": {"python": "Python"},
42
43
  * "labeller_knows": ["python"], optional: items of other groups are not put on the page
43
- * "familiarity": false, optional: no "how well do you know" question (approval page)
44
+ * "familiarity": true, optional: a "how well do you know" question per group;
45
+ * left out, none is asked (an approval page)
44
46
  * "familiarity_question": "How well do you know {group}?", optional
45
47
  * "questions": [{"id": "label", "text": "Is this correct?", "short": "correct?",
46
48
  * "options": [{"value": "yes", "label": "Yes", "example": "one real case",
@@ -50,17 +52,20 @@
50
52
  * ask_why: optional; the answer is incomplete until a
51
53
  * reason is given. In any shown text, backticks render
52
54
  * as code.
53
- * "practice": [{"body": "…", "expected": {"label": "yes"}, "why": "one line"}, …], 3 or more
55
+ * "practice": [{"body": "…", "expected": {"label": "yes"}, "why": "one line"}, …], 3 or more;
56
+ * a practice card may carry rules, unit, readings, file
54
57
  * "practice_round": 1, optional; raise it after rewording, the practice reopens
55
58
  * "items": [{"source_id": "case-0412-b", "group": "python", "body": "…", "code": "…",
56
59
  * "context": "…",
57
60
  * "rules": [{"linter": "ruff", "id": "TID253"}],
58
- * "unit": {"id": "a.md:12", "text": "the whole line", "part": 1, "of": 2}}]
61
+ * "unit": {"id": "a.md:12", "text": "the whole line", "mark": "whole", "part": 1, "of": 2},
62
+ * "readings": {"a": "…", "b": "…"}, "file": "`src/a.ts`: B flags it"}]
59
63
  * rules: optional; each shown with the linter's own
60
64
  * description (`ruff rule`, ESLint meta.docs.description)
61
- * unit: optional; one requirement of a line that holds
62
- * several. Every part of the line is an item, in one group;
63
- * id (never shown) tells two places with the same words apart.
65
+ * unit: optional; the line an item is drawn from, the
66
+ * requirement marked in it; id (never shown) tells two
67
+ * places with the same words apart. kind approval requires
68
+ * unit and rules; kind witness rules, readings and file.
64
69
  * }
65
70
  *
66
71
  * TypeScript, compiled beside itself to `label-page.js` by `npm run build`: a consumer runs the skill
@@ -35,12 +35,14 @@
35
35
  *
36
36
  * {
37
37
  * "study": "demo-gold", file-name stem
38
+ * "kind": "labels", approval | witness | labels: which card fields are required
38
39
  * "title": "Label these", 2-4 words; the page title
39
40
  * "intro": "One or two sentences.", optional
40
41
  * "seed": 7, fixes the order; change it and every id changes
41
42
  * "groups": {"python": "Python"},
42
43
  * "labeller_knows": ["python"], optional: items of other groups are not put on the page
43
- * "familiarity": false, optional: no "how well do you know" question (approval page)
44
+ * "familiarity": true, optional: a "how well do you know" question per group;
45
+ * left out, none is asked (an approval page)
44
46
  * "familiarity_question": "How well do you know {group}?", optional
45
47
  * "questions": [{"id": "label", "text": "Is this correct?", "short": "correct?",
46
48
  * "options": [{"value": "yes", "label": "Yes", "example": "one real case",
@@ -50,17 +52,20 @@
50
52
  * ask_why: optional; the answer is incomplete until a
51
53
  * reason is given. In any shown text, backticks render
52
54
  * as code.
53
- * "practice": [{"body": "…", "expected": {"label": "yes"}, "why": "one line"}, …], 3 or more
55
+ * "practice": [{"body": "…", "expected": {"label": "yes"}, "why": "one line"}, …], 3 or more;
56
+ * a practice card may carry rules, unit, readings, file
54
57
  * "practice_round": 1, optional; raise it after rewording, the practice reopens
55
58
  * "items": [{"source_id": "case-0412-b", "group": "python", "body": "…", "code": "…",
56
59
  * "context": "…",
57
60
  * "rules": [{"linter": "ruff", "id": "TID253"}],
58
- * "unit": {"id": "a.md:12", "text": "the whole line", "part": 1, "of": 2}}]
61
+ * "unit": {"id": "a.md:12", "text": "the whole line", "mark": "whole", "part": 1, "of": 2},
62
+ * "readings": {"a": "…", "b": "…"}, "file": "`src/a.ts`: B flags it"}]
59
63
  * rules: optional; each shown with the linter's own
60
64
  * description (`ruff rule`, ESLint meta.docs.description)
61
- * unit: optional; one requirement of a line that holds
62
- * several. Every part of the line is an item, in one group;
63
- * id (never shown) tells two places with the same words apart.
65
+ * unit: optional; the line an item is drawn from, the
66
+ * requirement marked in it; id (never shown) tells two
67
+ * places with the same words apart. kind approval requires
68
+ * unit and rules; kind witness rules, readings and file.
64
69
  * }
65
70
  *
66
71
  * TypeScript, compiled beside itself to `label-page.js` by `npm run build`: a consumer runs the skill
@@ -19,7 +19,7 @@
19
19
  html, body { margin: 0; background: var(--bg); color: var(--text); font-family: var(--sans);
20
20
  font-size: 16px; line-height: 1.6; }
21
21
  main { max-width: 760px; margin: 0 auto; padding: 24px 16px 64px; display: flex; flex-direction: column;
22
- gap: var(--gap-section); }
22
+ gap: var(--gap-section); overflow-wrap: anywhere; }
23
23
  main > * + section, main > section + * { border-top: 1px solid var(--line); padding-top: var(--gap-section); }
24
24
  header.top { display: flex; flex-direction: column; gap: 8px; }
25
25
  h1 { font-size: 1.5rem; line-height: 1.3; margin: 0; color: var(--strong); font-weight: 700; }
@@ -28,8 +28,15 @@ p { margin: 0; max-width: var(--measure); }
28
28
  .muted { color: var(--muted); }
29
29
  .id { font-family: var(--mono); color: var(--muted); }
30
30
  section { display: flex; flex-direction: column; gap: var(--gap-item); }
31
- .item { display: flex; flex-direction: column; gap: 12px; padding-top: var(--gap-item);
31
+ .item { display: flex; flex-direction: column; gap: 20px; padding-top: var(--gap-item);
32
32
  border-top: 1px solid var(--line); }
33
+ /* The evidence of a card: one labelled row per field, the label a short word above its content. The two
34
+ readings sit in the same place on every card: A on a solid accent rule, B on a dashed one. */
35
+ .row { display: flex; flex-direction: column; gap: 4px; }
36
+ .lbl { color: var(--muted); font-size: 0.8rem; letter-spacing: 0.08em; text-transform: uppercase; }
37
+ .reading { border-left: 3px solid var(--accent); padding-left: 12px; }
38
+ .reading.b { border-left: 3px dashed var(--text); }
39
+ mark { background: transparent; color: var(--mark); font-weight: 500; }
33
40
  .item:first-of-type { border-top: 0; padding-top: 0; }
34
41
  .ask { background: var(--surface); border: 1px solid var(--line); border-radius: 12px; padding: 16px;
35
42
  display: flex; flex-direction: column; gap: 12px; }
@@ -308,19 +315,7 @@ pre { overflow-x: auto; background: var(--surface); border: 1px solid var(--line
308
315
  var done = itemDone(item.id);
309
316
  var card = el("article", "item");
310
317
  card.appendChild(el("div", "id", item.id));
311
- if (item.title) card.appendChild(rich("h3", "", item.title));
312
- card.appendChild(rich("p", "body", item.body));
313
- if (item.code) card.appendChild(codeBlock(item.code));
314
- if (item.context) card.appendChild(rich("p", "muted", item.context));
315
- // A rule the item names, with what it does in the linter's own words (looked up at build time).
316
- (item.rules || []).forEach(function (r) {
317
- card.appendChild(rich("p", "muted", "Rule " + r.linter + " " + r.id + ": " + r.description));
318
- });
319
- // One requirement of a line that holds several: which one, and the whole line it came from.
320
- if (item.unit) {
321
- card.appendChild(el("p", "muted", "From the line (requirement " + item.unit.part + " of " + item.unit.of + "):"));
322
- card.appendChild(rich("p", "quote", item.unit.text));
323
- }
318
+ evidence(card, item);
324
319
  var a = answersOf(item.id);
325
320
  askOne(card, a, state.editing[item.id], function (q, v) {
326
321
  delete state.editing[item.id];
@@ -351,6 +346,40 @@ pre { overflow-x: auto; background: var(--surface); border: 1px solid var(--line
351
346
  card.appendChild(statusLine(item.id, missingFor(item.id), saved));
352
347
  return card;
353
348
  }
349
+ // A labelled row: a short word, then what it labels.
350
+ function row(label, content, cls) {
351
+ var r = el("div", "row" + (cls ? " " + cls : ""));
352
+ r.appendChild(el("span", "lbl", label));
353
+ r.appendChild(content);
354
+ return r;
355
+ }
356
+ // The source line with the requirement marked in it, in the line's own words.
357
+ function markedLine(u) {
358
+ var p = el("p", "quote"), at = u.text.indexOf(u.mark);
359
+ p.appendChild(rich("span", "", u.text.slice(0, at)));
360
+ p.appendChild(rich("mark", "", u.mark));
361
+ p.appendChild(rich("span", "", u.text.slice(at + u.mark.length)));
362
+ return p;
363
+ }
364
+ // What a card shows above its question, one labelled row per field (an item and a practice item alike).
365
+ function evidence(card, item) {
366
+ if (item.title) card.appendChild(rich("h3", "", item.title));
367
+ card.appendChild(rich("p", "body", item.body));
368
+ if (item.code) card.appendChild(codeBlock(item.code));
369
+ if (item.unit) card.appendChild(row(item.unit.of > 1 ? "Line · part " + item.unit.part + " of " + item.unit.of : "Line",
370
+ markedLine(item.unit)));
371
+ // A rule the item names, with what it does in the linter's own words (looked up at build time).
372
+ (item.rules || []).forEach(function (r) {
373
+ card.appendChild(row("Rule", rich("p", "", r.linter + " `" + r.id + "`" + (r.family ? " (" + r.family + ")" : "") +
374
+ ": " + r.description)));
375
+ });
376
+ if (item.readings) {
377
+ card.appendChild(row("Reading A", rich("p", "", item.readings.a), "reading a"));
378
+ card.appendChild(row("Reading B", rich("p", "", item.readings.b), "reading b"));
379
+ }
380
+ if (item.file) card.appendChild(row("File", rich("p", "", item.file)));
381
+ if (item.context) card.appendChild(rich("p", "muted", item.context));
382
+ }
354
383
  function labelOf(q, v) {
355
384
  var o = q.options.filter(function (x) { return x.value === v; })[0];
356
385
  return o ? o.label : v;
@@ -397,10 +426,7 @@ pre { overflow-x: auto; background: var(--surface); border: 1px solid var(--line
397
426
  function practiceCard(p, off) {
398
427
  var card = el("article", "item");
399
428
  card.appendChild(el("div", "id", "Practice " + p.id));
400
- if (p.title) card.appendChild(rich("h3", "", p.title));
401
- card.appendChild(rich("p", "body", p.body));
402
- if (p.code) card.appendChild(codeBlock(p.code));
403
- if (p.context) card.appendChild(rich("p", "muted", p.context));
429
+ evidence(card, p);
404
430
  var a = practiceAnswers(p.id);
405
431
  askOne(card, a, undefined, function (q, v) { answerPractice(p.id, q.id, v); }, null, off, true);
406
432
  if (practiceAnswered(p)) {
@@ -3,11 +3,13 @@
3
3
  * ("read as ruff `TID253`" means nothing to a labeller who has not met the code). Never invented: a
4
4
  * rule the linter cannot describe has no description, and the build refuses it.
5
5
  *
6
- * - ruff: `ruff rule <code> --output-format json`, the paragraph under "## What it does".
6
+ * - ruff: `ruff rule <code> --output-format json`, the paragraph under "## What it does", and the
7
+ * family it names (`linter`: flake8-print for T201).
7
8
  * - ESLint: the rule's `meta.docs.description`, from the ESLint (and the plugin) installed where
8
9
  * items.json is: a study's own install, found by Node's resolution from that folder upwards.
9
10
  * A plugin rule is found by ESLint's naming convention (`@scope/rule` → `@scope/eslint-plugin`,
10
- * `@scope/p/rule` → `@scope/eslint-plugin-p`, `p/rule` → `eslint-plugin-p`).
11
+ * `@scope/p/rule` → `@scope/eslint-plugin-p`, `p/rule` → `eslint-plugin-p`); the rule's own name
12
+ * may hold a slash (`n/no-unsupported-features/node-builtins`).
11
13
  */
12
14
  import { spawnSync } from "node:child_process";
13
15
  import { createRequire } from "node:module";
@@ -17,15 +19,17 @@ export const runProcess = (cmd, args) => {
17
19
  const r = spawnSync(cmd, args, { encoding: "utf8" });
18
20
  return { status: r.status, stdout: r.stdout };
19
21
  };
20
- const RuffRule = z.object({ explanation: z.string() });
21
- /** The paragraph under "## What it does", its lines joined. */
22
+ const RuffRule = z.object({ explanation: z.string(), linter: z.string() });
23
+ /** The paragraph under "## What it does", its lines joined, and the rule's family. */
22
24
  function ruffDescription(id, run) {
23
25
  const r = run("ruff", ["rule", id, "--output-format", "json"]);
24
26
  if (r.status !== 0)
25
27
  return undefined;
26
- const { explanation } = RuffRule.parse(JSON.parse(r.stdout));
28
+ const { explanation, linter } = RuffRule.parse(JSON.parse(r.stdout));
27
29
  const what = /## What it does\n([\s\S]*?)(?:\n\n|$)/.exec(explanation)?.[1];
28
- return what?.split("\n").join(" ").trim();
30
+ return what === undefined
31
+ ? undefined
32
+ : { description: what.split("\n").join(" ").trim(), family: linter };
29
33
  }
30
34
  const RuleModule = z.object({
31
35
  meta: z.object({ docs: z.object({ description: z.string() }) }),
@@ -34,19 +38,22 @@ const Plugin = z.object({ rules: z.record(z.string(), z.unknown()) });
34
38
  const Builtins = z.object({
35
39
  builtinRules: z.map(z.string(), z.unknown()),
36
40
  });
37
- /** The package and the rule name a namespaced ESLint rule id points at. */
38
- function pluginOf(id) {
39
- const parts = id.split("/");
40
- const name = id.slice(id.lastIndexOf("/") + 1);
41
- const [scope, plugin] = parts;
42
- if (id.startsWith("@"))
43
- return [
44
- parts.length === 2
45
- ? `${String(scope)}/eslint-plugin`
46
- : `${String(scope)}/eslint-plugin-${String(plugin)}`,
47
- name,
48
- ];
49
- return [`eslint-plugin-${String(scope)}`, name];
41
+ /**
42
+ * The packages and rule names a namespaced ESLint rule id may point at, likeliest first. The rule's
43
+ * own name may hold a slash, so `@scope/a/b` is `a/b` of `@scope/eslint-plugin` or `b` of
44
+ * `@scope/eslint-plugin-a`; an unscoped id's plugin is everything before its first slash.
45
+ */
46
+ function pluginsOf(id) {
47
+ const [first = "", second = "", ...rest] = id.split("/");
48
+ const after = (n) => id.split("/").slice(n).join("/");
49
+ return id.startsWith("@")
50
+ ? [
51
+ ...(rest.length
52
+ ? [[`${first}/eslint-plugin-${second}`, after(2)]]
53
+ : []),
54
+ [`${first}/eslint-plugin`, after(1)],
55
+ ]
56
+ : [[`eslint-plugin-${first}`, after(1)]];
50
57
  }
51
58
  // `require` of an ES-module plugin returns its namespace: the plugin object is then its `default`.
52
59
  const EsmPlugin = z.object({ default: Plugin });
@@ -56,23 +63,34 @@ const pluginRules = (mod) => {
56
63
  };
57
64
  const description = (rule) => {
58
65
  const r = RuleModule.safeParse(rule);
59
- return r.success ? r.data.meta.docs.description : undefined;
66
+ return r.success ? { description: r.data.meta.docs.description } : undefined;
60
67
  };
61
- function eslintDescription(id, fromDir) {
62
- // createRequire takes an absolute path only; items.json is often named relative to the study folder.
63
- const load = createRequire(resolve(fromDir, "items.json"));
68
+ /** `load(pkg)`, or undefined when it is not installed where items.json is. */
69
+ const tryLoad = (load, pkg) => {
64
70
  try {
65
- if (!id.includes("/")) {
66
- const core = Builtins.parse(load("eslint/use-at-your-own-risk"));
67
- return description(core.builtinRules.get(id));
68
- }
69
- const [pkg, name] = pluginOf(id);
70
- return description(pluginRules(load(pkg))[name]);
71
+ return load(pkg);
71
72
  }
72
73
  catch {
73
- // ESLint or the plugin is not installed where items.json is: the linter cannot answer.
74
74
  return undefined;
75
75
  }
76
+ };
77
+ function eslintDescription(id, fromDir) {
78
+ // createRequire takes an absolute path only; items.json is often named relative to the study folder.
79
+ const load = createRequire(resolve(fromDir, "items.json"));
80
+ if (!id.includes("/")) {
81
+ const core = tryLoad(load, "eslint/use-at-your-own-risk");
82
+ return core === undefined
83
+ ? undefined
84
+ : description(Builtins.parse(core).builtinRules.get(id));
85
+ }
86
+ return pluginsOf(id)
87
+ .map(([pkg, name]) => {
88
+ const mod = tryLoad(load, pkg);
89
+ return mod === undefined
90
+ ? undefined
91
+ : description(pluginRules(mod)[name]);
92
+ })
93
+ .find((d) => d !== undefined);
76
94
  }
77
95
  /** The linter's own one-line description of `ref`, or undefined when it has none. */
78
96
  export function describeRule(ref, fromDir, run = runProcess) {
@@ -31,38 +31,83 @@ const fakeRuff: RunProcess = (cmd, args) =>
31
31
  ? { status: 0, stdout: TID253 }
32
32
  : { status: 2, stdout: "" };
33
33
 
34
- test("🔴 a ruff rule is described by its own 'What it does' paragraph, on one line", () => {
35
- assert.equal(
34
+ test("🔴 a ruff rule is described by its own 'What it does' paragraph, on one line, with the family ruff names", () => {
35
+ // Guards: the family (`linter` in ruff's JSON) is kept, not dropped (flake8-tidy-imports for TID253).
36
+ assert.deepEqual(
36
37
  describeRule({ linter: "ruff", id: "TID253" }, REPO, fakeRuff),
37
- "Checks for module-level imports that should instead be imported lazily (e.g., within a function definition, or an `if TYPE_CHECKING:` block, or some other nested context).",
38
+ {
39
+ description:
40
+ "Checks for module-level imports that should instead be imported lazily (e.g., within a function definition, or an `if TYPE_CHECKING:` block, or some other nested context).",
41
+ family: "flake8-tidy-imports",
42
+ },
38
43
  );
39
44
  });
40
45
 
41
46
  test("🔴 an ESLint rule is described by meta.docs.description: a core rule, and a plugin rule resolved by the plugin naming convention", () => {
42
- assert.equal(
47
+ assert.deepEqual(
43
48
  describeRule({ linter: "eslint", id: "no-console" }, REPO, fakeRuff),
44
- "Disallow the use of `console`",
49
+ { description: "Disallow the use of `console`" },
45
50
  );
46
- assert.equal(
51
+ assert.deepEqual(
47
52
  describeRule(
48
53
  { linter: "eslint", id: "@typescript-eslint/no-explicit-any" },
49
54
  REPO,
50
55
  fakeRuff,
51
56
  ),
52
- "Disallow the `any` type",
57
+ { description: "Disallow the `any` type" },
53
58
  );
54
59
  });
55
60
 
61
+ test("🔴 a plugin rule whose own name holds a slash is found under its whole name", () => {
62
+ // Guards: `n/no-unsupported-features/node-builtins` is not cut to `node-builtins`.
63
+ assert.deepEqual(
64
+ describeRule(
65
+ { linter: "eslint", id: "n/no-unsupported-features/node-builtins" },
66
+ REPO,
67
+ fakeRuff,
68
+ ),
69
+ {
70
+ description:
71
+ "disallow unsupported Node.js built-in APIs on the specified version",
72
+ },
73
+ );
74
+ });
75
+
76
+ /** A folder whose own ESLint fails to load (the machine may have another ESLint on NODE_PATH). */
77
+ function brokenEslint(): string {
78
+ const dir = realpathSync(mkdtempSync(join(tmpdir(), "no-eslint-")));
79
+ const pkg = join(dir, "node_modules", "eslint");
80
+ mkdirSync(pkg, { recursive: true });
81
+ writeFileSync(
82
+ join(pkg, "package.json"),
83
+ JSON.stringify({
84
+ name: "eslint",
85
+ exports: { "./use-at-your-own-risk": "./index.js" },
86
+ }),
87
+ );
88
+ writeFileSync(join(pkg, "index.js"), 'throw new Error("broken");\n');
89
+ return dir;
90
+ }
91
+
56
92
  test("a rule the linter does not know, or a plugin that is not installed has no description (never an invented one)", () => {
57
93
  const none = [
58
94
  describeRule({ linter: "ruff", id: "NOPE1" }, REPO, fakeRuff),
59
95
  describeRule({ linter: "eslint", id: "no-such-rule" }, REPO, fakeRuff),
60
96
  describeRule({ linter: "eslint", id: "nope/rule" }, REPO, fakeRuff),
61
97
  describeRule({ linter: "eslint", id: "@nope/x/rule" }, REPO, fakeRuff),
98
+ // an ESLint that cannot be loaded where items.json is
99
+ describeRule(
100
+ { linter: "eslint", id: "no-console" },
101
+ brokenEslint(),
102
+ fakeRuff,
103
+ ),
62
104
  // ruff answers, but without a "What it does" section
63
105
  describeRule({ linter: "ruff", id: "TID253" }, REPO, () => ({
64
106
  status: 0,
65
- stdout: JSON.stringify({ explanation: "## Why is this bad?\nx" }),
107
+ stdout: JSON.stringify({
108
+ explanation: "## Why is this bad?\nx",
109
+ linter: "flake8-tidy-imports",
110
+ }),
66
111
  })),
67
112
  ];
68
113
  assert.deepEqual(none, [
@@ -71,6 +116,7 @@ test("a rule the linter does not know, or a plugin that is not installed has no
71
116
  undefined,
72
117
  undefined,
73
118
  undefined,
119
+ undefined,
74
120
  ]);
75
121
  });
76
122
 
@@ -85,13 +131,13 @@ test("the real process runner returns the exit status and what was printed (node
85
131
  });
86
132
 
87
133
  test("🔴 a relative folder (the documented `build items.json label-page`) is resolved before ESLint is looked up", () => {
88
- assert.equal(
134
+ assert.deepEqual(
89
135
  describeRule(
90
136
  { linter: "eslint", id: "no-console" },
91
137
  relative(process.cwd(), REPO) || ".",
92
138
  fakeRuff,
93
139
  ),
94
- "Disallow the use of `console`",
140
+ { description: "Disallow the use of `console`" },
95
141
  );
96
142
  });
97
143
 
@@ -111,8 +157,8 @@ test("🔴 an ES-module plugin whose rules sit under its default export is descr
111
157
  join(pkg, "index.js"),
112
158
  'export default { rules: { r: { meta: { docs: { description: "Only via default" } } } } };\n',
113
159
  );
114
- assert.equal(
160
+ assert.deepEqual(
115
161
  describeRule({ linter: "eslint", id: "esmonly/r" }, dir, fakeRuff),
116
- "Only via default",
162
+ { description: "Only via default" },
117
163
  );
118
164
  });
@@ -3,11 +3,13 @@
3
3
  * ("read as ruff `TID253`" means nothing to a labeller who has not met the code). Never invented: a
4
4
  * rule the linter cannot describe has no description, and the build refuses it.
5
5
  *
6
- * - ruff: `ruff rule <code> --output-format json`, the paragraph under "## What it does".
6
+ * - ruff: `ruff rule <code> --output-format json`, the paragraph under "## What it does", and the
7
+ * family it names (`linter`: flake8-print for T201).
7
8
  * - ESLint: the rule's `meta.docs.description`, from the ESLint (and the plugin) installed where
8
9
  * items.json is: a study's own install, found by Node's resolution from that folder upwards.
9
10
  * A plugin rule is found by ESLint's naming convention (`@scope/rule` → `@scope/eslint-plugin`,
10
- * `@scope/p/rule` → `@scope/eslint-plugin-p`, `p/rule` → `eslint-plugin-p`).
11
+ * `@scope/p/rule` → `@scope/eslint-plugin-p`, `p/rule` → `eslint-plugin-p`); the rule's own name
12
+ * may hold a slash (`n/no-unsupported-features/node-builtins`).
11
13
  */
12
14
  import { spawnSync } from "node:child_process";
13
15
  import { createRequire } from "node:module";
@@ -18,6 +20,11 @@ export interface RuleRef {
18
20
  readonly linter: "eslint" | "ruff";
19
21
  readonly id: string;
20
22
  }
23
+ /** What the linter says a rule does; a ruff rule also names the family it comes from. */
24
+ export interface Described {
25
+ readonly description: string;
26
+ readonly family?: string;
27
+ }
21
28
  /** A process run to its end: the exit status and what it printed. */
22
29
  export type RunProcess = (
23
30
  cmd: string,
@@ -29,14 +36,16 @@ export const runProcess: RunProcess = (cmd, args) => {
29
36
  return { status: r.status, stdout: r.stdout };
30
37
  };
31
38
 
32
- const RuffRule = z.object({ explanation: z.string() });
33
- /** The paragraph under "## What it does", its lines joined. */
34
- function ruffDescription(id: string, run: RunProcess): string | undefined {
39
+ const RuffRule = z.object({ explanation: z.string(), linter: z.string() });
40
+ /** The paragraph under "## What it does", its lines joined, and the rule's family. */
41
+ function ruffDescription(id: string, run: RunProcess): Described | undefined {
35
42
  const r = run("ruff", ["rule", id, "--output-format", "json"]);
36
43
  if (r.status !== 0) return undefined;
37
- const { explanation } = RuffRule.parse(JSON.parse(r.stdout));
44
+ const { explanation, linter } = RuffRule.parse(JSON.parse(r.stdout));
38
45
  const what = /## What it does\n([\s\S]*?)(?:\n\n|$)/.exec(explanation)?.[1];
39
- return what?.split("\n").join(" ").trim();
46
+ return what === undefined
47
+ ? undefined
48
+ : { description: what.split("\n").join(" ").trim(), family: linter };
40
49
  }
41
50
 
42
51
  const RuleModule = z.object({
@@ -46,19 +55,22 @@ const Plugin = z.object({ rules: z.record(z.string(), z.unknown()) });
46
55
  const Builtins = z.object({
47
56
  builtinRules: z.map(z.string(), z.unknown()),
48
57
  });
49
- /** The package and the rule name a namespaced ESLint rule id points at. */
50
- function pluginOf(id: string): readonly [string, string] {
51
- const parts = id.split("/");
52
- const name = id.slice(id.lastIndexOf("/") + 1);
53
- const [scope, plugin] = parts;
54
- if (id.startsWith("@"))
55
- return [
56
- parts.length === 2
57
- ? `${String(scope)}/eslint-plugin`
58
- : `${String(scope)}/eslint-plugin-${String(plugin)}`,
59
- name,
60
- ];
61
- return [`eslint-plugin-${String(scope)}`, name];
58
+ /**
59
+ * The packages and rule names a namespaced ESLint rule id may point at, likeliest first. The rule's
60
+ * own name may hold a slash, so `@scope/a/b` is `a/b` of `@scope/eslint-plugin` or `b` of
61
+ * `@scope/eslint-plugin-a`; an unscoped id's plugin is everything before its first slash.
62
+ */
63
+ function pluginsOf(id: string): readonly (readonly [string, string])[] {
64
+ const [first = "", second = "", ...rest] = id.split("/");
65
+ const after = (n: number): string => id.split("/").slice(n).join("/");
66
+ return id.startsWith("@")
67
+ ? [
68
+ ...(rest.length
69
+ ? [[`${first}/eslint-plugin-${second}`, after(2)] as const]
70
+ : []),
71
+ [`${first}/eslint-plugin`, after(1)] as const,
72
+ ]
73
+ : [[`eslint-plugin-${first}`, after(1)] as const];
62
74
  }
63
75
  // `require` of an ES-module plugin returns its namespace: the plugin object is then its `default`.
64
76
  const EsmPlugin = z.object({ default: Plugin });
@@ -66,24 +78,35 @@ const pluginRules = (mod: unknown): Readonly<Record<string, unknown>> => {
66
78
  const esm = EsmPlugin.safeParse(mod);
67
79
  return esm.success ? esm.data.default.rules : Plugin.parse(mod).rules;
68
80
  };
69
- const description = (rule: unknown): string | undefined => {
81
+ const description = (rule: unknown): Described | undefined => {
70
82
  const r = RuleModule.safeParse(rule);
71
- return r.success ? r.data.meta.docs.description : undefined;
83
+ return r.success ? { description: r.data.meta.docs.description } : undefined;
72
84
  };
73
- function eslintDescription(id: string, fromDir: string): string | undefined {
74
- // createRequire takes an absolute path only; items.json is often named relative to the study folder.
75
- const load = createRequire(resolve(fromDir, "items.json"));
85
+ /** `load(pkg)`, or undefined when it is not installed where items.json is. */
86
+ const tryLoad = (load: NodeJS.Require, pkg: string): unknown => {
76
87
  try {
77
- if (!id.includes("/")) {
78
- const core = Builtins.parse(load("eslint/use-at-your-own-risk"));
79
- return description(core.builtinRules.get(id));
80
- }
81
- const [pkg, name] = pluginOf(id);
82
- return description(pluginRules(load(pkg))[name]);
88
+ return load(pkg);
83
89
  } catch {
84
- // ESLint or the plugin is not installed where items.json is: the linter cannot answer.
85
90
  return undefined;
86
91
  }
92
+ };
93
+ function eslintDescription(id: string, fromDir: string): Described | undefined {
94
+ // createRequire takes an absolute path only; items.json is often named relative to the study folder.
95
+ const load = createRequire(resolve(fromDir, "items.json"));
96
+ if (!id.includes("/")) {
97
+ const core = tryLoad(load, "eslint/use-at-your-own-risk");
98
+ return core === undefined
99
+ ? undefined
100
+ : description(Builtins.parse(core).builtinRules.get(id));
101
+ }
102
+ return pluginsOf(id)
103
+ .map(([pkg, name]) => {
104
+ const mod = tryLoad(load, pkg);
105
+ return mod === undefined
106
+ ? undefined
107
+ : description(pluginRules(mod)[name]);
108
+ })
109
+ .find((d) => d !== undefined);
87
110
  }
88
111
 
89
112
  /** The linter's own one-line description of `ref`, or undefined when it has none. */
@@ -91,7 +114,7 @@ export function describeRule(
91
114
  ref: RuleRef,
92
115
  fromDir: string,
93
116
  run: RunProcess = runProcess,
94
- ): string | undefined {
117
+ ): Described | undefined {
95
118
  return ref.linter === "ruff"
96
119
  ? ruffDescription(ref.id, run)
97
120
  : eslintDescription(ref.id, fromDir);