paperlint 13.5.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);
@@ -66,6 +67,7 @@ type Item = {
66
67
  readonly source_id: string;
67
68
  readonly group: string;
68
69
  readonly body: string;
70
+ readonly rules?: readonly RuleRef[];
69
71
  readonly unit?: Unit;
70
72
  };
71
73
  interface Practice {
@@ -95,6 +97,8 @@ interface KeyEntry {
95
97
  readonly group: string;
96
98
  readonly on_page: boolean;
97
99
  }
100
+ /** Rule → the linter's description, for every rule an item on the page names. */
101
+ type Described = ReadonlyMap<string, string>;
98
102
  /** Only what the labeller can answer goes on the page, and only that gets an id, in id order. */
99
103
  declare function itemsOnPage(cfg: Config, keyPath: string): {
100
104
  items: Item[];
@@ -103,7 +107,7 @@ declare function itemsOnPage(cfg: Config, keyPath: string): {
103
107
  idOf: (i: Item) => string;
104
108
  };
105
109
  /** What a page shows: the questions, the practice and the items, blind, with their neutral ids. */
106
- declare function payloadOf(cfg: Config, page: ReturnType<typeof itemsOnPage>): {
110
+ declare function payloadOf(cfg: Config, page: ReturnType<typeof itemsOnPage>, described: Described): {
107
111
  title: string;
108
112
  intro: string;
109
113
  groups: {
@@ -123,9 +127,17 @@ declare function payloadOf(cfg: Config, page: ReturnType<typeof itemsOnPage>): {
123
127
  readonly id: string;
124
128
  })[];
125
129
  };
126
- items: {
130
+ items: ({
131
+ rules?: never;
127
132
  id: string;
128
- }[];
133
+ } | {
134
+ rules: {
135
+ linter: "eslint" | "ruff";
136
+ id: string;
137
+ description: string;
138
+ }[];
139
+ id: string;
140
+ })[];
129
141
  };
130
142
  export type Payload = ReturnType<typeof payloadOf>;
131
143
  /**
@@ -154,9 +166,17 @@ export declare function prepareBuild(configPath: string, outDir: string): {
154
166
  readonly id: string;
155
167
  })[];
156
168
  };
157
- items: {
169
+ items: ({
170
+ rules?: never;
158
171
  id: string;
159
- }[];
172
+ } | {
173
+ rules: {
174
+ linter: "eslint" | "ruff";
175
+ id: string;
176
+ description: string;
177
+ }[];
178
+ id: string;
179
+ })[];
160
180
  };
161
181
  dropped: number;
162
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.5.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,14 @@ 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`, `unit`. 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`.
42
48
  - **`unit`**: `{id, text, part, of}` on an item that is one requirement of a line holding several
43
49
  ("TypeScript only (no JavaScript); top-level await is allowed" is two). Each requirement is its
44
50
  own item with its own question, and its card shows "From the line (requirement 1 of 2):" and the
@@ -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,11 +90,15 @@ 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"];
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";
92
96
  // One requirement of a line that holds several: the whole line and which part this item is. Shown on
93
97
  // the card beneath the item; the line is text the labeller may read, like `context`.
94
98
  const UNIT_FIELD = "unit";
95
99
  const ALLOWED_FIELDS = [
96
100
  ...PAGE_FIELDS,
101
+ RULES_FIELD,
97
102
  UNIT_FIELD,
98
103
  ...PRIVATE_FIELDS,
99
104
  ];
@@ -151,6 +156,7 @@ const obj = (fields, closed = true, required = true) => ({ kind: "object", requi
151
156
  const ITEM_SCHEMA = obj({
152
157
  source_id: string(),
153
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),
154
160
  [UNIT_FIELD]: obj(
155
161
  // `id` names one occurrence of the line (two files may hold the same words): never shown.
156
162
  { id: string(), text: string(), part: integer(), of: integer() }, true, false),
@@ -490,6 +496,25 @@ const shownFields = (item) => ({
490
496
  },
491
497
  }),
492
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
+ };
493
518
  /** Only what the labeller can answer goes on the page, and only that gets an id, in id order. */
494
519
  function itemsOnPage(cfg, keyPath) {
495
520
  const knows = cfg.labeller_knows;
@@ -504,7 +529,7 @@ function itemsOnPage(cfg, keyPath) {
504
529
  }
505
530
  const practiceRound = (cfg) => cfg.practice_round ?? 1;
506
531
  /** What a page shows: the questions, the practice and the items, blind, with their neutral ids. */
507
- function payloadOf(cfg, page) {
532
+ function payloadOf(cfg, page, described) {
508
533
  const used = Object.keys(cfg.groups).filter((g) => page.items.some((i) => i.group === g));
509
534
  return {
510
535
  title: cfg.title,
@@ -517,7 +542,11 @@ function payloadOf(cfg, page) {
517
542
  allowed: allowedValues(cfg.questions, asksFamiliarity(cfg)),
518
543
  questions: cfg.questions,
519
544
  practice: { round: practiceRound(cfg), items: practiceOf(cfg) },
520
- 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
+ })),
521
550
  };
522
551
  }
523
552
  /** Every id the key keeps (on the page now, or kept from an earlier build), with its source and group. */
@@ -585,13 +614,14 @@ export function prepareBuild(configPath, outDir) {
585
614
  checkItemFields(cfg);
586
615
  checkUnits(cfg);
587
616
  checkNoSourceShown(cfg);
617
+ const described = describeRules(cfg, dirname(configPath));
588
618
  mkdirSync(outDir, { recursive: true });
589
619
  const keyPath = join(outDir, "key.json");
590
620
  const earlier = roundsOf(keyPath);
591
621
  const page = itemsOnPage(cfg, keyPath);
592
622
  writeJson(keyPath, keyOf(cfg, page, earlier));
593
623
  return {
594
- payload: payloadOf(cfg, page),
624
+ payload: payloadOf(cfg, page, described),
595
625
  dropped: cfg.items.length - page.items.length,
596
626
  };
597
627
  }
@@ -87,6 +87,7 @@ const FULL: DocObject = (() => {
87
87
  title: "T",
88
88
  code: "c = 1",
89
89
  context: "ctx",
90
+ rules: [{ linter: "eslint", id: "no-console" }],
90
91
  unit: { id: "L0", text: "One line.", part: 1, of: 1 },
91
92
  }
92
93
  : i,
@@ -1175,6 +1176,102 @@ test("🔴 a question dropped between practice rounds: the earlier round's answe
1175
1176
  assert.match(r.out, /practice round 1: 3 of 3 matched/);
1176
1177
  });
1177
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
+
1216
+ test("🔴 familiarity off: the key asks for none, the export reports no missing familiarity, and a stored one is refused", () => {
1217
+ const dir = setup("familiarity-off", approval({ familiarity: false }));
1218
+ const { key } = build(dir);
1219
+ assert.deepEqual(key.allowed.familiarity, []);
1220
+ const [a] = Object.keys(key.ids);
1221
+ assert.ok(a !== undefined);
1222
+ writeFileSync(
1223
+ join(dir, "dump.json"),
1224
+ JSON.stringify({
1225
+ answers: { [a]: { answers: { keep: "keep" }, source: "page" } },
1226
+ groups: {},
1227
+ }),
1228
+ );
1229
+ const r = exportAt(dir);
1230
+ assert.equal(r.status, 0, r.out);
1231
+ assert.doesNotMatch(r.out, /no familiarity answer|invalid group/);
1232
+ assert.deepEqual(Exported.parse(JSON.parse(labelsFile(dir))).groups, {});
1233
+ writeFileSync(
1234
+ join(dir, "dump.json"),
1235
+ JSON.stringify({
1236
+ answers: {},
1237
+ groups: { python: { familiarity: "well", source: "chat" } },
1238
+ }),
1239
+ );
1240
+ const refused = exportAt(dir);
1241
+ assert.notEqual(refused.status, 0);
1242
+ assert.match(refused.out, /groups\/python: .*familiarity is off/);
1243
+ });
1244
+
1245
+ test("🔴 familiarity off with a familiarity_question is a contradiction the build refuses", () => {
1246
+ const r = run([
1247
+ "build",
1248
+ join(
1249
+ setup(
1250
+ "familiarity-contradiction",
1251
+ approval({ familiarity: false, familiarity_question: "How well?" }),
1252
+ ),
1253
+ "items.json",
1254
+ ),
1255
+ join(WORK, "contradiction-out"),
1256
+ ]);
1257
+ assert.notEqual(r.status, 0);
1258
+ assert.match(
1259
+ r.out,
1260
+ /REFUSED: familiarity_question: .*`familiarity` is false/,
1261
+ );
1262
+ });
1263
+
1264
+ test("familiarity left out: every group is still asked (rule 5), and a missing one is reported", () => {
1265
+ const dir = setup("familiarity-default", approval());
1266
+ const { key } = build(dir);
1267
+ assert.deepEqual(key.allowed.familiarity, ["well", "somewhat", "not_really"]);
1268
+ writeFileSync(
1269
+ join(dir, "dump.json"),
1270
+ JSON.stringify({ answers: {}, groups: {} }),
1271
+ );
1272
+ assert.match(exportAt(dir).out, /group python: no familiarity answer/);
1273
+ });
1274
+
1178
1275
  /** Items 0 and 3 (both Rust) as the two requirements of one line. */
1179
1276
  const LINE = "TypeScript only (no JavaScript); top-level await is allowed.";
1180
1277
  const withUnit = (
@@ -1306,62 +1403,3 @@ test("🔴 the same line in two places is two lines: four items, two ids, it bui
1306
1403
  const { page } = build(dir);
1307
1404
  assert.ok(!page.includes("a.md:3"), "the line's id is private");
1308
1405
  });
1309
-
1310
- test("🔴 familiarity off: the key asks for none, the export reports no missing familiarity, and a stored one is refused", () => {
1311
- const dir = setup("familiarity-off", approval({ familiarity: false }));
1312
- const { key } = build(dir);
1313
- assert.deepEqual(key.allowed.familiarity, []);
1314
- const [a] = Object.keys(key.ids);
1315
- assert.ok(a !== undefined);
1316
- writeFileSync(
1317
- join(dir, "dump.json"),
1318
- JSON.stringify({
1319
- answers: { [a]: { answers: { keep: "keep" }, source: "page" } },
1320
- groups: {},
1321
- }),
1322
- );
1323
- const r = exportAt(dir);
1324
- assert.equal(r.status, 0, r.out);
1325
- assert.doesNotMatch(r.out, /no familiarity answer|invalid group/);
1326
- assert.deepEqual(Exported.parse(JSON.parse(labelsFile(dir))).groups, {});
1327
- writeFileSync(
1328
- join(dir, "dump.json"),
1329
- JSON.stringify({
1330
- answers: {},
1331
- groups: { python: { familiarity: "well", source: "chat" } },
1332
- }),
1333
- );
1334
- const refused = exportAt(dir);
1335
- assert.notEqual(refused.status, 0);
1336
- assert.match(refused.out, /groups\/python: .*familiarity is off/);
1337
- });
1338
-
1339
- test("🔴 familiarity off with a familiarity_question is a contradiction the build refuses", () => {
1340
- const r = run([
1341
- "build",
1342
- join(
1343
- setup(
1344
- "familiarity-contradiction",
1345
- approval({ familiarity: false, familiarity_question: "How well?" }),
1346
- ),
1347
- "items.json",
1348
- ),
1349
- join(WORK, "contradiction-out"),
1350
- ]);
1351
- assert.notEqual(r.status, 0);
1352
- assert.match(
1353
- r.out,
1354
- /REFUSED: familiarity_question: .*`familiarity` is false/,
1355
- );
1356
- });
1357
-
1358
- test("familiarity left out: every group is still asked (rule 5), and a missing one is reported", () => {
1359
- const dir = setup("familiarity-default", approval());
1360
- const { key } = build(dir);
1361
- assert.deepEqual(key.allowed.familiarity, ["well", "somewhat", "not_really"]);
1362
- writeFileSync(
1363
- join(dir, "dump.json"),
1364
- JSON.stringify({ answers: {}, groups: {} }),
1365
- );
1366
- assert.match(exportAt(dir).out, /group python: no familiarity answer/);
1367
- });
@@ -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,11 +95,15 @@ 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;
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";
97
101
  // One requirement of a line that holds several: the whole line and which part this item is. Shown on
98
102
  // the card beneath the item; the line is text the labeller may read, like `context`.
99
103
  const UNIT_FIELD = "unit";
100
104
  const ALLOWED_FIELDS: readonly string[] = [
101
105
  ...PAGE_FIELDS,
106
+ RULES_FIELD,
102
107
  UNIT_FIELD,
103
108
  ...PRIVATE_FIELDS,
104
109
  ];
@@ -196,6 +201,11 @@ const ITEM_SCHEMA = obj(
196
201
  ...Object.fromEntries(
197
202
  PAGE_FIELDS.map((f) => [f, string(f === "group" || f === "body")]),
198
203
  ),
204
+ [RULES_FIELD]: array(
205
+ obj({ linter: string(true, "eslint|ruff"), id: string() }),
206
+ false,
207
+ 1,
208
+ ),
199
209
  [UNIT_FIELD]: obj(
200
210
  // `id` names one occurrence of the line (two files may hold the same words): never shown.
201
211
  { id: string(), text: string(), part: integer(), of: integer() },
@@ -374,6 +384,7 @@ type Item = {
374
384
  readonly source_id: string;
375
385
  readonly group: string;
376
386
  readonly body: string;
387
+ readonly rules?: readonly RuleRef[];
377
388
  readonly unit?: Unit;
378
389
  };
379
390
  interface Practice {
@@ -725,6 +736,34 @@ const shownFields = (
725
736
  },
726
737
  }),
727
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
+ ),
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
+ };
728
767
 
729
768
  /** Only what the labeller can answer goes on the page, and only that gets an id, in id order. */
730
769
  function itemsOnPage(cfg: Config, keyPath: string) {
@@ -742,7 +781,11 @@ function itemsOnPage(cfg: Config, keyPath: string) {
742
781
  const practiceRound = (cfg: Config): number => cfg.practice_round ?? 1;
743
782
 
744
783
  /** What a page shows: the questions, the practice and the items, blind, with their neutral ids. */
745
- function payloadOf(cfg: Config, page: ReturnType<typeof itemsOnPage>) {
784
+ function payloadOf(
785
+ cfg: Config,
786
+ page: ReturnType<typeof itemsOnPage>,
787
+ described: Described,
788
+ ) {
746
789
  const used = Object.keys(cfg.groups).filter((g) =>
747
790
  page.items.some((i) => i.group === g),
748
791
  );
@@ -757,7 +800,11 @@ function payloadOf(cfg: Config, page: ReturnType<typeof itemsOnPage>) {
757
800
  allowed: allowedValues(cfg.questions, asksFamiliarity(cfg)),
758
801
  questions: cfg.questions,
759
802
  practice: { round: practiceRound(cfg), items: practiceOf(cfg) },
760
- 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
+ })),
761
808
  };
762
809
  }
763
810
  export type Payload = ReturnType<typeof payloadOf>;
@@ -837,13 +884,14 @@ export function prepareBuild(configPath: string, outDir: string) {
837
884
  checkItemFields(cfg);
838
885
  checkUnits(cfg);
839
886
  checkNoSourceShown(cfg);
887
+ const described = describeRules(cfg, dirname(configPath));
840
888
  mkdirSync(outDir, { recursive: true });
841
889
  const keyPath = join(outDir, "key.json");
842
890
  const earlier = roundsOf(keyPath);
843
891
  const page = itemsOnPage(cfg, keyPath);
844
892
  writeJson(keyPath, keyOf(cfg, page, earlier));
845
893
  return {
846
- payload: payloadOf(cfg, page),
894
+ payload: payloadOf(cfg, page, described),
847
895
  dropped: cfg.items.length - page.items.length,
848
896
  };
849
897
  }
@@ -412,26 +412,15 @@ 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("🔴 one requirement of a line: its card shows which part it is and the whole line beneath", async () => {
416
- const line = "TypeScript only (no JavaScript); top-level await is allowed.";
415
+ test("🔴 an item that names a rule: its card says which rule and what it does, in the linter's words", async () => {
417
416
  const { page } = build(
418
417
  setup(
419
- "unit-card",
418
+ "rules-card",
420
419
  config({
421
420
  items: ITEMS.map((item, n) =>
422
421
  n === 0
423
- ? {
424
- ...item,
425
- body: "TypeScript only (no JavaScript)",
426
- unit: { id: "L1", text: line, part: 1, of: 2 },
427
- }
428
- : n === 3
429
- ? {
430
- ...item,
431
- body: "top-level await is allowed",
432
- unit: { id: "L1", text: line, part: 2, of: 2 },
433
- }
434
- : item,
422
+ ? { ...item, rules: [{ linter: "eslint", id: "no-console" }] }
423
+ : item,
435
424
  ),
436
425
  }),
437
426
  ),
@@ -439,14 +428,13 @@ test("🔴 one requirement of a line: its card shows which part it is and the wh
439
428
  const shown = await showPage(page, {}, {});
440
429
  assert.match(
441
430
  shown,
442
- /From the line \(requirement 1 of 2\):\nTypeScript only \(no JavaScript\); top-level await is allowed\./,
431
+ /Rule eslint no-console: Disallow the use of \nconsole\n/,
443
432
  shown,
444
433
  );
445
- assert.match(shown, /From the line \(requirement 2 of 2\):/);
446
434
  assert.equal(
447
- occurrences(shown, "From the line"),
448
- 2,
449
- "only the two parts carry the line",
435
+ occurrences(shown, "Rule eslint"),
436
+ 1,
437
+ "only the item that names it",
450
438
  );
451
439
  });
452
440
 
@@ -471,3 +459,41 @@ test("🔴 familiarity off: no group asks how well it is known, and a group is d
471
459
  assert.doesNotMatch(shown, /how well you know it/);
472
460
  assert.match(shown, /Nothing left/, shown);
473
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
+ });
@@ -54,7 +54,10 @@
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
56
  * "context": "…",
57
+ * "rules": [{"linter": "ruff", "id": "TID253"}],
57
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)
58
61
  * unit: optional; one requirement of a line that holds
59
62
  * several. Every part of the line is an item, in one group;
60
63
  * id (never shown) tells two places with the same words apart.
@@ -54,7 +54,10 @@
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
56
  * "context": "…",
57
+ * "rules": [{"linter": "ruff", "id": "TID253"}],
57
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)
58
61
  * unit: optional; one requirement of a line that holds
59
62
  * several. Every part of the line is an item, in one group;
60
63
  * id (never shown) tells two places with the same words apart.
@@ -312,6 +312,10 @@ 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
+ });
315
319
  // One requirement of a line that holds several: which one, and the whole line it came from.
316
320
  if (item.unit) {
317
321
  card.appendChild(el("p", "muted", "From the line (requirement " + item.unit.part + " of " + item.unit.of + "):"));
@@ -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
+ }