paperlint 13.4.0 → 13.6.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.
@@ -1,3 +1,4 @@
1
+ import { type RuleRef } from "./rule-descriptions.ts";
1
2
  export declare class Refusal extends Error {
2
3
  readonly exitCode: number;
3
4
  constructor(exitCode: number, message: string);
@@ -12,6 +13,7 @@ type Json = null | boolean | number | string | readonly Json[] | {
12
13
  type JsonObject = {
13
14
  readonly [key: string]: Json;
14
15
  };
16
+ declare const PAGE_FIELDS: readonly ["group", "title", "body", "code", "context"];
15
17
  type SchemaNode = {
16
18
  kind: "string";
17
19
  required: boolean;
@@ -53,9 +55,20 @@ interface Question {
53
55
  readonly short?: string;
54
56
  readonly options: readonly Option[];
55
57
  }
56
- type Item = Readonly<Record<string, string>> & {
58
+ interface Unit {
59
+ readonly id: string;
60
+ readonly text: string;
61
+ readonly part: number;
62
+ readonly of: number;
63
+ }
64
+ type Item = {
65
+ readonly [F in (typeof PAGE_FIELDS)[number]]?: string;
66
+ } & {
57
67
  readonly source_id: string;
58
68
  readonly group: string;
69
+ readonly body: string;
70
+ readonly rules?: readonly RuleRef[];
71
+ readonly unit?: Unit;
59
72
  };
60
73
  interface Practice {
61
74
  readonly title?: string;
@@ -84,6 +97,8 @@ interface KeyEntry {
84
97
  readonly group: string;
85
98
  readonly on_page: boolean;
86
99
  }
100
+ /** Rule → the linter's description, for every rule an item on the page names. */
101
+ type Described = ReadonlyMap<string, string>;
87
102
  /** Only what the labeller can answer goes on the page, and only that gets an id, in id order. */
88
103
  declare function itemsOnPage(cfg: Config, keyPath: string): {
89
104
  items: Item[];
@@ -92,7 +107,7 @@ declare function itemsOnPage(cfg: Config, keyPath: string): {
92
107
  idOf: (i: Item) => string;
93
108
  };
94
109
  /** What a page shows: the questions, the practice and the items, blind, with their neutral ids. */
95
- declare function payloadOf(cfg: Config, page: ReturnType<typeof itemsOnPage>): {
110
+ declare function payloadOf(cfg: Config, page: ReturnType<typeof itemsOnPage>, described: Described): {
96
111
  title: string;
97
112
  intro: string;
98
113
  groups: {
@@ -112,9 +127,17 @@ declare function payloadOf(cfg: Config, page: ReturnType<typeof itemsOnPage>): {
112
127
  readonly id: string;
113
128
  })[];
114
129
  };
115
- items: {
130
+ items: ({
131
+ rules?: never;
116
132
  id: string;
117
- }[];
133
+ } | {
134
+ rules: {
135
+ linter: "eslint" | "ruff";
136
+ id: string;
137
+ description: string;
138
+ }[];
139
+ id: string;
140
+ })[];
118
141
  };
119
142
  export type Payload = ReturnType<typeof payloadOf>;
120
143
  /**
@@ -143,9 +166,17 @@ export declare function prepareBuild(configPath: string, outDir: string): {
143
166
  readonly id: string;
144
167
  })[];
145
168
  };
146
- items: {
169
+ items: ({
170
+ rules?: never;
147
171
  id: string;
148
- }[];
172
+ } | {
173
+ rules: {
174
+ linter: "eslint" | "ruff";
175
+ id: string;
176
+ description: string;
177
+ }[];
178
+ id: string;
179
+ })[];
149
180
  };
150
181
  dropped: number;
151
182
  };
@@ -0,0 +1,12 @@
1
+ export interface RuleRef {
2
+ readonly linter: "eslint" | "ruff";
3
+ readonly id: string;
4
+ }
5
+ /** A process run to its end: the exit status and what it printed. */
6
+ export type RunProcess = (cmd: string, args: readonly string[]) => {
7
+ readonly status: number | null;
8
+ readonly stdout: string;
9
+ };
10
+ export declare const runProcess: RunProcess;
11
+ /** The linter's own one-line description of `ref`, or undefined when it has none. */
12
+ export declare function describeRule(ref: RuleRef, fromDir: string, run?: RunProcess): string | undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "paperlint",
3
- "version": "13.4.0",
3
+ "version": "13.6.0",
4
4
  "description": "A linter for scientific papers written in LaTeX: catches mistakes before you submit to a conference or journal.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -37,8 +37,22 @@ length.
37
37
  - **`practice_round`**: raise it after rewording a question; the practice reopens and the answers of
38
38
  the earlier round are kept but not counted.
39
39
  - **`items`**: `source_id` (the study's own, never shown), `group`, `body`, and optionally `title`,
40
- `code`, `context`. Nothing else: the allow-list is fixed in the generator, and a field outside it
40
+ `code`, `context`, `rules`, `unit`. Nothing else: the allow-list is fixed in the generator, and a field outside it
41
41
  is taken out of the item, not added to the list to make a build pass.
42
+ - **`rules`**: `[{linter, id}]` (`linter` is `eslint` or `ruff`) on an item read as a linter rule.
43
+ The card shows "Rule ruff TID253: Checks for module-level imports that should instead be imported
44
+ lazily…", the description taken at build time from the linter itself: `ruff rule <id>`'s "What it
45
+ does" paragraph, or the ESLint rule's `meta.docs.description` from the ESLint and plugins
46
+ installed where `items.json` is. The build refuses a rule the linter cannot describe; a
47
+ description is never typed into `items.json`.
48
+ - **`unit`**: `{id, text, part, of}` on an item that is one requirement of a line holding several
49
+ ("TypeScript only (no JavaScript); top-level await is allowed" is two). Each requirement is its
50
+ own item with its own question, and its card shows "From the line (requirement 1 of 2):" and the
51
+ whole line beneath, so the labeller judges one requirement at a time and sees where it came from.
52
+ The build refuses a line whose parts do not add up (one missing, repeated, a different `of`, or
53
+ split across groups), naming the item. `id` names one occurrence of the line (a file and line
54
+ number, say) and is never shown: two files holding the same words are two lines, each judged in
55
+ full. Splitting the line is the study's job, before the page.
42
56
  - **`seed`**: fixes the order. Rebuilding with the same seed keeps every id, and a new item gets the
43
57
  next one; a different seed is refused once a key exists.
44
58
  - **`ask_why`** on an option: a prompt. That answer is incomplete, on the page and in the export,
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "banned-module-level-imports",
3
+ "code": "TID253",
4
+ "linter": "flake8-tidy-imports",
5
+ "summary": "`{name}` is banned at the module level",
6
+ "message_formats": [
7
+ "`{name}` is banned at the module level"
8
+ ],
9
+ "fix": "Fix is not available.",
10
+ "fix_availability": "None",
11
+ "explanation": "## What it does\nChecks for module-level imports that should instead be imported lazily\n(e.g., within a function definition, or an `if TYPE_CHECKING:` block, or\nsome other nested context).\n\n## Why is this bad?\nSome modules are expensive to import. For example, importing `torch` or\n`tensorflow` can introduce a noticeable delay in the startup time of a\nPython program.\n\nIn such cases, you may want to enforce that the module is imported lazily\nas needed, rather than at the top of the file. This could involve inlining\nthe import into the function that uses it, rather than importing it\nunconditionally, to ensure that the module is only imported when necessary.\n\n## Example\n```python\nimport tensorflow as tf\n\n\ndef show_version():\n print(tf.__version__)\n```\n\nUse instead:\n```python\ndef show_version():\n import tensorflow as tf\n\n print(tf.__version__)\n```\n\n## Options\n- `lint.flake8-tidy-imports.banned-module-level-imports`\n",
12
+ "preview": false,
13
+ "status": {
14
+ "Stable": {
15
+ "since": "v0.0.285"
16
+ }
17
+ },
18
+ "category": "restriction",
19
+ "source_location": {
20
+ "file": "crates/ruff_linter/src/rules/flake8_tidy_imports/rules/banned_module_level_imports.rs",
21
+ "line": 46
22
+ }
23
+ }
@@ -6,8 +6,9 @@
6
6
  */
7
7
  import { createHash } from "node:crypto";
8
8
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
9
- import { join } from "node:path";
9
+ import { dirname, join } from "node:path";
10
10
  import { z } from "zod";
11
+ import { describeRule } from "./rule-descriptions.js";
11
12
  // ── refusals and output ───────────────────────────────────────────────────────────────────────
12
13
  export class Refusal extends Error {
13
14
  exitCode;
@@ -89,7 +90,18 @@ const readJson = (path) => {
89
90
  // labeller what the answer is, or where the item came from?" — never to make a build pass.
90
91
  const PAGE_FIELDS = ["group", "title", "body", "code", "context"];
91
92
  const PRIVATE_FIELDS = ["source_id"];
92
- const ALLOWED_FIELDS = [...PAGE_FIELDS, ...PRIVATE_FIELDS];
93
+ // The linter rules an item names ("read as ruff `TID253`"): the page shows each with the linter's
94
+ // own description, looked up at build time (rule-descriptions.ts), never typed into items.json.
95
+ const RULES_FIELD = "rules";
96
+ // One requirement of a line that holds several: the whole line and which part this item is. Shown on
97
+ // the card beneath the item; the line is text the labeller may read, like `context`.
98
+ const UNIT_FIELD = "unit";
99
+ const ALLOWED_FIELDS = [
100
+ ...PAGE_FIELDS,
101
+ RULES_FIELD,
102
+ UNIT_FIELD,
103
+ ...PRIVATE_FIELDS,
104
+ ];
93
105
  const FAMILIARITY = [
94
106
  { value: "well", label: "Well" },
95
107
  { value: "somewhat", label: "Somewhat" },
@@ -140,10 +152,14 @@ const mapping = (values, keyPattern, min = 0) => ({
140
152
  key_pattern: keyPattern,
141
153
  values,
142
154
  });
143
- const obj = (fields, closed = true) => ({ kind: "object", required: true, closed, fields });
155
+ const obj = (fields, closed = true, required = true) => ({ kind: "object", required, closed, fields });
144
156
  const ITEM_SCHEMA = obj({
145
157
  source_id: string(),
146
158
  ...Object.fromEntries(PAGE_FIELDS.map((f) => [f, string(f === "group" || f === "body")])),
159
+ [RULES_FIELD]: array(obj({ linter: string(true, "eslint|ruff"), id: string() }), false, 1),
160
+ [UNIT_FIELD]: obj(
161
+ // `id` names one occurrence of the line (two files may hold the same words): never shown.
162
+ { id: string(), text: string(), part: integer(), of: integer() }, true, false),
147
163
  }, false);
148
164
  // `icon` (an emoji) tells the buttons apart at a glance. No coloured side stripe on a button: it
149
165
  // reads as decoration, not meaning (the build-label-page look test refuses one).
@@ -356,7 +372,41 @@ function checkConfig(cfg) {
356
372
  }
357
373
  /** The practice as the page shows it and the key keeps it: neutral ids P1, P2 … in the given order. */
358
374
  const practiceOf = (cfg) => cfg.practice.map((item, n) => ({ id: `P${String(n + 1)}`, ...item }));
359
- // ── the items: the leak guard, and the references to the config ───────────────────────────────
375
+ /** The items of each line, in items.json order; a line's list is never empty. */
376
+ const linesOf = (cfg) => cfg.items.reduce((byLine, item, n) => {
377
+ if (item.unit === undefined)
378
+ return byLine;
379
+ const part = { item, unit: item.unit, n };
380
+ const seen = byLine.get(item.unit.id);
381
+ return byLine.set(item.unit.id, seen ? [seen[0], ...seen.slice(1), part] : [part]);
382
+ }, new Map());
383
+ /** One part against the first part of its line: same count, in range, not repeated, same group. */
384
+ function checkPart({ item, unit, n }, parts) {
385
+ const [first] = parts;
386
+ const at = `items[${String(n)}]`;
387
+ if (unit.text !== first.unit.text)
388
+ fail(`${at}.unit.text: ${repr(unit.text)}, but items[${String(first.n)}] of line ${repr(unit.id)} says ${repr(first.unit.text)}`);
389
+ if (unit.of !== first.unit.of)
390
+ fail(`${at}.unit.of: ${String(unit.of)}, but items[${String(first.n)}].unit.of says ${String(first.unit.of)} for the same line`);
391
+ if (unit.part < 1 || unit.part > unit.of)
392
+ fail(`${at}.unit.part: ${String(unit.part)} is not between 1 and ${String(unit.of)}`);
393
+ const earlier = parts.find((p) => p.n < n && p.unit.part === unit.part);
394
+ if (earlier !== undefined)
395
+ fail(`${at}.unit.part: ${String(unit.part)} repeats items[${String(earlier.n)}].unit.part`);
396
+ if (item.group !== first.item.group)
397
+ fail(`${at}.group: ${repr(item.group)}, but items[${String(first.n)}] of the same line is in ${repr(first.item.group)}`);
398
+ }
399
+ function checkUnits(cfg) {
400
+ for (const [line, parts] of linesOf(cfg)) {
401
+ parts.forEach((p) => {
402
+ checkPart(p, parts);
403
+ });
404
+ const [first] = parts;
405
+ const missing = Array.from({ length: first.unit.of }, (_, k) => k + 1).find((k) => !parts.some((p) => p.unit.part === k));
406
+ if (missing !== undefined)
407
+ fail(`items[${String(first.n)}].unit: part ${String(missing)} of ${String(first.unit.of)} of line ${repr(line)} is not among the items`);
408
+ }
409
+ }
360
410
  function checkItemFields(cfg) {
361
411
  const sids = [];
362
412
  cfg.items.forEach((item, n) => {
@@ -373,6 +423,11 @@ function checkItemFields(cfg) {
373
423
  fail(`items[${String(n)}].group: ${repr(item.group)} is not in \`groups\``);
374
424
  });
375
425
  }
426
+ /** Every text of an item the labeller sees, by field: the page fields and the line of a unit. */
427
+ const visibleTexts = (item) => [
428
+ ...PAGE_FIELDS.map((f) => [f, item[f] ?? ""]),
429
+ [UNIT_FIELD, item.unit?.text ?? ""],
430
+ ];
376
431
  const escapeRegExp = (s) => s.replace(/[\\^$.*+?()[\]{}|/]/g, "\\$&");
377
432
  /**
378
433
  * A source id typed into a visible field gives the source away as well as a field would. Short ids
@@ -385,7 +440,7 @@ function checkNoSourceShown(cfg) {
385
440
  for (const item of cfg.items) {
386
441
  for (const sid of long) {
387
442
  const word = new RegExp(`(?<![\\p{L}\\p{N}_-])${escapeRegExp(sid)}(?![\\p{L}\\p{N}_-])`, "u");
388
- const field = PAGE_FIELDS.find((f) => word.test(item[f] ?? ""));
443
+ const field = visibleTexts(item).find(([, text]) => word.test(text))?.[0];
389
444
  if (field !== undefined) {
390
445
  fail(`the source id ${repr(sid)} appears in the visible \`${field}\` of item ${item.source_id}. Take it out of the text (or use source ids that are not natural words).`);
391
446
  }
@@ -426,11 +481,40 @@ function assignIds(items, seed, keyPath) {
426
481
  ]);
427
482
  return { ids, entries };
428
483
  }
429
- // ── a build, short of the page ──────────────────────────────────────────────────────────────────
430
- const shownFields = (item) => Object.fromEntries(PAGE_FIELDS.flatMap((f) => {
431
- const v = item[f];
432
- return v === undefined ? [] : [[f, v]];
433
- }));
484
+ const shownFields = (item) => ({
485
+ ...Object.fromEntries(PAGE_FIELDS.flatMap((f) => {
486
+ const v = item[f];
487
+ return v === undefined ? [] : [[f, v]];
488
+ })),
489
+ ...(item.unit === undefined
490
+ ? {}
491
+ : {
492
+ [UNIT_FIELD]: {
493
+ text: item.unit.text,
494
+ part: item.unit.part,
495
+ of: item.unit.of,
496
+ },
497
+ }),
498
+ });
499
+ const ruleKey = (r) => `${r.linter} ${r.id}`;
500
+ /** Ask the linter for every rule named; refuse, by path, a rule it cannot describe. */
501
+ function describeRules(cfg, fromDir) {
502
+ return new Map(cfg.items.flatMap((item, n) => (item.rules ?? []).map((r, k) => {
503
+ const text = describeRule(r, fromDir);
504
+ return text === undefined
505
+ ? fail(`items[${String(n)}].rules[${String(k)}]: ${r.linter} ${repr(r.id)} has no description from the linter (${r.linter === "ruff" ? "`ruff rule` does not know it, or ruff is not on PATH" : "not a rule of the ESLint, or of a plugin, installed where items.json is"}) — check the id`)
506
+ : [ruleKey(r), text];
507
+ })));
508
+ }
509
+ const rulesShown = (item, described) => item.rules === undefined
510
+ ? {}
511
+ : {
512
+ [RULES_FIELD]: item.rules.map((r) => ({
513
+ linter: r.linter,
514
+ id: r.id,
515
+ description: String(described.get(ruleKey(r))),
516
+ })),
517
+ };
434
518
  /** Only what the labeller can answer goes on the page, and only that gets an id, in id order. */
435
519
  function itemsOnPage(cfg, keyPath) {
436
520
  const knows = cfg.labeller_knows;
@@ -445,7 +529,7 @@ function itemsOnPage(cfg, keyPath) {
445
529
  }
446
530
  const practiceRound = (cfg) => cfg.practice_round ?? 1;
447
531
  /** What a page shows: the questions, the practice and the items, blind, with their neutral ids. */
448
- function payloadOf(cfg, page) {
532
+ function payloadOf(cfg, page, described) {
449
533
  const used = Object.keys(cfg.groups).filter((g) => page.items.some((i) => i.group === g));
450
534
  return {
451
535
  title: cfg.title,
@@ -458,7 +542,11 @@ function payloadOf(cfg, page) {
458
542
  allowed: allowedValues(cfg.questions, asksFamiliarity(cfg)),
459
543
  questions: cfg.questions,
460
544
  practice: { round: practiceRound(cfg), items: practiceOf(cfg) },
461
- items: page.items.map((i) => ({ id: page.idOf(i), ...shownFields(i) })),
545
+ items: page.items.map((i) => ({
546
+ id: page.idOf(i),
547
+ ...shownFields(i),
548
+ ...rulesShown(i, described),
549
+ })),
462
550
  };
463
551
  }
464
552
  /** Every id the key keeps (on the page now, or kept from an earlier build), with its source and group. */
@@ -524,14 +612,16 @@ export function prepareBuild(configPath, outDir) {
524
612
  assertConfig(cfg);
525
613
  checkConfig(cfg);
526
614
  checkItemFields(cfg);
615
+ checkUnits(cfg);
527
616
  checkNoSourceShown(cfg);
617
+ const described = describeRules(cfg, dirname(configPath));
528
618
  mkdirSync(outDir, { recursive: true });
529
619
  const keyPath = join(outDir, "key.json");
530
620
  const earlier = roundsOf(keyPath);
531
621
  const page = itemsOnPage(cfg, keyPath);
532
622
  writeJson(keyPath, keyOf(cfg, page, earlier));
533
623
  return {
534
- payload: payloadOf(cfg, page),
624
+ payload: payloadOf(cfg, page, described),
535
625
  dropped: cfg.items.length - page.items.length,
536
626
  };
537
627
  }
@@ -81,7 +81,16 @@ const FULL: DocObject = (() => {
81
81
  familiarity: true,
82
82
  familiarity_question: "How well do you know {group}?",
83
83
  items: ITEMS.map((i, n) =>
84
- n === 0 ? { ...i, title: "T", code: "c = 1", context: "ctx" } : i,
84
+ n === 0
85
+ ? {
86
+ ...i,
87
+ title: "T",
88
+ code: "c = 1",
89
+ context: "ctx",
90
+ rules: [{ linter: "eslint", id: "no-console" }],
91
+ unit: { id: "L0", text: "One line.", part: 1, of: 1 },
92
+ }
93
+ : i,
85
94
  ),
86
95
  }),
87
96
  );
@@ -1167,6 +1176,43 @@ test("🔴 a question dropped between practice rounds: the earlier round's answe
1167
1176
  assert.match(r.out, /practice round 1: 3 of 3 matched/);
1168
1177
  });
1169
1178
 
1179
+ test("🔴 an item that names a linter rule: the build takes the rule's description from the linter, and refuses one it cannot describe", () => {
1180
+ const named = (rules: readonly Record<string, string>[]) =>
1181
+ config({
1182
+ items: ITEMS.map((item, n) => (n === 0 ? { ...item, rules } : item)),
1183
+ });
1184
+ const { page } = build(
1185
+ setup("rules-ok", named([{ linter: "eslint", id: "no-console" }])),
1186
+ );
1187
+ assert.ok(
1188
+ page.includes(
1189
+ '"rules": [{"linter": "eslint", "id": "no-console", "description": "Disallow the use of `console`"}]',
1190
+ ),
1191
+ "the page carries the linter's own description",
1192
+ );
1193
+ const dir = setup(
1194
+ "rules-unknown",
1195
+ named([{ linter: "eslint", id: "no-such-rule" }]),
1196
+ );
1197
+ const r = run(["build", join(dir, "items.json"), join(dir, "out")]);
1198
+ assert.equal(r.status, 1, r.out);
1199
+ assert.match(
1200
+ r.out,
1201
+ /REFUSED: items\[0\]\.rules\[0\]: eslint 'no-such-rule' has no description from the linter/,
1202
+ );
1203
+ // ruff answers the same whether it is missing or does not know the code: no description.
1204
+ const ruff = setup(
1205
+ "rules-ruff-unknown",
1206
+ named([{ linter: "ruff", id: "NOPE1" }]),
1207
+ );
1208
+ const r2 = run(["build", join(ruff, "items.json"), join(ruff, "out")]);
1209
+ assert.equal(r2.status, 1, r2.out);
1210
+ assert.match(
1211
+ r2.out,
1212
+ /ruff 'NOPE1' has no description from the linter \(`ruff rule` does not know it/,
1213
+ );
1214
+ });
1215
+
1170
1216
  test("🔴 familiarity off: the key asks for none, the export reports no missing familiarity, and a stored one is refused", () => {
1171
1217
  const dir = setup("familiarity-off", approval({ familiarity: false }));
1172
1218
  const { key } = build(dir);
@@ -1225,3 +1271,135 @@ test("familiarity left out: every group is still asked (rule 5), and a missing o
1225
1271
  );
1226
1272
  assert.match(exportAt(dir).out, /group python: no familiarity answer/);
1227
1273
  });
1274
+
1275
+ /** Items 0 and 3 (both Rust) as the two requirements of one line. */
1276
+ const LINE = "TypeScript only (no JavaScript); top-level await is allowed.";
1277
+ const withUnit = (
1278
+ parts: readonly (readonly [n: number, unit: Record<string, unknown>])[],
1279
+ ): Record<string, unknown> =>
1280
+ config({
1281
+ items: ITEMS.map((item, n) => {
1282
+ const u = parts.find(([at]) => at === n);
1283
+ return u === undefined ? item : { ...item, unit: u[1] };
1284
+ }),
1285
+ });
1286
+
1287
+ /** A line whose parts do not add up, and the refusal that names it. */
1288
+ const UNIT_REFUSALS: readonly (readonly [
1289
+ string,
1290
+ Record<string, unknown>,
1291
+ string,
1292
+ ])[] = [
1293
+ [
1294
+ "unit-missing-part",
1295
+ withUnit([[0, { id: "L1", text: LINE, part: 1, of: 2 }]]),
1296
+ `items[0].unit: part 2 of 2 of line 'L1' is not among the items`,
1297
+ ],
1298
+ [
1299
+ "unit-part-twice",
1300
+ withUnit([
1301
+ [0, { id: "L1", text: LINE, part: 1, of: 2 }],
1302
+ [3, { id: "L1", text: LINE, part: 1, of: 2 }],
1303
+ ]),
1304
+ "items[3].unit.part: 1 repeats items[0].unit.part",
1305
+ ],
1306
+ [
1307
+ "unit-of-differs",
1308
+ withUnit([
1309
+ [0, { id: "L1", text: LINE, part: 1, of: 2 }],
1310
+ [3, { id: "L1", text: LINE, part: 2, of: 3 }],
1311
+ ]),
1312
+ "items[3].unit.of: 3, but items[0].unit.of says 2 for the same line",
1313
+ ],
1314
+ [
1315
+ "unit-part-range",
1316
+ withUnit([[0, { id: "L1", text: LINE, part: 3, of: 2 }]]),
1317
+ "items[0].unit.part: 3 is not between 1 and 2",
1318
+ ],
1319
+ [
1320
+ "unit-groups-differ",
1321
+ withUnit([
1322
+ [0, { id: "L1", text: LINE, part: 1, of: 2 }],
1323
+ [1, { id: "L1", text: LINE, part: 2, of: 2 }],
1324
+ ]),
1325
+ "items[1].group: 'python', but items[0] of the same line is in 'rust'",
1326
+ ],
1327
+ [
1328
+ "unit-text-differs",
1329
+ withUnit([
1330
+ [0, { id: "L1", text: LINE, part: 1, of: 2 }],
1331
+ [3, { id: "L1", text: "Another line.", part: 2, of: 2 }],
1332
+ ]),
1333
+ "items[3].unit.text: 'Another line.', but items[0] of line 'L1' says",
1334
+ ],
1335
+ [
1336
+ "unit-halves-of-two-occurrences",
1337
+ withUnit([
1338
+ [0, { id: "L1", text: LINE, part: 1, of: 2 }],
1339
+ [3, { id: "L2", text: LINE, part: 2, of: 2 }],
1340
+ ]),
1341
+ "items[0].unit: part 2 of 2 of line 'L1' is not among the items",
1342
+ ],
1343
+ [
1344
+ "unit-leaks-source",
1345
+ withUnit([[0, { id: "L1", text: "as in case-1003", part: 1, of: 1 }]]),
1346
+ "the source id 'case-1003' appears in the visible `unit` of item case-1000",
1347
+ ],
1348
+ ];
1349
+
1350
+ test("🔴 a line with several requirements: build refuses a unit whose parts do not add up, naming the item", () => {
1351
+ for (const [name, cfg, message] of UNIT_REFUSALS) {
1352
+ const dir = setup(name, cfg);
1353
+ const r = run(["build", join(dir, "items.json"), join(dir, "out")]);
1354
+ assert.equal(r.status, 1, `${name}\n${r.out}`);
1355
+ assert.ok(r.out.includes(`REFUSED: ${message}`), `${name}\n${r.out}`);
1356
+ }
1357
+ });
1358
+
1359
+ test("a line whose every requirement is an item builds, and each item carries its part and the whole line", () => {
1360
+ const dir = setup(
1361
+ "unit-ok",
1362
+ withUnit([
1363
+ [0, { id: "L1", text: LINE, part: 1, of: 2 }],
1364
+ [3, { id: "L1", text: LINE, part: 2, of: 2 }],
1365
+ ]),
1366
+ );
1367
+ const { page } = build(dir);
1368
+ const json =
1369
+ /<script type="application\/json" id="data">([\s\S]*?)<\/script>/.exec(
1370
+ page,
1371
+ )?.[1];
1372
+ assert.ok(json !== undefined);
1373
+ const units = z
1374
+ .object({
1375
+ items: z.array(
1376
+ z.object({
1377
+ unit: z
1378
+ .object({ text: z.string(), part: z.number(), of: z.number() })
1379
+ .optional(),
1380
+ }),
1381
+ ),
1382
+ })
1383
+ .parse(JSON.parse(json.replace(/<\\\//g, "</")))
1384
+ .items.flatMap((i) => (i.unit ? [i.unit] : []))
1385
+ .sort((a, b) => a.part - b.part);
1386
+ // what the page carries: the part and the line, never the line's private id
1387
+ assert.deepEqual(units, [
1388
+ { text: LINE, part: 1, of: 2 },
1389
+ { text: LINE, part: 2, of: 2 },
1390
+ ]);
1391
+ });
1392
+
1393
+ test("🔴 the same line in two places is two lines: four items, two ids, it builds, and the id stays off the page", () => {
1394
+ const dir = setup(
1395
+ "unit-same-text",
1396
+ withUnit([
1397
+ [0, { id: "a.md:3", text: LINE, part: 1, of: 2 }],
1398
+ [3, { id: "a.md:3", text: LINE, part: 2, of: 2 }],
1399
+ [6, { id: "b.md:9", text: LINE, part: 1, of: 2 }],
1400
+ [9, { id: "b.md:9", text: LINE, part: 2, of: 2 }],
1401
+ ]),
1402
+ );
1403
+ const { page } = build(dir);
1404
+ assert.ok(!page.includes("a.md:3"), "the line's id is private");
1405
+ });
@@ -6,8 +6,9 @@
6
6
  */
7
7
  import { createHash } from "node:crypto";
8
8
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
9
- import { join } from "node:path";
9
+ import { dirname, join } from "node:path";
10
10
  import { z } from "zod";
11
+ import { describeRule, type RuleRef } from "./rule-descriptions.ts";
11
12
 
12
13
  // ── refusals and output ───────────────────────────────────────────────────────────────────────
13
14
  export class Refusal extends Error {
@@ -94,7 +95,18 @@ const readJson = (path: string): Json => {
94
95
  // labeller what the answer is, or where the item came from?" — never to make a build pass.
95
96
  const PAGE_FIELDS = ["group", "title", "body", "code", "context"] as const;
96
97
  const PRIVATE_FIELDS = ["source_id"] as const;
97
- const ALLOWED_FIELDS: readonly string[] = [...PAGE_FIELDS, ...PRIVATE_FIELDS];
98
+ // The linter rules an item names ("read as ruff `TID253`"): the page shows each with the linter's
99
+ // own description, looked up at build time (rule-descriptions.ts), never typed into items.json.
100
+ const RULES_FIELD = "rules";
101
+ // One requirement of a line that holds several: the whole line and which part this item is. Shown on
102
+ // the card beneath the item; the line is text the labeller may read, like `context`.
103
+ const UNIT_FIELD = "unit";
104
+ const ALLOWED_FIELDS: readonly string[] = [
105
+ ...PAGE_FIELDS,
106
+ RULES_FIELD,
107
+ UNIT_FIELD,
108
+ ...PRIVATE_FIELDS,
109
+ ];
98
110
 
99
111
  const FAMILIARITY = [
100
112
  { value: "well", label: "Well" },
@@ -180,7 +192,8 @@ const mapping = (
180
192
  const obj = (
181
193
  fields: Readonly<Record<string, SchemaNode>>,
182
194
  closed = true,
183
- ): SchemaNode => ({ kind: "object", required: true, closed, fields });
195
+ required = true,
196
+ ): SchemaNode => ({ kind: "object", required, closed, fields });
184
197
 
185
198
  const ITEM_SCHEMA = obj(
186
199
  {
@@ -188,6 +201,17 @@ const ITEM_SCHEMA = obj(
188
201
  ...Object.fromEntries(
189
202
  PAGE_FIELDS.map((f) => [f, string(f === "group" || f === "body")]),
190
203
  ),
204
+ [RULES_FIELD]: array(
205
+ obj({ linter: string(true, "eslint|ruff"), id: string() }),
206
+ false,
207
+ 1,
208
+ ),
209
+ [UNIT_FIELD]: obj(
210
+ // `id` names one occurrence of the line (two files may hold the same words): never shown.
211
+ { id: string(), text: string(), part: integer(), of: integer() },
212
+ true,
213
+ false,
214
+ ),
191
215
  },
192
216
  false, // an unlisted item field is the leak guard's refusal, with its own message
193
217
  );
@@ -348,9 +372,20 @@ interface Question {
348
372
  readonly short?: string;
349
373
  readonly options: readonly Option[];
350
374
  }
351
- type Item = Readonly<Record<string, string>> & {
375
+ interface Unit {
376
+ readonly id: string;
377
+ readonly text: string;
378
+ readonly part: number;
379
+ readonly of: number;
380
+ }
381
+ type Item = {
382
+ readonly [F in (typeof PAGE_FIELDS)[number]]?: string;
383
+ } & {
352
384
  readonly source_id: string;
353
385
  readonly group: string;
386
+ readonly body: string;
387
+ readonly rules?: readonly RuleRef[];
388
+ readonly unit?: Unit;
354
389
  };
355
390
  interface Practice {
356
391
  readonly title?: string;
@@ -507,6 +542,69 @@ const practiceOf = (
507
542
  cfg.practice.map((item, n) => ({ id: `P${String(n + 1)}`, ...item }));
508
543
 
509
544
  // ── the items: the leak guard, and the references to the config ───────────────────────────────
545
+ /**
546
+ * A line with several requirements is split upstream into one item per requirement, each carrying
547
+ * `unit: {text, part, of}`. Every part of a line must be an item, once, in one group (so
548
+ * `labeller_knows` keeps or drops the whole line): a lost part is a requirement nobody judged.
549
+ */
550
+ interface Part {
551
+ readonly item: Item;
552
+ readonly unit: Unit;
553
+ readonly n: number;
554
+ }
555
+ type Parts = readonly [Part, ...Part[]];
556
+ /** The items of each line, in items.json order; a line's list is never empty. */
557
+ const linesOf = (cfg: Config): ReadonlyMap<string, Parts> =>
558
+ cfg.items.reduce((byLine, item, n) => {
559
+ if (item.unit === undefined) return byLine;
560
+ const part: Part = { item, unit: item.unit, n };
561
+ const seen = byLine.get(item.unit.id);
562
+ return byLine.set(
563
+ item.unit.id,
564
+ seen ? [seen[0], ...seen.slice(1), part] : [part],
565
+ );
566
+ }, new Map<string, Parts>());
567
+ /** One part against the first part of its line: same count, in range, not repeated, same group. */
568
+ function checkPart({ item, unit, n }: Part, parts: Parts): void {
569
+ const [first] = parts;
570
+ const at = `items[${String(n)}]`;
571
+ if (unit.text !== first.unit.text)
572
+ fail(
573
+ `${at}.unit.text: ${repr(unit.text)}, but items[${String(first.n)}] of line ${repr(unit.id)} says ${repr(first.unit.text)}`,
574
+ );
575
+ if (unit.of !== first.unit.of)
576
+ fail(
577
+ `${at}.unit.of: ${String(unit.of)}, but items[${String(first.n)}].unit.of says ${String(first.unit.of)} for the same line`,
578
+ );
579
+ if (unit.part < 1 || unit.part > unit.of)
580
+ fail(
581
+ `${at}.unit.part: ${String(unit.part)} is not between 1 and ${String(unit.of)}`,
582
+ );
583
+ const earlier = parts.find((p) => p.n < n && p.unit.part === unit.part);
584
+ if (earlier !== undefined)
585
+ fail(
586
+ `${at}.unit.part: ${String(unit.part)} repeats items[${String(earlier.n)}].unit.part`,
587
+ );
588
+ if (item.group !== first.item.group)
589
+ fail(
590
+ `${at}.group: ${repr(item.group)}, but items[${String(first.n)}] of the same line is in ${repr(first.item.group)}`,
591
+ );
592
+ }
593
+ function checkUnits(cfg: Config): void {
594
+ for (const [line, parts] of linesOf(cfg)) {
595
+ parts.forEach((p) => {
596
+ checkPart(p, parts);
597
+ });
598
+ const [first] = parts;
599
+ const missing = Array.from({ length: first.unit.of }, (_, k) => k + 1).find(
600
+ (k) => !parts.some((p) => p.unit.part === k),
601
+ );
602
+ if (missing !== undefined)
603
+ fail(
604
+ `items[${String(first.n)}].unit: part ${String(missing)} of ${String(first.unit.of)} of line ${repr(line)} is not among the items`,
605
+ );
606
+ }
607
+ }
510
608
  function checkItemFields(cfg: Config): void {
511
609
  const sids: string[] = [];
512
610
  cfg.items.forEach((item, n) => {
@@ -529,6 +627,11 @@ function checkItemFields(cfg: Config): void {
529
627
  );
530
628
  });
531
629
  }
630
+ /** Every text of an item the labeller sees, by field: the page fields and the line of a unit. */
631
+ const visibleTexts = (item: Item): readonly (readonly [string, string])[] => [
632
+ ...PAGE_FIELDS.map((f): readonly [string, string] => [f, item[f] ?? ""]),
633
+ [UNIT_FIELD, item.unit?.text ?? ""],
634
+ ];
532
635
  const escapeRegExp = (s: string): string =>
533
636
  s.replace(/[\\^$.*+?()[\]{}|/]/g, "\\$&");
534
637
  /**
@@ -545,7 +648,7 @@ function checkNoSourceShown(cfg: Config): void {
545
648
  `(?<![\\p{L}\\p{N}_-])${escapeRegExp(sid)}(?![\\p{L}\\p{N}_-])`,
546
649
  "u",
547
650
  );
548
- const field = PAGE_FIELDS.find((f) => word.test(item[f] ?? ""));
651
+ const field = visibleTexts(item).find(([, text]) => word.test(text))?.[0];
549
652
  if (field !== undefined) {
550
653
  fail(
551
654
  `the source id ${repr(sid)} appears in the visible \`${field}\` of item ${item.source_id}. Take it out of the text (or use source ids that are not natural words).`,
@@ -612,13 +715,55 @@ function assignIds(
612
715
  }
613
716
 
614
717
  // ── a build, short of the page ──────────────────────────────────────────────────────────────────
615
- const shownFields = (item: Item): Readonly<Record<string, string>> =>
616
- Object.fromEntries(
718
+ /** A unit as the page shows it: the part and the line, without the line's private id. */
719
+ type ShownUnit = Omit<Unit, "id">;
720
+ const shownFields = (
721
+ item: Item,
722
+ ): Readonly<Record<string, string | ShownUnit>> => ({
723
+ ...Object.fromEntries(
617
724
  PAGE_FIELDS.flatMap((f) => {
618
725
  const v = item[f];
619
726
  return v === undefined ? [] : [[f, v]];
620
727
  }),
728
+ ),
729
+ ...(item.unit === undefined
730
+ ? {}
731
+ : {
732
+ [UNIT_FIELD]: {
733
+ text: item.unit.text,
734
+ part: item.unit.part,
735
+ of: item.unit.of,
736
+ },
737
+ }),
738
+ });
739
+ /** Rule → the linter's description, for every rule an item on the page names. */
740
+ type Described = ReadonlyMap<string, string>;
741
+ const ruleKey = (r: RuleRef): string => `${r.linter} ${r.id}`;
742
+ /** Ask the linter for every rule named; refuse, by path, a rule it cannot describe. */
743
+ function describeRules(cfg: Config, fromDir: string): Described {
744
+ return new Map(
745
+ cfg.items.flatMap((item, n) =>
746
+ (item.rules ?? []).map((r, k): readonly [string, string] => {
747
+ const text = describeRule(r, fromDir);
748
+ return text === undefined
749
+ ? fail(
750
+ `items[${String(n)}].rules[${String(k)}]: ${r.linter} ${repr(r.id)} has no description from the linter (${r.linter === "ruff" ? "`ruff rule` does not know it, or ruff is not on PATH" : "not a rule of the ESLint, or of a plugin, installed where items.json is"}) — check the id`,
751
+ )
752
+ : [ruleKey(r), text];
753
+ }),
754
+ ),
621
755
  );
756
+ }
757
+ const rulesShown = (item: Item, described: Described) =>
758
+ item.rules === undefined
759
+ ? {}
760
+ : {
761
+ [RULES_FIELD]: item.rules.map((r) => ({
762
+ linter: r.linter,
763
+ id: r.id,
764
+ description: String(described.get(ruleKey(r))),
765
+ })),
766
+ };
622
767
 
623
768
  /** Only what the labeller can answer goes on the page, and only that gets an id, in id order. */
624
769
  function itemsOnPage(cfg: Config, keyPath: string) {
@@ -636,7 +781,11 @@ function itemsOnPage(cfg: Config, keyPath: string) {
636
781
  const practiceRound = (cfg: Config): number => cfg.practice_round ?? 1;
637
782
 
638
783
  /** What a page shows: the questions, the practice and the items, blind, with their neutral ids. */
639
- function payloadOf(cfg: Config, page: ReturnType<typeof itemsOnPage>) {
784
+ function payloadOf(
785
+ cfg: Config,
786
+ page: ReturnType<typeof itemsOnPage>,
787
+ described: Described,
788
+ ) {
640
789
  const used = Object.keys(cfg.groups).filter((g) =>
641
790
  page.items.some((i) => i.group === g),
642
791
  );
@@ -651,7 +800,11 @@ function payloadOf(cfg: Config, page: ReturnType<typeof itemsOnPage>) {
651
800
  allowed: allowedValues(cfg.questions, asksFamiliarity(cfg)),
652
801
  questions: cfg.questions,
653
802
  practice: { round: practiceRound(cfg), items: practiceOf(cfg) },
654
- items: page.items.map((i) => ({ id: page.idOf(i), ...shownFields(i) })),
803
+ items: page.items.map((i) => ({
804
+ id: page.idOf(i),
805
+ ...shownFields(i),
806
+ ...rulesShown(i, described),
807
+ })),
655
808
  };
656
809
  }
657
810
  export type Payload = ReturnType<typeof payloadOf>;
@@ -729,14 +882,16 @@ export function prepareBuild(configPath: string, outDir: string) {
729
882
  assertConfig(cfg);
730
883
  checkConfig(cfg);
731
884
  checkItemFields(cfg);
885
+ checkUnits(cfg);
732
886
  checkNoSourceShown(cfg);
887
+ const described = describeRules(cfg, dirname(configPath));
733
888
  mkdirSync(outDir, { recursive: true });
734
889
  const keyPath = join(outDir, "key.json");
735
890
  const earlier = roundsOf(keyPath);
736
891
  const page = itemsOnPage(cfg, keyPath);
737
892
  writeJson(keyPath, keyOf(cfg, page, earlier));
738
893
  return {
739
- payload: payloadOf(cfg, page),
894
+ payload: payloadOf(cfg, page, described),
740
895
  dropped: cfg.items.length - page.items.length,
741
896
  };
742
897
  }
@@ -412,6 +412,32 @@ test("🔴 without the store the page is read-only, and the real items stay read
412
412
  assert.match(shown, /G001/, "a real item is shown read-only");
413
413
  });
414
414
 
415
+ test("🔴 an item that names a rule: its card says which rule and what it does, in the linter's words", async () => {
416
+ const { page } = build(
417
+ setup(
418
+ "rules-card",
419
+ config({
420
+ items: ITEMS.map((item, n) =>
421
+ n === 0
422
+ ? { ...item, rules: [{ linter: "eslint", id: "no-console" }] }
423
+ : item,
424
+ ),
425
+ }),
426
+ ),
427
+ );
428
+ const shown = await showPage(page, {}, {});
429
+ assert.match(
430
+ shown,
431
+ /Rule eslint no-console: Disallow the use of \nconsole\n/,
432
+ shown,
433
+ );
434
+ assert.equal(
435
+ occurrences(shown, "Rule eslint"),
436
+ 1,
437
+ "only the item that names it",
438
+ );
439
+ });
440
+
415
441
  test("🔴 familiarity off: no group asks how well it is known, and a group is done when its items are", async () => {
416
442
  const { page, key } = build(
417
443
  setup("familiarity-off-page", approval({ familiarity: false })),
@@ -433,3 +459,41 @@ test("🔴 familiarity off: no group asks how well it is known, and a group is d
433
459
  assert.doesNotMatch(shown, /how well you know it/);
434
460
  assert.match(shown, /Nothing left/, shown);
435
461
  });
462
+
463
+ test("🔴 one requirement of a line: its card shows which part it is and the whole line beneath", async () => {
464
+ const line = "TypeScript only (no JavaScript); top-level await is allowed.";
465
+ const { page } = build(
466
+ setup(
467
+ "unit-card",
468
+ config({
469
+ items: ITEMS.map((item, n) =>
470
+ n === 0
471
+ ? {
472
+ ...item,
473
+ body: "TypeScript only (no JavaScript)",
474
+ unit: { id: "L1", text: line, part: 1, of: 2 },
475
+ }
476
+ : n === 3
477
+ ? {
478
+ ...item,
479
+ body: "top-level await is allowed",
480
+ unit: { id: "L1", text: line, part: 2, of: 2 },
481
+ }
482
+ : item,
483
+ ),
484
+ }),
485
+ ),
486
+ );
487
+ const shown = await showPage(page, {}, {});
488
+ assert.match(
489
+ shown,
490
+ /From the line \(requirement 1 of 2\):\nTypeScript only \(no JavaScript\); top-level await is allowed\./,
491
+ shown,
492
+ );
493
+ assert.match(shown, /From the line \(requirement 2 of 2\):/);
494
+ assert.equal(
495
+ occurrences(shown, "From the line"),
496
+ 2,
497
+ "only the two parts carry the line",
498
+ );
499
+ });
@@ -53,7 +53,14 @@
53
53
  * "practice": [{"body": "…", "expected": {"label": "yes"}, "why": "one line"}, …], 3 or more
54
54
  * "practice_round": 1, optional; raise it after rewording, the practice reopens
55
55
  * "items": [{"source_id": "case-0412-b", "group": "python", "body": "…", "code": "…",
56
- * "context": "…"}]
56
+ * "context": "…",
57
+ * "rules": [{"linter": "ruff", "id": "TID253"}],
58
+ * "unit": {"id": "a.md:12", "text": "the whole line", "part": 1, "of": 2}}]
59
+ * rules: optional; each shown with the linter's own
60
+ * 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.
57
64
  * }
58
65
  *
59
66
  * TypeScript, compiled beside itself to `label-page.js` by `npm run build`: a consumer runs the skill
@@ -53,7 +53,14 @@
53
53
  * "practice": [{"body": "…", "expected": {"label": "yes"}, "why": "one line"}, …], 3 or more
54
54
  * "practice_round": 1, optional; raise it after rewording, the practice reopens
55
55
  * "items": [{"source_id": "case-0412-b", "group": "python", "body": "…", "code": "…",
56
- * "context": "…"}]
56
+ * "context": "…",
57
+ * "rules": [{"linter": "ruff", "id": "TID253"}],
58
+ * "unit": {"id": "a.md:12", "text": "the whole line", "part": 1, "of": 2}}]
59
+ * rules: optional; each shown with the linter's own
60
+ * 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.
57
64
  * }
58
65
  *
59
66
  * TypeScript, compiled beside itself to `label-page.js` by `npm run build`: a consumer runs the skill
@@ -312,6 +312,15 @@ pre { overflow-x: auto; background: var(--surface); border: 1px solid var(--line
312
312
  card.appendChild(rich("p", "body", item.body));
313
313
  if (item.code) card.appendChild(codeBlock(item.code));
314
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
+ }
315
324
  var a = answersOf(item.id);
316
325
  askOne(card, a, state.editing[item.id], function (q, v) {
317
326
  delete state.editing[item.id];
@@ -0,0 +1,82 @@
1
+ /**
2
+ * What a linter rule does, in the linter's own words, for the card of an item that names one
3
+ * ("read as ruff `TID253`" means nothing to a labeller who has not met the code). Never invented: a
4
+ * rule the linter cannot describe has no description, and the build refuses it.
5
+ *
6
+ * - ruff: `ruff rule <code> --output-format json`, the paragraph under "## What it does".
7
+ * - ESLint: the rule's `meta.docs.description`, from the ESLint (and the plugin) installed where
8
+ * items.json is: a study's own install, found by Node's resolution from that folder upwards.
9
+ * 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
+ */
12
+ import { spawnSync } from "node:child_process";
13
+ import { createRequire } from "node:module";
14
+ import { resolve } from "node:path";
15
+ import { z } from "zod";
16
+ export const runProcess = (cmd, args) => {
17
+ const r = spawnSync(cmd, args, { encoding: "utf8" });
18
+ return { status: r.status, stdout: r.stdout };
19
+ };
20
+ const RuffRule = z.object({ explanation: z.string() });
21
+ /** The paragraph under "## What it does", its lines joined. */
22
+ function ruffDescription(id, run) {
23
+ const r = run("ruff", ["rule", id, "--output-format", "json"]);
24
+ if (r.status !== 0)
25
+ return undefined;
26
+ const { explanation } = RuffRule.parse(JSON.parse(r.stdout));
27
+ const what = /## What it does\n([\s\S]*?)(?:\n\n|$)/.exec(explanation)?.[1];
28
+ return what?.split("\n").join(" ").trim();
29
+ }
30
+ const RuleModule = z.object({
31
+ meta: z.object({ docs: z.object({ description: z.string() }) }),
32
+ });
33
+ const Plugin = z.object({ rules: z.record(z.string(), z.unknown()) });
34
+ const Builtins = z.object({
35
+ builtinRules: z.map(z.string(), z.unknown()),
36
+ });
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];
50
+ }
51
+ // `require` of an ES-module plugin returns its namespace: the plugin object is then its `default`.
52
+ const EsmPlugin = z.object({ default: Plugin });
53
+ const pluginRules = (mod) => {
54
+ const esm = EsmPlugin.safeParse(mod);
55
+ return esm.success ? esm.data.default.rules : Plugin.parse(mod).rules;
56
+ };
57
+ const description = (rule) => {
58
+ const r = RuleModule.safeParse(rule);
59
+ return r.success ? r.data.meta.docs.description : undefined;
60
+ };
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"));
64
+ 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
+ }
72
+ catch {
73
+ // ESLint or the plugin is not installed where items.json is: the linter cannot answer.
74
+ return undefined;
75
+ }
76
+ }
77
+ /** The linter's own one-line description of `ref`, or undefined when it has none. */
78
+ export function describeRule(ref, fromDir, run = runProcess) {
79
+ return ref.linter === "ruff"
80
+ ? ruffDescription(ref.id, run)
81
+ : eslintDescription(ref.id, fromDir);
82
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * What a rule does, in the linter's own words. ESLint against the copy installed in this repository
3
+ * (a real catalogue, no network); ruff through a stand-in process holding what `ruff 0.16.9 rule TID253
4
+ * --output-format json` printed (fixtures/ruff-rule-TID253.json), so no test needs the ruff binary.
5
+ */
6
+ import assert from "node:assert/strict";
7
+ import {
8
+ mkdirSync,
9
+ mkdtempSync,
10
+ readFileSync,
11
+ realpathSync,
12
+ writeFileSync,
13
+ } from "node:fs";
14
+ import { tmpdir } from "node:os";
15
+ import { join, relative } from "node:path";
16
+ import { test } from "vitest";
17
+ import {
18
+ describeRule,
19
+ runProcess,
20
+ type RunProcess,
21
+ } from "./rule-descriptions.ts";
22
+
23
+ const REPO = join(import.meta.dirname, "..", "..", "..");
24
+ const TID253 = readFileSync(
25
+ join(import.meta.dirname, "fixtures", "ruff-rule-TID253.json"),
26
+ "utf8",
27
+ );
28
+ /** `ruff rule <code>` as the binary answers: the recorded JSON for TID253, exit 2 for anything else. */
29
+ const fakeRuff: RunProcess = (cmd, args) =>
30
+ cmd === "ruff" && args[1] === "TID253"
31
+ ? { status: 0, stdout: TID253 }
32
+ : { status: 2, stdout: "" };
33
+
34
+ test("🔴 a ruff rule is described by its own 'What it does' paragraph, on one line", () => {
35
+ assert.equal(
36
+ 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
+ });
40
+
41
+ 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(
43
+ describeRule({ linter: "eslint", id: "no-console" }, REPO, fakeRuff),
44
+ "Disallow the use of `console`",
45
+ );
46
+ assert.equal(
47
+ describeRule(
48
+ { linter: "eslint", id: "@typescript-eslint/no-explicit-any" },
49
+ REPO,
50
+ fakeRuff,
51
+ ),
52
+ "Disallow the `any` type",
53
+ );
54
+ });
55
+
56
+ test("a rule the linter does not know, or a plugin that is not installed has no description (never an invented one)", () => {
57
+ const none = [
58
+ describeRule({ linter: "ruff", id: "NOPE1" }, REPO, fakeRuff),
59
+ describeRule({ linter: "eslint", id: "no-such-rule" }, REPO, fakeRuff),
60
+ describeRule({ linter: "eslint", id: "nope/rule" }, REPO, fakeRuff),
61
+ describeRule({ linter: "eslint", id: "@nope/x/rule" }, REPO, fakeRuff),
62
+ // ruff answers, but without a "What it does" section
63
+ describeRule({ linter: "ruff", id: "TID253" }, REPO, () => ({
64
+ status: 0,
65
+ stdout: JSON.stringify({ explanation: "## Why is this bad?\nx" }),
66
+ })),
67
+ ];
68
+ assert.deepEqual(none, [
69
+ undefined,
70
+ undefined,
71
+ undefined,
72
+ undefined,
73
+ undefined,
74
+ ]);
75
+ });
76
+
77
+ test("the real process runner returns the exit status and what was printed (node stands in for ruff)", () => {
78
+ assert.deepEqual(
79
+ runProcess(process.execPath, [
80
+ "-e",
81
+ "process.stdout.write('x'); process.exit(3)",
82
+ ]),
83
+ { status: 3, stdout: "x" },
84
+ );
85
+ });
86
+
87
+ test("🔴 a relative folder (the documented `build items.json label-page`) is resolved before ESLint is looked up", () => {
88
+ assert.equal(
89
+ describeRule(
90
+ { linter: "eslint", id: "no-console" },
91
+ relative(process.cwd(), REPO) || ".",
92
+ fakeRuff,
93
+ ),
94
+ "Disallow the use of `console`",
95
+ );
96
+ });
97
+
98
+ test("🔴 an ES-module plugin whose rules sit under its default export is described too", () => {
99
+ const dir = realpathSync(mkdtempSync(join(tmpdir(), "esm-plugin-")));
100
+ const pkg = join(dir, "node_modules", "eslint-plugin-esmonly");
101
+ mkdirSync(pkg, { recursive: true });
102
+ writeFileSync(
103
+ join(pkg, "package.json"),
104
+ JSON.stringify({
105
+ name: "eslint-plugin-esmonly",
106
+ type: "module",
107
+ main: "index.js",
108
+ }),
109
+ );
110
+ writeFileSync(
111
+ join(pkg, "index.js"),
112
+ 'export default { rules: { r: { meta: { docs: { description: "Only via default" } } } } };\n',
113
+ );
114
+ assert.equal(
115
+ describeRule({ linter: "eslint", id: "esmonly/r" }, dir, fakeRuff),
116
+ "Only via default",
117
+ );
118
+ });
@@ -0,0 +1,98 @@
1
+ /**
2
+ * What a linter rule does, in the linter's own words, for the card of an item that names one
3
+ * ("read as ruff `TID253`" means nothing to a labeller who has not met the code). Never invented: a
4
+ * rule the linter cannot describe has no description, and the build refuses it.
5
+ *
6
+ * - ruff: `ruff rule <code> --output-format json`, the paragraph under "## What it does".
7
+ * - ESLint: the rule's `meta.docs.description`, from the ESLint (and the plugin) installed where
8
+ * items.json is: a study's own install, found by Node's resolution from that folder upwards.
9
+ * 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
+ */
12
+ import { spawnSync } from "node:child_process";
13
+ import { createRequire } from "node:module";
14
+ import { resolve } from "node:path";
15
+ import { z } from "zod";
16
+
17
+ export interface RuleRef {
18
+ readonly linter: "eslint" | "ruff";
19
+ readonly id: string;
20
+ }
21
+ /** A process run to its end: the exit status and what it printed. */
22
+ export type RunProcess = (
23
+ cmd: string,
24
+ args: readonly string[],
25
+ ) => { readonly status: number | null; readonly stdout: string };
26
+
27
+ export const runProcess: RunProcess = (cmd, args) => {
28
+ const r = spawnSync(cmd, args, { encoding: "utf8" });
29
+ return { status: r.status, stdout: r.stdout };
30
+ };
31
+
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 {
35
+ const r = run("ruff", ["rule", id, "--output-format", "json"]);
36
+ if (r.status !== 0) return undefined;
37
+ const { explanation } = RuffRule.parse(JSON.parse(r.stdout));
38
+ const what = /## What it does\n([\s\S]*?)(?:\n\n|$)/.exec(explanation)?.[1];
39
+ return what?.split("\n").join(" ").trim();
40
+ }
41
+
42
+ const RuleModule = z.object({
43
+ meta: z.object({ docs: z.object({ description: z.string() }) }),
44
+ });
45
+ const Plugin = z.object({ rules: z.record(z.string(), z.unknown()) });
46
+ const Builtins = z.object({
47
+ builtinRules: z.map(z.string(), z.unknown()),
48
+ });
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];
62
+ }
63
+ // `require` of an ES-module plugin returns its namespace: the plugin object is then its `default`.
64
+ const EsmPlugin = z.object({ default: Plugin });
65
+ const pluginRules = (mod: unknown): Readonly<Record<string, unknown>> => {
66
+ const esm = EsmPlugin.safeParse(mod);
67
+ return esm.success ? esm.data.default.rules : Plugin.parse(mod).rules;
68
+ };
69
+ const description = (rule: unknown): string | undefined => {
70
+ const r = RuleModule.safeParse(rule);
71
+ return r.success ? r.data.meta.docs.description : undefined;
72
+ };
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"));
76
+ 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]);
83
+ } catch {
84
+ // ESLint or the plugin is not installed where items.json is: the linter cannot answer.
85
+ return undefined;
86
+ }
87
+ }
88
+
89
+ /** The linter's own one-line description of `ref`, or undefined when it has none. */
90
+ export function describeRule(
91
+ ref: RuleRef,
92
+ fromDir: string,
93
+ run: RunProcess = runProcess,
94
+ ): string | undefined {
95
+ return ref.linter === "ruff"
96
+ ? ruffDescription(ref.id, run)
97
+ : eslintDescription(ref.id, fromDir);
98
+ }