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.
- package/dist/skills/build-label-page/scripts/label-page-core.d.ts +37 -6
- package/dist/skills/build-label-page/scripts/rule-descriptions.d.ts +12 -0
- package/package.json +1 -1
- package/skills/build-label-page/references/closed-choice-generator.md +15 -1
- package/skills/build-label-page/scripts/fixtures/ruff-rule-TID253.json +23 -0
- package/skills/build-label-page/scripts/label-page-core.js +103 -13
- package/skills/build-label-page/scripts/label-page-core.test.ts +179 -1
- package/skills/build-label-page/scripts/label-page-core.ts +165 -10
- package/skills/build-label-page/scripts/label-page-html.test.ts +64 -0
- package/skills/build-label-page/scripts/label-page.js +8 -1
- package/skills/build-label-page/scripts/label-page.ts +8 -1
- package/skills/build-label-page/scripts/page-template.html +9 -0
- package/skills/build-label-page/scripts/rule-descriptions.js +82 -0
- package/skills/build-label-page/scripts/rule-descriptions.test.ts +118 -0
- package/skills/build-label-page/scripts/rule-descriptions.ts +98 -0
|
@@ -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
|
-
|
|
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
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
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) => ({
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
616
|
-
|
|
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(
|
|
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) => ({
|
|
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
|
+
}
|