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