paperlint 13.5.0 → 13.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/skills/build-label-page/scripts/label-page-core.d.ts +25 -5
- 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 +7 -1
- package/skills/build-label-page/scripts/fixtures/ruff-rule-TID253.json +23 -0
- package/skills/build-label-page/scripts/label-page-core.js +34 -4
- package/skills/build-label-page/scripts/label-page-core.test.ts +97 -59
- package/skills/build-label-page/scripts/label-page-core.ts +52 -4
- package/skills/build-label-page/scripts/label-page-html.test.ts +46 -20
- package/skills/build-label-page/scripts/label-page.js +3 -0
- package/skills/build-label-page/scripts/label-page.ts +3 -0
- package/skills/build-label-page/scripts/page-template.html +4 -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);
|
|
@@ -66,6 +67,7 @@ type Item = {
|
|
|
66
67
|
readonly source_id: string;
|
|
67
68
|
readonly group: string;
|
|
68
69
|
readonly body: string;
|
|
70
|
+
readonly rules?: readonly RuleRef[];
|
|
69
71
|
readonly unit?: Unit;
|
|
70
72
|
};
|
|
71
73
|
interface Practice {
|
|
@@ -95,6 +97,8 @@ interface KeyEntry {
|
|
|
95
97
|
readonly group: string;
|
|
96
98
|
readonly on_page: boolean;
|
|
97
99
|
}
|
|
100
|
+
/** Rule → the linter's description, for every rule an item on the page names. */
|
|
101
|
+
type Described = ReadonlyMap<string, string>;
|
|
98
102
|
/** Only what the labeller can answer goes on the page, and only that gets an id, in id order. */
|
|
99
103
|
declare function itemsOnPage(cfg: Config, keyPath: string): {
|
|
100
104
|
items: Item[];
|
|
@@ -103,7 +107,7 @@ declare function itemsOnPage(cfg: Config, keyPath: string): {
|
|
|
103
107
|
idOf: (i: Item) => string;
|
|
104
108
|
};
|
|
105
109
|
/** What a page shows: the questions, the practice and the items, blind, with their neutral ids. */
|
|
106
|
-
declare function payloadOf(cfg: Config, page: ReturnType<typeof itemsOnPage
|
|
110
|
+
declare function payloadOf(cfg: Config, page: ReturnType<typeof itemsOnPage>, described: Described): {
|
|
107
111
|
title: string;
|
|
108
112
|
intro: string;
|
|
109
113
|
groups: {
|
|
@@ -123,9 +127,17 @@ declare function payloadOf(cfg: Config, page: ReturnType<typeof itemsOnPage>): {
|
|
|
123
127
|
readonly id: string;
|
|
124
128
|
})[];
|
|
125
129
|
};
|
|
126
|
-
items: {
|
|
130
|
+
items: ({
|
|
131
|
+
rules?: never;
|
|
127
132
|
id: string;
|
|
128
|
-
}
|
|
133
|
+
} | {
|
|
134
|
+
rules: {
|
|
135
|
+
linter: "eslint" | "ruff";
|
|
136
|
+
id: string;
|
|
137
|
+
description: string;
|
|
138
|
+
}[];
|
|
139
|
+
id: string;
|
|
140
|
+
})[];
|
|
129
141
|
};
|
|
130
142
|
export type Payload = ReturnType<typeof payloadOf>;
|
|
131
143
|
/**
|
|
@@ -154,9 +166,17 @@ export declare function prepareBuild(configPath: string, outDir: string): {
|
|
|
154
166
|
readonly id: string;
|
|
155
167
|
})[];
|
|
156
168
|
};
|
|
157
|
-
items: {
|
|
169
|
+
items: ({
|
|
170
|
+
rules?: never;
|
|
158
171
|
id: string;
|
|
159
|
-
}
|
|
172
|
+
} | {
|
|
173
|
+
rules: {
|
|
174
|
+
linter: "eslint" | "ruff";
|
|
175
|
+
id: string;
|
|
176
|
+
description: string;
|
|
177
|
+
}[];
|
|
178
|
+
id: string;
|
|
179
|
+
})[];
|
|
160
180
|
};
|
|
161
181
|
dropped: number;
|
|
162
182
|
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export interface RuleRef {
|
|
2
|
+
readonly linter: "eslint" | "ruff";
|
|
3
|
+
readonly id: string;
|
|
4
|
+
}
|
|
5
|
+
/** A process run to its end: the exit status and what it printed. */
|
|
6
|
+
export type RunProcess = (cmd: string, args: readonly string[]) => {
|
|
7
|
+
readonly status: number | null;
|
|
8
|
+
readonly stdout: string;
|
|
9
|
+
};
|
|
10
|
+
export declare const runProcess: RunProcess;
|
|
11
|
+
/** The linter's own one-line description of `ref`, or undefined when it has none. */
|
|
12
|
+
export declare function describeRule(ref: RuleRef, fromDir: string, run?: RunProcess): string | undefined;
|
package/package.json
CHANGED
|
@@ -37,8 +37,14 @@ length.
|
|
|
37
37
|
- **`practice_round`**: raise it after rewording a question; the practice reopens and the answers of
|
|
38
38
|
the earlier round are kept but not counted.
|
|
39
39
|
- **`items`**: `source_id` (the study's own, never shown), `group`, `body`, and optionally `title`,
|
|
40
|
-
`code`, `context`, `unit`. Nothing else: the allow-list is fixed in the generator, and a field outside it
|
|
40
|
+
`code`, `context`, `rules`, `unit`. Nothing else: the allow-list is fixed in the generator, and a field outside it
|
|
41
41
|
is taken out of the item, not added to the list to make a build pass.
|
|
42
|
+
- **`rules`**: `[{linter, id}]` (`linter` is `eslint` or `ruff`) on an item read as a linter rule.
|
|
43
|
+
The card shows "Rule ruff TID253: Checks for module-level imports that should instead be imported
|
|
44
|
+
lazily…", the description taken at build time from the linter itself: `ruff rule <id>`'s "What it
|
|
45
|
+
does" paragraph, or the ESLint rule's `meta.docs.description` from the ESLint and plugins
|
|
46
|
+
installed where `items.json` is. The build refuses a rule the linter cannot describe; a
|
|
47
|
+
description is never typed into `items.json`.
|
|
42
48
|
- **`unit`**: `{id, text, part, of}` on an item that is one requirement of a line holding several
|
|
43
49
|
("TypeScript only (no JavaScript); top-level await is allowed" is two). Each requirement is its
|
|
44
50
|
own item with its own question, and its card shows "From the line (requirement 1 of 2):" and the
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "banned-module-level-imports",
|
|
3
|
+
"code": "TID253",
|
|
4
|
+
"linter": "flake8-tidy-imports",
|
|
5
|
+
"summary": "`{name}` is banned at the module level",
|
|
6
|
+
"message_formats": [
|
|
7
|
+
"`{name}` is banned at the module level"
|
|
8
|
+
],
|
|
9
|
+
"fix": "Fix is not available.",
|
|
10
|
+
"fix_availability": "None",
|
|
11
|
+
"explanation": "## What it does\nChecks for module-level imports that should instead be imported lazily\n(e.g., within a function definition, or an `if TYPE_CHECKING:` block, or\nsome other nested context).\n\n## Why is this bad?\nSome modules are expensive to import. For example, importing `torch` or\n`tensorflow` can introduce a noticeable delay in the startup time of a\nPython program.\n\nIn such cases, you may want to enforce that the module is imported lazily\nas needed, rather than at the top of the file. This could involve inlining\nthe import into the function that uses it, rather than importing it\nunconditionally, to ensure that the module is only imported when necessary.\n\n## Example\n```python\nimport tensorflow as tf\n\n\ndef show_version():\n print(tf.__version__)\n```\n\nUse instead:\n```python\ndef show_version():\n import tensorflow as tf\n\n print(tf.__version__)\n```\n\n## Options\n- `lint.flake8-tidy-imports.banned-module-level-imports`\n",
|
|
12
|
+
"preview": false,
|
|
13
|
+
"status": {
|
|
14
|
+
"Stable": {
|
|
15
|
+
"since": "v0.0.285"
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"category": "restriction",
|
|
19
|
+
"source_location": {
|
|
20
|
+
"file": "crates/ruff_linter/src/rules/flake8_tidy_imports/rules/banned_module_level_imports.rs",
|
|
21
|
+
"line": 46
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -6,8 +6,9 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { createHash } from "node:crypto";
|
|
8
8
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
9
|
-
import { join } from "node:path";
|
|
9
|
+
import { dirname, join } from "node:path";
|
|
10
10
|
import { z } from "zod";
|
|
11
|
+
import { describeRule } from "./rule-descriptions.js";
|
|
11
12
|
// ── refusals and output ───────────────────────────────────────────────────────────────────────
|
|
12
13
|
export class Refusal extends Error {
|
|
13
14
|
exitCode;
|
|
@@ -89,11 +90,15 @@ const readJson = (path) => {
|
|
|
89
90
|
// labeller what the answer is, or where the item came from?" — never to make a build pass.
|
|
90
91
|
const PAGE_FIELDS = ["group", "title", "body", "code", "context"];
|
|
91
92
|
const PRIVATE_FIELDS = ["source_id"];
|
|
93
|
+
// The linter rules an item names ("read as ruff `TID253`"): the page shows each with the linter's
|
|
94
|
+
// own description, looked up at build time (rule-descriptions.ts), never typed into items.json.
|
|
95
|
+
const RULES_FIELD = "rules";
|
|
92
96
|
// One requirement of a line that holds several: the whole line and which part this item is. Shown on
|
|
93
97
|
// the card beneath the item; the line is text the labeller may read, like `context`.
|
|
94
98
|
const UNIT_FIELD = "unit";
|
|
95
99
|
const ALLOWED_FIELDS = [
|
|
96
100
|
...PAGE_FIELDS,
|
|
101
|
+
RULES_FIELD,
|
|
97
102
|
UNIT_FIELD,
|
|
98
103
|
...PRIVATE_FIELDS,
|
|
99
104
|
];
|
|
@@ -151,6 +156,7 @@ const obj = (fields, closed = true, required = true) => ({ kind: "object", requi
|
|
|
151
156
|
const ITEM_SCHEMA = obj({
|
|
152
157
|
source_id: string(),
|
|
153
158
|
...Object.fromEntries(PAGE_FIELDS.map((f) => [f, string(f === "group" || f === "body")])),
|
|
159
|
+
[RULES_FIELD]: array(obj({ linter: string(true, "eslint|ruff"), id: string() }), false, 1),
|
|
154
160
|
[UNIT_FIELD]: obj(
|
|
155
161
|
// `id` names one occurrence of the line (two files may hold the same words): never shown.
|
|
156
162
|
{ id: string(), text: string(), part: integer(), of: integer() }, true, false),
|
|
@@ -490,6 +496,25 @@ const shownFields = (item) => ({
|
|
|
490
496
|
},
|
|
491
497
|
}),
|
|
492
498
|
});
|
|
499
|
+
const ruleKey = (r) => `${r.linter} ${r.id}`;
|
|
500
|
+
/** Ask the linter for every rule named; refuse, by path, a rule it cannot describe. */
|
|
501
|
+
function describeRules(cfg, fromDir) {
|
|
502
|
+
return new Map(cfg.items.flatMap((item, n) => (item.rules ?? []).map((r, k) => {
|
|
503
|
+
const text = describeRule(r, fromDir);
|
|
504
|
+
return text === undefined
|
|
505
|
+
? fail(`items[${String(n)}].rules[${String(k)}]: ${r.linter} ${repr(r.id)} has no description from the linter (${r.linter === "ruff" ? "`ruff rule` does not know it, or ruff is not on PATH" : "not a rule of the ESLint, or of a plugin, installed where items.json is"}) — check the id`)
|
|
506
|
+
: [ruleKey(r), text];
|
|
507
|
+
})));
|
|
508
|
+
}
|
|
509
|
+
const rulesShown = (item, described) => item.rules === undefined
|
|
510
|
+
? {}
|
|
511
|
+
: {
|
|
512
|
+
[RULES_FIELD]: item.rules.map((r) => ({
|
|
513
|
+
linter: r.linter,
|
|
514
|
+
id: r.id,
|
|
515
|
+
description: String(described.get(ruleKey(r))),
|
|
516
|
+
})),
|
|
517
|
+
};
|
|
493
518
|
/** Only what the labeller can answer goes on the page, and only that gets an id, in id order. */
|
|
494
519
|
function itemsOnPage(cfg, keyPath) {
|
|
495
520
|
const knows = cfg.labeller_knows;
|
|
@@ -504,7 +529,7 @@ function itemsOnPage(cfg, keyPath) {
|
|
|
504
529
|
}
|
|
505
530
|
const practiceRound = (cfg) => cfg.practice_round ?? 1;
|
|
506
531
|
/** What a page shows: the questions, the practice and the items, blind, with their neutral ids. */
|
|
507
|
-
function payloadOf(cfg, page) {
|
|
532
|
+
function payloadOf(cfg, page, described) {
|
|
508
533
|
const used = Object.keys(cfg.groups).filter((g) => page.items.some((i) => i.group === g));
|
|
509
534
|
return {
|
|
510
535
|
title: cfg.title,
|
|
@@ -517,7 +542,11 @@ function payloadOf(cfg, page) {
|
|
|
517
542
|
allowed: allowedValues(cfg.questions, asksFamiliarity(cfg)),
|
|
518
543
|
questions: cfg.questions,
|
|
519
544
|
practice: { round: practiceRound(cfg), items: practiceOf(cfg) },
|
|
520
|
-
items: page.items.map((i) => ({
|
|
545
|
+
items: page.items.map((i) => ({
|
|
546
|
+
id: page.idOf(i),
|
|
547
|
+
...shownFields(i),
|
|
548
|
+
...rulesShown(i, described),
|
|
549
|
+
})),
|
|
521
550
|
};
|
|
522
551
|
}
|
|
523
552
|
/** Every id the key keeps (on the page now, or kept from an earlier build), with its source and group. */
|
|
@@ -585,13 +614,14 @@ export function prepareBuild(configPath, outDir) {
|
|
|
585
614
|
checkItemFields(cfg);
|
|
586
615
|
checkUnits(cfg);
|
|
587
616
|
checkNoSourceShown(cfg);
|
|
617
|
+
const described = describeRules(cfg, dirname(configPath));
|
|
588
618
|
mkdirSync(outDir, { recursive: true });
|
|
589
619
|
const keyPath = join(outDir, "key.json");
|
|
590
620
|
const earlier = roundsOf(keyPath);
|
|
591
621
|
const page = itemsOnPage(cfg, keyPath);
|
|
592
622
|
writeJson(keyPath, keyOf(cfg, page, earlier));
|
|
593
623
|
return {
|
|
594
|
-
payload: payloadOf(cfg, page),
|
|
624
|
+
payload: payloadOf(cfg, page, described),
|
|
595
625
|
dropped: cfg.items.length - page.items.length,
|
|
596
626
|
};
|
|
597
627
|
}
|
|
@@ -87,6 +87,7 @@ const FULL: DocObject = (() => {
|
|
|
87
87
|
title: "T",
|
|
88
88
|
code: "c = 1",
|
|
89
89
|
context: "ctx",
|
|
90
|
+
rules: [{ linter: "eslint", id: "no-console" }],
|
|
90
91
|
unit: { id: "L0", text: "One line.", part: 1, of: 1 },
|
|
91
92
|
}
|
|
92
93
|
: i,
|
|
@@ -1175,6 +1176,102 @@ test("🔴 a question dropped between practice rounds: the earlier round's answe
|
|
|
1175
1176
|
assert.match(r.out, /practice round 1: 3 of 3 matched/);
|
|
1176
1177
|
});
|
|
1177
1178
|
|
|
1179
|
+
test("🔴 an item that names a linter rule: the build takes the rule's description from the linter, and refuses one it cannot describe", () => {
|
|
1180
|
+
const named = (rules: readonly Record<string, string>[]) =>
|
|
1181
|
+
config({
|
|
1182
|
+
items: ITEMS.map((item, n) => (n === 0 ? { ...item, rules } : item)),
|
|
1183
|
+
});
|
|
1184
|
+
const { page } = build(
|
|
1185
|
+
setup("rules-ok", named([{ linter: "eslint", id: "no-console" }])),
|
|
1186
|
+
);
|
|
1187
|
+
assert.ok(
|
|
1188
|
+
page.includes(
|
|
1189
|
+
'"rules": [{"linter": "eslint", "id": "no-console", "description": "Disallow the use of `console`"}]',
|
|
1190
|
+
),
|
|
1191
|
+
"the page carries the linter's own description",
|
|
1192
|
+
);
|
|
1193
|
+
const dir = setup(
|
|
1194
|
+
"rules-unknown",
|
|
1195
|
+
named([{ linter: "eslint", id: "no-such-rule" }]),
|
|
1196
|
+
);
|
|
1197
|
+
const r = run(["build", join(dir, "items.json"), join(dir, "out")]);
|
|
1198
|
+
assert.equal(r.status, 1, r.out);
|
|
1199
|
+
assert.match(
|
|
1200
|
+
r.out,
|
|
1201
|
+
/REFUSED: items\[0\]\.rules\[0\]: eslint 'no-such-rule' has no description from the linter/,
|
|
1202
|
+
);
|
|
1203
|
+
// ruff answers the same whether it is missing or does not know the code: no description.
|
|
1204
|
+
const ruff = setup(
|
|
1205
|
+
"rules-ruff-unknown",
|
|
1206
|
+
named([{ linter: "ruff", id: "NOPE1" }]),
|
|
1207
|
+
);
|
|
1208
|
+
const r2 = run(["build", join(ruff, "items.json"), join(ruff, "out")]);
|
|
1209
|
+
assert.equal(r2.status, 1, r2.out);
|
|
1210
|
+
assert.match(
|
|
1211
|
+
r2.out,
|
|
1212
|
+
/ruff 'NOPE1' has no description from the linter \(`ruff rule` does not know it/,
|
|
1213
|
+
);
|
|
1214
|
+
});
|
|
1215
|
+
|
|
1216
|
+
test("🔴 familiarity off: the key asks for none, the export reports no missing familiarity, and a stored one is refused", () => {
|
|
1217
|
+
const dir = setup("familiarity-off", approval({ familiarity: false }));
|
|
1218
|
+
const { key } = build(dir);
|
|
1219
|
+
assert.deepEqual(key.allowed.familiarity, []);
|
|
1220
|
+
const [a] = Object.keys(key.ids);
|
|
1221
|
+
assert.ok(a !== undefined);
|
|
1222
|
+
writeFileSync(
|
|
1223
|
+
join(dir, "dump.json"),
|
|
1224
|
+
JSON.stringify({
|
|
1225
|
+
answers: { [a]: { answers: { keep: "keep" }, source: "page" } },
|
|
1226
|
+
groups: {},
|
|
1227
|
+
}),
|
|
1228
|
+
);
|
|
1229
|
+
const r = exportAt(dir);
|
|
1230
|
+
assert.equal(r.status, 0, r.out);
|
|
1231
|
+
assert.doesNotMatch(r.out, /no familiarity answer|invalid group/);
|
|
1232
|
+
assert.deepEqual(Exported.parse(JSON.parse(labelsFile(dir))).groups, {});
|
|
1233
|
+
writeFileSync(
|
|
1234
|
+
join(dir, "dump.json"),
|
|
1235
|
+
JSON.stringify({
|
|
1236
|
+
answers: {},
|
|
1237
|
+
groups: { python: { familiarity: "well", source: "chat" } },
|
|
1238
|
+
}),
|
|
1239
|
+
);
|
|
1240
|
+
const refused = exportAt(dir);
|
|
1241
|
+
assert.notEqual(refused.status, 0);
|
|
1242
|
+
assert.match(refused.out, /groups\/python: .*familiarity is off/);
|
|
1243
|
+
});
|
|
1244
|
+
|
|
1245
|
+
test("🔴 familiarity off with a familiarity_question is a contradiction the build refuses", () => {
|
|
1246
|
+
const r = run([
|
|
1247
|
+
"build",
|
|
1248
|
+
join(
|
|
1249
|
+
setup(
|
|
1250
|
+
"familiarity-contradiction",
|
|
1251
|
+
approval({ familiarity: false, familiarity_question: "How well?" }),
|
|
1252
|
+
),
|
|
1253
|
+
"items.json",
|
|
1254
|
+
),
|
|
1255
|
+
join(WORK, "contradiction-out"),
|
|
1256
|
+
]);
|
|
1257
|
+
assert.notEqual(r.status, 0);
|
|
1258
|
+
assert.match(
|
|
1259
|
+
r.out,
|
|
1260
|
+
/REFUSED: familiarity_question: .*`familiarity` is false/,
|
|
1261
|
+
);
|
|
1262
|
+
});
|
|
1263
|
+
|
|
1264
|
+
test("familiarity left out: every group is still asked (rule 5), and a missing one is reported", () => {
|
|
1265
|
+
const dir = setup("familiarity-default", approval());
|
|
1266
|
+
const { key } = build(dir);
|
|
1267
|
+
assert.deepEqual(key.allowed.familiarity, ["well", "somewhat", "not_really"]);
|
|
1268
|
+
writeFileSync(
|
|
1269
|
+
join(dir, "dump.json"),
|
|
1270
|
+
JSON.stringify({ answers: {}, groups: {} }),
|
|
1271
|
+
);
|
|
1272
|
+
assert.match(exportAt(dir).out, /group python: no familiarity answer/);
|
|
1273
|
+
});
|
|
1274
|
+
|
|
1178
1275
|
/** Items 0 and 3 (both Rust) as the two requirements of one line. */
|
|
1179
1276
|
const LINE = "TypeScript only (no JavaScript); top-level await is allowed.";
|
|
1180
1277
|
const withUnit = (
|
|
@@ -1306,62 +1403,3 @@ test("🔴 the same line in two places is two lines: four items, two ids, it bui
|
|
|
1306
1403
|
const { page } = build(dir);
|
|
1307
1404
|
assert.ok(!page.includes("a.md:3"), "the line's id is private");
|
|
1308
1405
|
});
|
|
1309
|
-
|
|
1310
|
-
test("🔴 familiarity off: the key asks for none, the export reports no missing familiarity, and a stored one is refused", () => {
|
|
1311
|
-
const dir = setup("familiarity-off", approval({ familiarity: false }));
|
|
1312
|
-
const { key } = build(dir);
|
|
1313
|
-
assert.deepEqual(key.allowed.familiarity, []);
|
|
1314
|
-
const [a] = Object.keys(key.ids);
|
|
1315
|
-
assert.ok(a !== undefined);
|
|
1316
|
-
writeFileSync(
|
|
1317
|
-
join(dir, "dump.json"),
|
|
1318
|
-
JSON.stringify({
|
|
1319
|
-
answers: { [a]: { answers: { keep: "keep" }, source: "page" } },
|
|
1320
|
-
groups: {},
|
|
1321
|
-
}),
|
|
1322
|
-
);
|
|
1323
|
-
const r = exportAt(dir);
|
|
1324
|
-
assert.equal(r.status, 0, r.out);
|
|
1325
|
-
assert.doesNotMatch(r.out, /no familiarity answer|invalid group/);
|
|
1326
|
-
assert.deepEqual(Exported.parse(JSON.parse(labelsFile(dir))).groups, {});
|
|
1327
|
-
writeFileSync(
|
|
1328
|
-
join(dir, "dump.json"),
|
|
1329
|
-
JSON.stringify({
|
|
1330
|
-
answers: {},
|
|
1331
|
-
groups: { python: { familiarity: "well", source: "chat" } },
|
|
1332
|
-
}),
|
|
1333
|
-
);
|
|
1334
|
-
const refused = exportAt(dir);
|
|
1335
|
-
assert.notEqual(refused.status, 0);
|
|
1336
|
-
assert.match(refused.out, /groups\/python: .*familiarity is off/);
|
|
1337
|
-
});
|
|
1338
|
-
|
|
1339
|
-
test("🔴 familiarity off with a familiarity_question is a contradiction the build refuses", () => {
|
|
1340
|
-
const r = run([
|
|
1341
|
-
"build",
|
|
1342
|
-
join(
|
|
1343
|
-
setup(
|
|
1344
|
-
"familiarity-contradiction",
|
|
1345
|
-
approval({ familiarity: false, familiarity_question: "How well?" }),
|
|
1346
|
-
),
|
|
1347
|
-
"items.json",
|
|
1348
|
-
),
|
|
1349
|
-
join(WORK, "contradiction-out"),
|
|
1350
|
-
]);
|
|
1351
|
-
assert.notEqual(r.status, 0);
|
|
1352
|
-
assert.match(
|
|
1353
|
-
r.out,
|
|
1354
|
-
/REFUSED: familiarity_question: .*`familiarity` is false/,
|
|
1355
|
-
);
|
|
1356
|
-
});
|
|
1357
|
-
|
|
1358
|
-
test("familiarity left out: every group is still asked (rule 5), and a missing one is reported", () => {
|
|
1359
|
-
const dir = setup("familiarity-default", approval());
|
|
1360
|
-
const { key } = build(dir);
|
|
1361
|
-
assert.deepEqual(key.allowed.familiarity, ["well", "somewhat", "not_really"]);
|
|
1362
|
-
writeFileSync(
|
|
1363
|
-
join(dir, "dump.json"),
|
|
1364
|
-
JSON.stringify({ answers: {}, groups: {} }),
|
|
1365
|
-
);
|
|
1366
|
-
assert.match(exportAt(dir).out, /group python: no familiarity answer/);
|
|
1367
|
-
});
|
|
@@ -6,8 +6,9 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { createHash } from "node:crypto";
|
|
8
8
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
9
|
-
import { join } from "node:path";
|
|
9
|
+
import { dirname, join } from "node:path";
|
|
10
10
|
import { z } from "zod";
|
|
11
|
+
import { describeRule, type RuleRef } from "./rule-descriptions.ts";
|
|
11
12
|
|
|
12
13
|
// ── refusals and output ───────────────────────────────────────────────────────────────────────
|
|
13
14
|
export class Refusal extends Error {
|
|
@@ -94,11 +95,15 @@ const readJson = (path: string): Json => {
|
|
|
94
95
|
// labeller what the answer is, or where the item came from?" — never to make a build pass.
|
|
95
96
|
const PAGE_FIELDS = ["group", "title", "body", "code", "context"] as const;
|
|
96
97
|
const PRIVATE_FIELDS = ["source_id"] as const;
|
|
98
|
+
// The linter rules an item names ("read as ruff `TID253`"): the page shows each with the linter's
|
|
99
|
+
// own description, looked up at build time (rule-descriptions.ts), never typed into items.json.
|
|
100
|
+
const RULES_FIELD = "rules";
|
|
97
101
|
// One requirement of a line that holds several: the whole line and which part this item is. Shown on
|
|
98
102
|
// the card beneath the item; the line is text the labeller may read, like `context`.
|
|
99
103
|
const UNIT_FIELD = "unit";
|
|
100
104
|
const ALLOWED_FIELDS: readonly string[] = [
|
|
101
105
|
...PAGE_FIELDS,
|
|
106
|
+
RULES_FIELD,
|
|
102
107
|
UNIT_FIELD,
|
|
103
108
|
...PRIVATE_FIELDS,
|
|
104
109
|
];
|
|
@@ -196,6 +201,11 @@ const ITEM_SCHEMA = obj(
|
|
|
196
201
|
...Object.fromEntries(
|
|
197
202
|
PAGE_FIELDS.map((f) => [f, string(f === "group" || f === "body")]),
|
|
198
203
|
),
|
|
204
|
+
[RULES_FIELD]: array(
|
|
205
|
+
obj({ linter: string(true, "eslint|ruff"), id: string() }),
|
|
206
|
+
false,
|
|
207
|
+
1,
|
|
208
|
+
),
|
|
199
209
|
[UNIT_FIELD]: obj(
|
|
200
210
|
// `id` names one occurrence of the line (two files may hold the same words): never shown.
|
|
201
211
|
{ id: string(), text: string(), part: integer(), of: integer() },
|
|
@@ -374,6 +384,7 @@ type Item = {
|
|
|
374
384
|
readonly source_id: string;
|
|
375
385
|
readonly group: string;
|
|
376
386
|
readonly body: string;
|
|
387
|
+
readonly rules?: readonly RuleRef[];
|
|
377
388
|
readonly unit?: Unit;
|
|
378
389
|
};
|
|
379
390
|
interface Practice {
|
|
@@ -725,6 +736,34 @@ const shownFields = (
|
|
|
725
736
|
},
|
|
726
737
|
}),
|
|
727
738
|
});
|
|
739
|
+
/** Rule → the linter's description, for every rule an item on the page names. */
|
|
740
|
+
type Described = ReadonlyMap<string, string>;
|
|
741
|
+
const ruleKey = (r: RuleRef): string => `${r.linter} ${r.id}`;
|
|
742
|
+
/** Ask the linter for every rule named; refuse, by path, a rule it cannot describe. */
|
|
743
|
+
function describeRules(cfg: Config, fromDir: string): Described {
|
|
744
|
+
return new Map(
|
|
745
|
+
cfg.items.flatMap((item, n) =>
|
|
746
|
+
(item.rules ?? []).map((r, k): readonly [string, string] => {
|
|
747
|
+
const text = describeRule(r, fromDir);
|
|
748
|
+
return text === undefined
|
|
749
|
+
? fail(
|
|
750
|
+
`items[${String(n)}].rules[${String(k)}]: ${r.linter} ${repr(r.id)} has no description from the linter (${r.linter === "ruff" ? "`ruff rule` does not know it, or ruff is not on PATH" : "not a rule of the ESLint, or of a plugin, installed where items.json is"}) — check the id`,
|
|
751
|
+
)
|
|
752
|
+
: [ruleKey(r), text];
|
|
753
|
+
}),
|
|
754
|
+
),
|
|
755
|
+
);
|
|
756
|
+
}
|
|
757
|
+
const rulesShown = (item: Item, described: Described) =>
|
|
758
|
+
item.rules === undefined
|
|
759
|
+
? {}
|
|
760
|
+
: {
|
|
761
|
+
[RULES_FIELD]: item.rules.map((r) => ({
|
|
762
|
+
linter: r.linter,
|
|
763
|
+
id: r.id,
|
|
764
|
+
description: String(described.get(ruleKey(r))),
|
|
765
|
+
})),
|
|
766
|
+
};
|
|
728
767
|
|
|
729
768
|
/** Only what the labeller can answer goes on the page, and only that gets an id, in id order. */
|
|
730
769
|
function itemsOnPage(cfg: Config, keyPath: string) {
|
|
@@ -742,7 +781,11 @@ function itemsOnPage(cfg: Config, keyPath: string) {
|
|
|
742
781
|
const practiceRound = (cfg: Config): number => cfg.practice_round ?? 1;
|
|
743
782
|
|
|
744
783
|
/** What a page shows: the questions, the practice and the items, blind, with their neutral ids. */
|
|
745
|
-
function payloadOf(
|
|
784
|
+
function payloadOf(
|
|
785
|
+
cfg: Config,
|
|
786
|
+
page: ReturnType<typeof itemsOnPage>,
|
|
787
|
+
described: Described,
|
|
788
|
+
) {
|
|
746
789
|
const used = Object.keys(cfg.groups).filter((g) =>
|
|
747
790
|
page.items.some((i) => i.group === g),
|
|
748
791
|
);
|
|
@@ -757,7 +800,11 @@ function payloadOf(cfg: Config, page: ReturnType<typeof itemsOnPage>) {
|
|
|
757
800
|
allowed: allowedValues(cfg.questions, asksFamiliarity(cfg)),
|
|
758
801
|
questions: cfg.questions,
|
|
759
802
|
practice: { round: practiceRound(cfg), items: practiceOf(cfg) },
|
|
760
|
-
items: page.items.map((i) => ({
|
|
803
|
+
items: page.items.map((i) => ({
|
|
804
|
+
id: page.idOf(i),
|
|
805
|
+
...shownFields(i),
|
|
806
|
+
...rulesShown(i, described),
|
|
807
|
+
})),
|
|
761
808
|
};
|
|
762
809
|
}
|
|
763
810
|
export type Payload = ReturnType<typeof payloadOf>;
|
|
@@ -837,13 +884,14 @@ export function prepareBuild(configPath: string, outDir: string) {
|
|
|
837
884
|
checkItemFields(cfg);
|
|
838
885
|
checkUnits(cfg);
|
|
839
886
|
checkNoSourceShown(cfg);
|
|
887
|
+
const described = describeRules(cfg, dirname(configPath));
|
|
840
888
|
mkdirSync(outDir, { recursive: true });
|
|
841
889
|
const keyPath = join(outDir, "key.json");
|
|
842
890
|
const earlier = roundsOf(keyPath);
|
|
843
891
|
const page = itemsOnPage(cfg, keyPath);
|
|
844
892
|
writeJson(keyPath, keyOf(cfg, page, earlier));
|
|
845
893
|
return {
|
|
846
|
-
payload: payloadOf(cfg, page),
|
|
894
|
+
payload: payloadOf(cfg, page, described),
|
|
847
895
|
dropped: cfg.items.length - page.items.length,
|
|
848
896
|
};
|
|
849
897
|
}
|
|
@@ -412,26 +412,15 @@ test("🔴 without the store the page is read-only, and the real items stay read
|
|
|
412
412
|
assert.match(shown, /G001/, "a real item is shown read-only");
|
|
413
413
|
});
|
|
414
414
|
|
|
415
|
-
test("🔴
|
|
416
|
-
const line = "TypeScript only (no JavaScript); top-level await is allowed.";
|
|
415
|
+
test("🔴 an item that names a rule: its card says which rule and what it does, in the linter's words", async () => {
|
|
417
416
|
const { page } = build(
|
|
418
417
|
setup(
|
|
419
|
-
"
|
|
418
|
+
"rules-card",
|
|
420
419
|
config({
|
|
421
420
|
items: ITEMS.map((item, n) =>
|
|
422
421
|
n === 0
|
|
423
|
-
? {
|
|
424
|
-
|
|
425
|
-
body: "TypeScript only (no JavaScript)",
|
|
426
|
-
unit: { id: "L1", text: line, part: 1, of: 2 },
|
|
427
|
-
}
|
|
428
|
-
: n === 3
|
|
429
|
-
? {
|
|
430
|
-
...item,
|
|
431
|
-
body: "top-level await is allowed",
|
|
432
|
-
unit: { id: "L1", text: line, part: 2, of: 2 },
|
|
433
|
-
}
|
|
434
|
-
: item,
|
|
422
|
+
? { ...item, rules: [{ linter: "eslint", id: "no-console" }] }
|
|
423
|
+
: item,
|
|
435
424
|
),
|
|
436
425
|
}),
|
|
437
426
|
),
|
|
@@ -439,14 +428,13 @@ test("🔴 one requirement of a line: its card shows which part it is and the wh
|
|
|
439
428
|
const shown = await showPage(page, {}, {});
|
|
440
429
|
assert.match(
|
|
441
430
|
shown,
|
|
442
|
-
/
|
|
431
|
+
/Rule eslint no-console: Disallow the use of \nconsole\n/,
|
|
443
432
|
shown,
|
|
444
433
|
);
|
|
445
|
-
assert.match(shown, /From the line \(requirement 2 of 2\):/);
|
|
446
434
|
assert.equal(
|
|
447
|
-
occurrences(shown, "
|
|
448
|
-
|
|
449
|
-
"only the
|
|
435
|
+
occurrences(shown, "Rule eslint"),
|
|
436
|
+
1,
|
|
437
|
+
"only the item that names it",
|
|
450
438
|
);
|
|
451
439
|
});
|
|
452
440
|
|
|
@@ -471,3 +459,41 @@ test("🔴 familiarity off: no group asks how well it is known, and a group is d
|
|
|
471
459
|
assert.doesNotMatch(shown, /how well you know it/);
|
|
472
460
|
assert.match(shown, /Nothing left/, shown);
|
|
473
461
|
});
|
|
462
|
+
|
|
463
|
+
test("🔴 one requirement of a line: its card shows which part it is and the whole line beneath", async () => {
|
|
464
|
+
const line = "TypeScript only (no JavaScript); top-level await is allowed.";
|
|
465
|
+
const { page } = build(
|
|
466
|
+
setup(
|
|
467
|
+
"unit-card",
|
|
468
|
+
config({
|
|
469
|
+
items: ITEMS.map((item, n) =>
|
|
470
|
+
n === 0
|
|
471
|
+
? {
|
|
472
|
+
...item,
|
|
473
|
+
body: "TypeScript only (no JavaScript)",
|
|
474
|
+
unit: { id: "L1", text: line, part: 1, of: 2 },
|
|
475
|
+
}
|
|
476
|
+
: n === 3
|
|
477
|
+
? {
|
|
478
|
+
...item,
|
|
479
|
+
body: "top-level await is allowed",
|
|
480
|
+
unit: { id: "L1", text: line, part: 2, of: 2 },
|
|
481
|
+
}
|
|
482
|
+
: item,
|
|
483
|
+
),
|
|
484
|
+
}),
|
|
485
|
+
),
|
|
486
|
+
);
|
|
487
|
+
const shown = await showPage(page, {}, {});
|
|
488
|
+
assert.match(
|
|
489
|
+
shown,
|
|
490
|
+
/From the line \(requirement 1 of 2\):\nTypeScript only \(no JavaScript\); top-level await is allowed\./,
|
|
491
|
+
shown,
|
|
492
|
+
);
|
|
493
|
+
assert.match(shown, /From the line \(requirement 2 of 2\):/);
|
|
494
|
+
assert.equal(
|
|
495
|
+
occurrences(shown, "From the line"),
|
|
496
|
+
2,
|
|
497
|
+
"only the two parts carry the line",
|
|
498
|
+
);
|
|
499
|
+
});
|
|
@@ -54,7 +54,10 @@
|
|
|
54
54
|
* "practice_round": 1, optional; raise it after rewording, the practice reopens
|
|
55
55
|
* "items": [{"source_id": "case-0412-b", "group": "python", "body": "…", "code": "…",
|
|
56
56
|
* "context": "…",
|
|
57
|
+
* "rules": [{"linter": "ruff", "id": "TID253"}],
|
|
57
58
|
* "unit": {"id": "a.md:12", "text": "the whole line", "part": 1, "of": 2}}]
|
|
59
|
+
* rules: optional; each shown with the linter's own
|
|
60
|
+
* description (`ruff rule`, ESLint meta.docs.description)
|
|
58
61
|
* unit: optional; one requirement of a line that holds
|
|
59
62
|
* several. Every part of the line is an item, in one group;
|
|
60
63
|
* id (never shown) tells two places with the same words apart.
|
|
@@ -54,7 +54,10 @@
|
|
|
54
54
|
* "practice_round": 1, optional; raise it after rewording, the practice reopens
|
|
55
55
|
* "items": [{"source_id": "case-0412-b", "group": "python", "body": "…", "code": "…",
|
|
56
56
|
* "context": "…",
|
|
57
|
+
* "rules": [{"linter": "ruff", "id": "TID253"}],
|
|
57
58
|
* "unit": {"id": "a.md:12", "text": "the whole line", "part": 1, "of": 2}}]
|
|
59
|
+
* rules: optional; each shown with the linter's own
|
|
60
|
+
* description (`ruff rule`, ESLint meta.docs.description)
|
|
58
61
|
* unit: optional; one requirement of a line that holds
|
|
59
62
|
* several. Every part of the line is an item, in one group;
|
|
60
63
|
* id (never shown) tells two places with the same words apart.
|
|
@@ -312,6 +312,10 @@ pre { overflow-x: auto; background: var(--surface); border: 1px solid var(--line
|
|
|
312
312
|
card.appendChild(rich("p", "body", item.body));
|
|
313
313
|
if (item.code) card.appendChild(codeBlock(item.code));
|
|
314
314
|
if (item.context) card.appendChild(rich("p", "muted", item.context));
|
|
315
|
+
// A rule the item names, with what it does in the linter's own words (looked up at build time).
|
|
316
|
+
(item.rules || []).forEach(function (r) {
|
|
317
|
+
card.appendChild(rich("p", "muted", "Rule " + r.linter + " " + r.id + ": " + r.description));
|
|
318
|
+
});
|
|
315
319
|
// One requirement of a line that holds several: which one, and the whole line it came from.
|
|
316
320
|
if (item.unit) {
|
|
317
321
|
card.appendChild(el("p", "muted", "From the line (requirement " + item.unit.part + " of " + item.unit.of + "):"));
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a linter rule does, in the linter's own words, for the card of an item that names one
|
|
3
|
+
* ("read as ruff `TID253`" means nothing to a labeller who has not met the code). Never invented: a
|
|
4
|
+
* rule the linter cannot describe has no description, and the build refuses it.
|
|
5
|
+
*
|
|
6
|
+
* - ruff: `ruff rule <code> --output-format json`, the paragraph under "## What it does".
|
|
7
|
+
* - ESLint: the rule's `meta.docs.description`, from the ESLint (and the plugin) installed where
|
|
8
|
+
* items.json is: a study's own install, found by Node's resolution from that folder upwards.
|
|
9
|
+
* A plugin rule is found by ESLint's naming convention (`@scope/rule` → `@scope/eslint-plugin`,
|
|
10
|
+
* `@scope/p/rule` → `@scope/eslint-plugin-p`, `p/rule` → `eslint-plugin-p`).
|
|
11
|
+
*/
|
|
12
|
+
import { spawnSync } from "node:child_process";
|
|
13
|
+
import { createRequire } from "node:module";
|
|
14
|
+
import { resolve } from "node:path";
|
|
15
|
+
import { z } from "zod";
|
|
16
|
+
export const runProcess = (cmd, args) => {
|
|
17
|
+
const r = spawnSync(cmd, args, { encoding: "utf8" });
|
|
18
|
+
return { status: r.status, stdout: r.stdout };
|
|
19
|
+
};
|
|
20
|
+
const RuffRule = z.object({ explanation: z.string() });
|
|
21
|
+
/** The paragraph under "## What it does", its lines joined. */
|
|
22
|
+
function ruffDescription(id, run) {
|
|
23
|
+
const r = run("ruff", ["rule", id, "--output-format", "json"]);
|
|
24
|
+
if (r.status !== 0)
|
|
25
|
+
return undefined;
|
|
26
|
+
const { explanation } = RuffRule.parse(JSON.parse(r.stdout));
|
|
27
|
+
const what = /## What it does\n([\s\S]*?)(?:\n\n|$)/.exec(explanation)?.[1];
|
|
28
|
+
return what?.split("\n").join(" ").trim();
|
|
29
|
+
}
|
|
30
|
+
const RuleModule = z.object({
|
|
31
|
+
meta: z.object({ docs: z.object({ description: z.string() }) }),
|
|
32
|
+
});
|
|
33
|
+
const Plugin = z.object({ rules: z.record(z.string(), z.unknown()) });
|
|
34
|
+
const Builtins = z.object({
|
|
35
|
+
builtinRules: z.map(z.string(), z.unknown()),
|
|
36
|
+
});
|
|
37
|
+
/** The package and the rule name a namespaced ESLint rule id points at. */
|
|
38
|
+
function pluginOf(id) {
|
|
39
|
+
const parts = id.split("/");
|
|
40
|
+
const name = id.slice(id.lastIndexOf("/") + 1);
|
|
41
|
+
const [scope, plugin] = parts;
|
|
42
|
+
if (id.startsWith("@"))
|
|
43
|
+
return [
|
|
44
|
+
parts.length === 2
|
|
45
|
+
? `${String(scope)}/eslint-plugin`
|
|
46
|
+
: `${String(scope)}/eslint-plugin-${String(plugin)}`,
|
|
47
|
+
name,
|
|
48
|
+
];
|
|
49
|
+
return [`eslint-plugin-${String(scope)}`, name];
|
|
50
|
+
}
|
|
51
|
+
// `require` of an ES-module plugin returns its namespace: the plugin object is then its `default`.
|
|
52
|
+
const EsmPlugin = z.object({ default: Plugin });
|
|
53
|
+
const pluginRules = (mod) => {
|
|
54
|
+
const esm = EsmPlugin.safeParse(mod);
|
|
55
|
+
return esm.success ? esm.data.default.rules : Plugin.parse(mod).rules;
|
|
56
|
+
};
|
|
57
|
+
const description = (rule) => {
|
|
58
|
+
const r = RuleModule.safeParse(rule);
|
|
59
|
+
return r.success ? r.data.meta.docs.description : undefined;
|
|
60
|
+
};
|
|
61
|
+
function eslintDescription(id, fromDir) {
|
|
62
|
+
// createRequire takes an absolute path only; items.json is often named relative to the study folder.
|
|
63
|
+
const load = createRequire(resolve(fromDir, "items.json"));
|
|
64
|
+
try {
|
|
65
|
+
if (!id.includes("/")) {
|
|
66
|
+
const core = Builtins.parse(load("eslint/use-at-your-own-risk"));
|
|
67
|
+
return description(core.builtinRules.get(id));
|
|
68
|
+
}
|
|
69
|
+
const [pkg, name] = pluginOf(id);
|
|
70
|
+
return description(pluginRules(load(pkg))[name]);
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
// ESLint or the plugin is not installed where items.json is: the linter cannot answer.
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/** The linter's own one-line description of `ref`, or undefined when it has none. */
|
|
78
|
+
export function describeRule(ref, fromDir, run = runProcess) {
|
|
79
|
+
return ref.linter === "ruff"
|
|
80
|
+
? ruffDescription(ref.id, run)
|
|
81
|
+
: eslintDescription(ref.id, fromDir);
|
|
82
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a rule does, in the linter's own words. ESLint against the copy installed in this repository
|
|
3
|
+
* (a real catalogue, no network); ruff through a stand-in process holding what `ruff 0.16.9 rule TID253
|
|
4
|
+
* --output-format json` printed (fixtures/ruff-rule-TID253.json), so no test needs the ruff binary.
|
|
5
|
+
*/
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import {
|
|
8
|
+
mkdirSync,
|
|
9
|
+
mkdtempSync,
|
|
10
|
+
readFileSync,
|
|
11
|
+
realpathSync,
|
|
12
|
+
writeFileSync,
|
|
13
|
+
} from "node:fs";
|
|
14
|
+
import { tmpdir } from "node:os";
|
|
15
|
+
import { join, relative } from "node:path";
|
|
16
|
+
import { test } from "vitest";
|
|
17
|
+
import {
|
|
18
|
+
describeRule,
|
|
19
|
+
runProcess,
|
|
20
|
+
type RunProcess,
|
|
21
|
+
} from "./rule-descriptions.ts";
|
|
22
|
+
|
|
23
|
+
const REPO = join(import.meta.dirname, "..", "..", "..");
|
|
24
|
+
const TID253 = readFileSync(
|
|
25
|
+
join(import.meta.dirname, "fixtures", "ruff-rule-TID253.json"),
|
|
26
|
+
"utf8",
|
|
27
|
+
);
|
|
28
|
+
/** `ruff rule <code>` as the binary answers: the recorded JSON for TID253, exit 2 for anything else. */
|
|
29
|
+
const fakeRuff: RunProcess = (cmd, args) =>
|
|
30
|
+
cmd === "ruff" && args[1] === "TID253"
|
|
31
|
+
? { status: 0, stdout: TID253 }
|
|
32
|
+
: { status: 2, stdout: "" };
|
|
33
|
+
|
|
34
|
+
test("🔴 a ruff rule is described by its own 'What it does' paragraph, on one line", () => {
|
|
35
|
+
assert.equal(
|
|
36
|
+
describeRule({ linter: "ruff", id: "TID253" }, REPO, fakeRuff),
|
|
37
|
+
"Checks for module-level imports that should instead be imported lazily (e.g., within a function definition, or an `if TYPE_CHECKING:` block, or some other nested context).",
|
|
38
|
+
);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("🔴 an ESLint rule is described by meta.docs.description: a core rule, and a plugin rule resolved by the plugin naming convention", () => {
|
|
42
|
+
assert.equal(
|
|
43
|
+
describeRule({ linter: "eslint", id: "no-console" }, REPO, fakeRuff),
|
|
44
|
+
"Disallow the use of `console`",
|
|
45
|
+
);
|
|
46
|
+
assert.equal(
|
|
47
|
+
describeRule(
|
|
48
|
+
{ linter: "eslint", id: "@typescript-eslint/no-explicit-any" },
|
|
49
|
+
REPO,
|
|
50
|
+
fakeRuff,
|
|
51
|
+
),
|
|
52
|
+
"Disallow the `any` type",
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("a rule the linter does not know, or a plugin that is not installed has no description (never an invented one)", () => {
|
|
57
|
+
const none = [
|
|
58
|
+
describeRule({ linter: "ruff", id: "NOPE1" }, REPO, fakeRuff),
|
|
59
|
+
describeRule({ linter: "eslint", id: "no-such-rule" }, REPO, fakeRuff),
|
|
60
|
+
describeRule({ linter: "eslint", id: "nope/rule" }, REPO, fakeRuff),
|
|
61
|
+
describeRule({ linter: "eslint", id: "@nope/x/rule" }, REPO, fakeRuff),
|
|
62
|
+
// ruff answers, but without a "What it does" section
|
|
63
|
+
describeRule({ linter: "ruff", id: "TID253" }, REPO, () => ({
|
|
64
|
+
status: 0,
|
|
65
|
+
stdout: JSON.stringify({ explanation: "## Why is this bad?\nx" }),
|
|
66
|
+
})),
|
|
67
|
+
];
|
|
68
|
+
assert.deepEqual(none, [
|
|
69
|
+
undefined,
|
|
70
|
+
undefined,
|
|
71
|
+
undefined,
|
|
72
|
+
undefined,
|
|
73
|
+
undefined,
|
|
74
|
+
]);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("the real process runner returns the exit status and what was printed (node stands in for ruff)", () => {
|
|
78
|
+
assert.deepEqual(
|
|
79
|
+
runProcess(process.execPath, [
|
|
80
|
+
"-e",
|
|
81
|
+
"process.stdout.write('x'); process.exit(3)",
|
|
82
|
+
]),
|
|
83
|
+
{ status: 3, stdout: "x" },
|
|
84
|
+
);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("🔴 a relative folder (the documented `build items.json label-page`) is resolved before ESLint is looked up", () => {
|
|
88
|
+
assert.equal(
|
|
89
|
+
describeRule(
|
|
90
|
+
{ linter: "eslint", id: "no-console" },
|
|
91
|
+
relative(process.cwd(), REPO) || ".",
|
|
92
|
+
fakeRuff,
|
|
93
|
+
),
|
|
94
|
+
"Disallow the use of `console`",
|
|
95
|
+
);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("🔴 an ES-module plugin whose rules sit under its default export is described too", () => {
|
|
99
|
+
const dir = realpathSync(mkdtempSync(join(tmpdir(), "esm-plugin-")));
|
|
100
|
+
const pkg = join(dir, "node_modules", "eslint-plugin-esmonly");
|
|
101
|
+
mkdirSync(pkg, { recursive: true });
|
|
102
|
+
writeFileSync(
|
|
103
|
+
join(pkg, "package.json"),
|
|
104
|
+
JSON.stringify({
|
|
105
|
+
name: "eslint-plugin-esmonly",
|
|
106
|
+
type: "module",
|
|
107
|
+
main: "index.js",
|
|
108
|
+
}),
|
|
109
|
+
);
|
|
110
|
+
writeFileSync(
|
|
111
|
+
join(pkg, "index.js"),
|
|
112
|
+
'export default { rules: { r: { meta: { docs: { description: "Only via default" } } } } };\n',
|
|
113
|
+
);
|
|
114
|
+
assert.equal(
|
|
115
|
+
describeRule({ linter: "eslint", id: "esmonly/r" }, dir, fakeRuff),
|
|
116
|
+
"Only via default",
|
|
117
|
+
);
|
|
118
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a linter rule does, in the linter's own words, for the card of an item that names one
|
|
3
|
+
* ("read as ruff `TID253`" means nothing to a labeller who has not met the code). Never invented: a
|
|
4
|
+
* rule the linter cannot describe has no description, and the build refuses it.
|
|
5
|
+
*
|
|
6
|
+
* - ruff: `ruff rule <code> --output-format json`, the paragraph under "## What it does".
|
|
7
|
+
* - ESLint: the rule's `meta.docs.description`, from the ESLint (and the plugin) installed where
|
|
8
|
+
* items.json is: a study's own install, found by Node's resolution from that folder upwards.
|
|
9
|
+
* A plugin rule is found by ESLint's naming convention (`@scope/rule` → `@scope/eslint-plugin`,
|
|
10
|
+
* `@scope/p/rule` → `@scope/eslint-plugin-p`, `p/rule` → `eslint-plugin-p`).
|
|
11
|
+
*/
|
|
12
|
+
import { spawnSync } from "node:child_process";
|
|
13
|
+
import { createRequire } from "node:module";
|
|
14
|
+
import { resolve } from "node:path";
|
|
15
|
+
import { z } from "zod";
|
|
16
|
+
|
|
17
|
+
export interface RuleRef {
|
|
18
|
+
readonly linter: "eslint" | "ruff";
|
|
19
|
+
readonly id: string;
|
|
20
|
+
}
|
|
21
|
+
/** A process run to its end: the exit status and what it printed. */
|
|
22
|
+
export type RunProcess = (
|
|
23
|
+
cmd: string,
|
|
24
|
+
args: readonly string[],
|
|
25
|
+
) => { readonly status: number | null; readonly stdout: string };
|
|
26
|
+
|
|
27
|
+
export const runProcess: RunProcess = (cmd, args) => {
|
|
28
|
+
const r = spawnSync(cmd, args, { encoding: "utf8" });
|
|
29
|
+
return { status: r.status, stdout: r.stdout };
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const RuffRule = z.object({ explanation: z.string() });
|
|
33
|
+
/** The paragraph under "## What it does", its lines joined. */
|
|
34
|
+
function ruffDescription(id: string, run: RunProcess): string | undefined {
|
|
35
|
+
const r = run("ruff", ["rule", id, "--output-format", "json"]);
|
|
36
|
+
if (r.status !== 0) return undefined;
|
|
37
|
+
const { explanation } = RuffRule.parse(JSON.parse(r.stdout));
|
|
38
|
+
const what = /## What it does\n([\s\S]*?)(?:\n\n|$)/.exec(explanation)?.[1];
|
|
39
|
+
return what?.split("\n").join(" ").trim();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const RuleModule = z.object({
|
|
43
|
+
meta: z.object({ docs: z.object({ description: z.string() }) }),
|
|
44
|
+
});
|
|
45
|
+
const Plugin = z.object({ rules: z.record(z.string(), z.unknown()) });
|
|
46
|
+
const Builtins = z.object({
|
|
47
|
+
builtinRules: z.map(z.string(), z.unknown()),
|
|
48
|
+
});
|
|
49
|
+
/** The package and the rule name a namespaced ESLint rule id points at. */
|
|
50
|
+
function pluginOf(id: string): readonly [string, string] {
|
|
51
|
+
const parts = id.split("/");
|
|
52
|
+
const name = id.slice(id.lastIndexOf("/") + 1);
|
|
53
|
+
const [scope, plugin] = parts;
|
|
54
|
+
if (id.startsWith("@"))
|
|
55
|
+
return [
|
|
56
|
+
parts.length === 2
|
|
57
|
+
? `${String(scope)}/eslint-plugin`
|
|
58
|
+
: `${String(scope)}/eslint-plugin-${String(plugin)}`,
|
|
59
|
+
name,
|
|
60
|
+
];
|
|
61
|
+
return [`eslint-plugin-${String(scope)}`, name];
|
|
62
|
+
}
|
|
63
|
+
// `require` of an ES-module plugin returns its namespace: the plugin object is then its `default`.
|
|
64
|
+
const EsmPlugin = z.object({ default: Plugin });
|
|
65
|
+
const pluginRules = (mod: unknown): Readonly<Record<string, unknown>> => {
|
|
66
|
+
const esm = EsmPlugin.safeParse(mod);
|
|
67
|
+
return esm.success ? esm.data.default.rules : Plugin.parse(mod).rules;
|
|
68
|
+
};
|
|
69
|
+
const description = (rule: unknown): string | undefined => {
|
|
70
|
+
const r = RuleModule.safeParse(rule);
|
|
71
|
+
return r.success ? r.data.meta.docs.description : undefined;
|
|
72
|
+
};
|
|
73
|
+
function eslintDescription(id: string, fromDir: string): string | undefined {
|
|
74
|
+
// createRequire takes an absolute path only; items.json is often named relative to the study folder.
|
|
75
|
+
const load = createRequire(resolve(fromDir, "items.json"));
|
|
76
|
+
try {
|
|
77
|
+
if (!id.includes("/")) {
|
|
78
|
+
const core = Builtins.parse(load("eslint/use-at-your-own-risk"));
|
|
79
|
+
return description(core.builtinRules.get(id));
|
|
80
|
+
}
|
|
81
|
+
const [pkg, name] = pluginOf(id);
|
|
82
|
+
return description(pluginRules(load(pkg))[name]);
|
|
83
|
+
} catch {
|
|
84
|
+
// ESLint or the plugin is not installed where items.json is: the linter cannot answer.
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The linter's own one-line description of `ref`, or undefined when it has none. */
|
|
90
|
+
export function describeRule(
|
|
91
|
+
ref: RuleRef,
|
|
92
|
+
fromDir: string,
|
|
93
|
+
run: RunProcess = runProcess,
|
|
94
|
+
): string | undefined {
|
|
95
|
+
return ref.linter === "ruff"
|
|
96
|
+
? ruffDescription(ref.id, run)
|
|
97
|
+
: eslintDescription(ref.id, fromDir);
|
|
98
|
+
}
|