paperlint 13.5.0 → 13.6.1
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/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/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
|
@@ -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
|
+
});
|