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
|
@@ -8,7 +8,11 @@ import { createHash } from "node:crypto";
|
|
|
8
8
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
9
9
|
import { dirname, join } from "node:path";
|
|
10
10
|
import { z } from "zod";
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
describeRule,
|
|
13
|
+
type Described as RuleText,
|
|
14
|
+
type RuleRef,
|
|
15
|
+
} from "./rule-descriptions.ts";
|
|
12
16
|
|
|
13
17
|
// ── refusals and output ───────────────────────────────────────────────────────────────────────
|
|
14
18
|
export class Refusal extends Error {
|
|
@@ -93,18 +97,22 @@ const readJson = (path: string): Json => {
|
|
|
93
97
|
// stops the build: a blind page fails by what leaked in, and a field nobody listed is exactly the
|
|
94
98
|
// one nobody thought of. A NEW FIELD IS ADDED HERE ON PURPOSE, after asking "does this tell the
|
|
95
99
|
// labeller what the answer is, or where the item came from?" — never to make a build pass.
|
|
96
|
-
|
|
100
|
+
// prettier-ignore
|
|
101
|
+
const PAGE_FIELDS = ["group", "title", "body", "code", "context", "file"] as const;
|
|
97
102
|
const PRIVATE_FIELDS = ["source_id"] as const;
|
|
98
103
|
// The linter rules an item names ("read as ruff `TID253`"): the page shows each with the linter's
|
|
99
104
|
// own description, looked up at build time (rule-descriptions.ts), never typed into items.json.
|
|
100
105
|
const RULES_FIELD = "rules";
|
|
101
|
-
//
|
|
102
|
-
// the
|
|
106
|
+
// The source line an item was drawn from, with its requirement marked (`mark`, words of the line)
|
|
107
|
+
// and which part of the line it is. Shown on the card; the line is text the labeller may read.
|
|
103
108
|
const UNIT_FIELD = "unit";
|
|
109
|
+
// Two readings of one requirement, shown as rows A and B (#374).
|
|
110
|
+
const READINGS_FIELD = "readings";
|
|
104
111
|
const ALLOWED_FIELDS: readonly string[] = [
|
|
105
112
|
...PAGE_FIELDS,
|
|
106
113
|
RULES_FIELD,
|
|
107
114
|
UNIT_FIELD,
|
|
115
|
+
READINGS_FIELD,
|
|
108
116
|
...PRIVATE_FIELDS,
|
|
109
117
|
];
|
|
110
118
|
|
|
@@ -140,8 +148,9 @@ const SOURCES_SHOWN = `(${SOURCES.map(quoted).join(", ")})`;
|
|
|
140
148
|
// the field's path (`questions[0].options[1].label`). A NEW FIELD IS A NEW LINE HERE; the
|
|
141
149
|
// build-label-page test walks this very table (`schema` prints it) with a wrong type, null and an
|
|
142
150
|
// empty value for each path, so a field without a line is a test failure, not a review finding.
|
|
143
|
-
// The leak guard on items (ALLOWED_FIELDS) stays separate and stays closed.
|
|
144
|
-
|
|
151
|
+
// The leak guard on items (ALLOWED_FIELDS) stays separate and stays closed. `why` says, in a
|
|
152
|
+
// refusal, what a value must be (a pattern) or why a field is required (a page kind needs it).
|
|
153
|
+
type SchemaNode = (
|
|
145
154
|
| { kind: "string"; required: boolean; pattern?: string }
|
|
146
155
|
| { kind: "integer"; required: boolean }
|
|
147
156
|
| { kind: "boolean"; required: boolean }
|
|
@@ -158,12 +167,18 @@ type SchemaNode =
|
|
|
158
167
|
required: boolean;
|
|
159
168
|
closed: boolean;
|
|
160
169
|
fields: Readonly<Record<string, SchemaNode>>;
|
|
161
|
-
}
|
|
170
|
+
}
|
|
171
|
+
) & { why?: string };
|
|
162
172
|
|
|
163
|
-
const string = (required = true, pattern?: string): SchemaNode =>
|
|
173
|
+
const string = (required = true, pattern?: string, why?: string): SchemaNode =>
|
|
164
174
|
pattern === undefined
|
|
165
175
|
? { kind: "string", required }
|
|
166
|
-
: {
|
|
176
|
+
: {
|
|
177
|
+
kind: "string",
|
|
178
|
+
required,
|
|
179
|
+
pattern,
|
|
180
|
+
...(why === undefined ? {} : { why }),
|
|
181
|
+
};
|
|
167
182
|
const integer = (required = true): SchemaNode => ({
|
|
168
183
|
kind: "integer",
|
|
169
184
|
required,
|
|
@@ -195,26 +210,82 @@ const obj = (
|
|
|
195
210
|
required = true,
|
|
196
211
|
): SchemaNode => ({ kind: "object", required, closed, fields });
|
|
197
212
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
213
|
+
// What an item and a practice item both may carry beyond text: a practice card looks like the real
|
|
214
|
+
// cards it rehearses. Each is optional here; a page's `kind` makes some of them required (KINDS).
|
|
215
|
+
const CARD_FIELDS: Readonly<Record<string, SchemaNode>> = {
|
|
216
|
+
[RULES_FIELD]: array(
|
|
217
|
+
obj({
|
|
218
|
+
linter: string(true, "eslint|ruff", "eslint or ruff"),
|
|
219
|
+
id: string(),
|
|
220
|
+
}),
|
|
221
|
+
false,
|
|
222
|
+
1,
|
|
223
|
+
),
|
|
224
|
+
[UNIT_FIELD]: obj(
|
|
225
|
+
// `id` names one occurrence of the line (two files may hold the same words): never shown.
|
|
226
|
+
{
|
|
227
|
+
id: string(),
|
|
228
|
+
text: string(),
|
|
229
|
+
mark: string(),
|
|
230
|
+
part: integer(),
|
|
231
|
+
of: integer(),
|
|
232
|
+
},
|
|
233
|
+
true,
|
|
234
|
+
false,
|
|
235
|
+
),
|
|
236
|
+
[READINGS_FIELD]: obj({ a: string(), b: string() }, true, false),
|
|
237
|
+
file: string(false),
|
|
238
|
+
};
|
|
239
|
+
/**
|
|
240
|
+
* THE PAGE KINDS, and the card fields each requires on every item and practice card (#373). A fix to
|
|
241
|
+
* a card is a field; a kind that needs it makes it required, so a page built without it fails the
|
|
242
|
+
* schema with the field named instead of shipping the old card.
|
|
243
|
+
*/
|
|
244
|
+
type Kind = "approval" | "witness" | "labels";
|
|
245
|
+
const KINDS: Readonly<Record<Kind, Readonly<Record<string, string>>>> = {
|
|
246
|
+
// keep or drop a drawn requirement: the line it came from, marked, and the rule read for it
|
|
247
|
+
approval: {
|
|
248
|
+
[UNIT_FIELD]:
|
|
249
|
+
"an approval card shows the line its requirement was drawn from, the requirement marked",
|
|
250
|
+
[RULES_FIELD]:
|
|
251
|
+
"an approval card shows the rule read for it, with the linter's own description",
|
|
215
252
|
},
|
|
216
|
-
|
|
217
|
-
|
|
253
|
+
// why two readings of one requirement disagree on a file
|
|
254
|
+
witness: {
|
|
255
|
+
[RULES_FIELD]:
|
|
256
|
+
"a witness card shows the rule both readings configure, with the linter's description",
|
|
257
|
+
[READINGS_FIELD]: "a witness card shows the two readings, one row each",
|
|
258
|
+
file: "a witness card shows the file the readings disagree on, on its own row",
|
|
259
|
+
},
|
|
260
|
+
// gold labels, a blind review: the card is the item, nothing more is required
|
|
261
|
+
labels: {},
|
|
262
|
+
};
|
|
263
|
+
const KIND_NAMES = Object.keys(KINDS).join(", ");
|
|
264
|
+
/** The card fields of a page of `kind`: those it needs, required and saying why. */
|
|
265
|
+
const cardFields = (kind: Kind): Readonly<Record<string, SchemaNode>> =>
|
|
266
|
+
Object.fromEntries(
|
|
267
|
+
Object.entries(CARD_FIELDS).map(([name, node]) => {
|
|
268
|
+
const why = KINDS[kind][name];
|
|
269
|
+
return [
|
|
270
|
+
name,
|
|
271
|
+
why === undefined ? node : { ...node, required: true, why },
|
|
272
|
+
];
|
|
273
|
+
}),
|
|
274
|
+
);
|
|
275
|
+
const itemSchema = (kind: Kind): SchemaNode =>
|
|
276
|
+
obj(
|
|
277
|
+
{
|
|
278
|
+
source_id: string(),
|
|
279
|
+
...Object.fromEntries(
|
|
280
|
+
PAGE_FIELDS.filter((f) => f !== "file").map((f) => [
|
|
281
|
+
f,
|
|
282
|
+
string(f === "group" || f === "body"),
|
|
283
|
+
]),
|
|
284
|
+
),
|
|
285
|
+
...cardFields(kind),
|
|
286
|
+
},
|
|
287
|
+
false, // an unlisted item field is the leak guard's refusal, with its own message
|
|
288
|
+
);
|
|
218
289
|
// `icon` (an emoji) tells the buttons apart at a glance. No coloured side stripe on a button: it
|
|
219
290
|
// reads as decoration, not meaning (the build-label-page look test refuses one).
|
|
220
291
|
// `ask_why` (a prompt) makes the answer incomplete until the labeller says why: an approval page's "drop".
|
|
@@ -225,16 +296,18 @@ const OPTION_SCHEMA = obj({
|
|
|
225
296
|
icon: string(false),
|
|
226
297
|
ask_why: string(false),
|
|
227
298
|
});
|
|
228
|
-
const
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
f
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
299
|
+
const practiceSchema = (kind: Kind): SchemaNode =>
|
|
300
|
+
obj({
|
|
301
|
+
...Object.fromEntries(
|
|
302
|
+
PAGE_FIELDS.filter((f) => f !== "group" && f !== "file").map((f) => [
|
|
303
|
+
f,
|
|
304
|
+
string(f === "body"),
|
|
305
|
+
]),
|
|
306
|
+
),
|
|
307
|
+
...cardFields(kind),
|
|
308
|
+
expected: mapping(string(), NAME_PATTERN, 1),
|
|
309
|
+
why: string(),
|
|
310
|
+
});
|
|
238
311
|
const MIN_PRACTICE = 3;
|
|
239
312
|
const QUESTION_SCHEMA = obj({
|
|
240
313
|
id: string(true, NAME_PATTERN),
|
|
@@ -242,22 +315,26 @@ const QUESTION_SCHEMA = obj({
|
|
|
242
315
|
short: string(false),
|
|
243
316
|
options: array(OPTION_SCHEMA, true, 2),
|
|
244
317
|
});
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
318
|
+
const configSchema = (kind: Kind): SchemaNode =>
|
|
319
|
+
obj({
|
|
320
|
+
study: string(true, NAME_PATTERN), // becomes a file name
|
|
321
|
+
kind: string(true, Object.keys(KINDS).join("|"), `one of ${KIND_NAMES}`),
|
|
322
|
+
title: string(),
|
|
323
|
+
intro: string(false),
|
|
324
|
+
seed: integer(),
|
|
325
|
+
groups: mapping(string(), NAME_PATTERN, 1), // ids become store document ids
|
|
326
|
+
labeller_knows: array(string(true, NAME_PATTERN), false),
|
|
327
|
+
// true: a "how well do you know {group}" question per group, for a study that filters on it
|
|
328
|
+
// (rule 5). Left out, none is asked: an approval page's author has never seen the repositories.
|
|
329
|
+
familiarity: boolean(false),
|
|
330
|
+
familiarity_question: string(false),
|
|
331
|
+
questions: array(QUESTION_SCHEMA, true, 1),
|
|
332
|
+
practice: array(practiceSchema(kind), true, MIN_PRACTICE),
|
|
333
|
+
practice_round: integer(false),
|
|
334
|
+
items: array(itemSchema(kind), true, 1),
|
|
335
|
+
});
|
|
336
|
+
/** The declared schema (`schema` prints it): every field, those a kind requires left optional. */
|
|
337
|
+
export const CONFIG_SCHEMA = configSchema("labels");
|
|
261
338
|
|
|
262
339
|
// ── checking a value against the schema ──────────────────────────────────────────────────────
|
|
263
340
|
const at = (path: string, name: string): string =>
|
|
@@ -268,13 +345,13 @@ const ID_RULE = "letters, digits, - and _ only";
|
|
|
268
345
|
|
|
269
346
|
function checkString(
|
|
270
347
|
value: Json,
|
|
271
|
-
node: { pattern?: string },
|
|
348
|
+
node: { pattern?: string; why?: string },
|
|
272
349
|
where: string,
|
|
273
350
|
): void {
|
|
274
351
|
if (typeof value !== "string" || !value.trim()) {
|
|
275
352
|
fail(`${where}: must be a non-empty string, got ${repr(value)}`);
|
|
276
353
|
} else if (node.pattern !== undefined && !fullMatch(node.pattern, value)) {
|
|
277
|
-
fail(`${where}: ${repr(value)} must be ${ID_RULE}`);
|
|
354
|
+
fail(`${where}: ${repr(value)} must be ${node.why ?? ID_RULE}`);
|
|
278
355
|
}
|
|
279
356
|
}
|
|
280
357
|
const checkCount = (n: number, min: number, where: string): void => {
|
|
@@ -319,7 +396,10 @@ function checkObject(
|
|
|
319
396
|
return fail(`${where}: must be an object, got ${repr(value)}`);
|
|
320
397
|
for (const [name, sub] of Object.entries(node.fields)) {
|
|
321
398
|
if (has(value, name)) checkValue(value[name] ?? null, sub, at(path, name));
|
|
322
|
-
else if (sub.required)
|
|
399
|
+
else if (sub.required)
|
|
400
|
+
fail(
|
|
401
|
+
`${at(path, name)}: is required and missing${sub.why === undefined ? "" : ` — ${sub.why}`}`,
|
|
402
|
+
);
|
|
323
403
|
}
|
|
324
404
|
const unknown = Object.keys(value).find((name) => !has(node.fields, name));
|
|
325
405
|
if (node.closed && unknown !== undefined) {
|
|
@@ -375,28 +455,27 @@ interface Question {
|
|
|
375
455
|
interface Unit {
|
|
376
456
|
readonly id: string;
|
|
377
457
|
readonly text: string;
|
|
458
|
+
readonly mark: string;
|
|
378
459
|
readonly part: number;
|
|
379
460
|
readonly of: number;
|
|
380
461
|
}
|
|
381
|
-
|
|
462
|
+
/** What a card shows, on an item and on a practice item alike. */
|
|
463
|
+
type Card = {
|
|
382
464
|
readonly [F in (typeof PAGE_FIELDS)[number]]?: string;
|
|
383
465
|
} & {
|
|
384
|
-
readonly source_id: string;
|
|
385
|
-
readonly group: string;
|
|
386
466
|
readonly body: string;
|
|
387
467
|
readonly rules?: readonly RuleRef[];
|
|
388
468
|
readonly unit?: Unit;
|
|
469
|
+
readonly readings?: { readonly a: string; readonly b: string };
|
|
389
470
|
};
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
readonly body: string;
|
|
393
|
-
readonly code?: string;
|
|
394
|
-
readonly context?: string;
|
|
471
|
+
type Item = Card & { readonly source_id: string; readonly group: string };
|
|
472
|
+
type Practice = Card & {
|
|
395
473
|
readonly expected: Readonly<Record<string, string>>;
|
|
396
474
|
readonly why: string;
|
|
397
|
-
}
|
|
475
|
+
};
|
|
398
476
|
interface Config {
|
|
399
477
|
readonly study: string;
|
|
478
|
+
readonly kind: Kind;
|
|
400
479
|
readonly title: string;
|
|
401
480
|
readonly intro?: string;
|
|
402
481
|
readonly seed: number;
|
|
@@ -436,7 +515,7 @@ const allowedValues = (
|
|
|
436
515
|
// Empty when the page asks no familiarity: the export then expects no group document at all.
|
|
437
516
|
familiarity: asksFamiliarity ? FAMILIARITY.map((o) => o.value) : [],
|
|
438
517
|
});
|
|
439
|
-
const asksFamiliarity = (cfg: Config): boolean => cfg.familiarity
|
|
518
|
+
const asksFamiliarity = (cfg: Config): boolean => cfg.familiarity === true;
|
|
440
519
|
|
|
441
520
|
/** Two values that read the same once case and surrounding space are gone. */
|
|
442
521
|
const readsAs = (s: string): string => s.trim().toLowerCase();
|
|
@@ -522,7 +601,7 @@ function checkConfig(cfg: Config): void {
|
|
|
522
601
|
checkGroups(cfg);
|
|
523
602
|
if (!asksFamiliarity(cfg) && cfg.familiarity_question !== undefined) {
|
|
524
603
|
fail(
|
|
525
|
-
`familiarity_question: ${repr(cfg.familiarity_question)} is never asked, because \`familiarity\` is
|
|
604
|
+
`familiarity_question: ${repr(cfg.familiarity_question)} is never asked, because \`familiarity\` is not true — remove it, or set "familiarity": true`,
|
|
526
605
|
);
|
|
527
606
|
}
|
|
528
607
|
cfg.questions.forEach((q, qn) => {
|
|
@@ -544,8 +623,9 @@ const practiceOf = (
|
|
|
544
623
|
// ── the items: the leak guard, and the references to the config ───────────────────────────────
|
|
545
624
|
/**
|
|
546
625
|
* A line with several requirements is split upstream into one item per requirement, each carrying
|
|
547
|
-
* `unit: {text, part, of}`.
|
|
548
|
-
*
|
|
626
|
+
* `unit: {text, mark, part, of}`. A drawn sample may hold one part of a line and not the others; the
|
|
627
|
+
* parts it holds agree on the line, are not repeated, and sit in one group (so `labeller_knows`
|
|
628
|
+
* keeps or drops them together).
|
|
549
629
|
*/
|
|
550
630
|
interface Part {
|
|
551
631
|
readonly item: Item;
|
|
@@ -576,10 +656,6 @@ function checkPart({ item, unit, n }: Part, parts: Parts): void {
|
|
|
576
656
|
fail(
|
|
577
657
|
`${at}.unit.of: ${String(unit.of)}, but items[${String(first.n)}].unit.of says ${String(first.unit.of)} for the same line`,
|
|
578
658
|
);
|
|
579
|
-
if (unit.part < 1 || unit.part > unit.of)
|
|
580
|
-
fail(
|
|
581
|
-
`${at}.unit.part: ${String(unit.part)} is not between 1 and ${String(unit.of)}`,
|
|
582
|
-
);
|
|
583
659
|
const earlier = parts.find((p) => p.n < n && p.unit.part === unit.part);
|
|
584
660
|
if (earlier !== undefined)
|
|
585
661
|
fail(
|
|
@@ -590,21 +666,33 @@ function checkPart({ item, unit, n }: Part, parts: Parts): void {
|
|
|
590
666
|
`${at}.group: ${repr(item.group)}, but items[${String(first.n)}] of the same line is in ${repr(first.item.group)}`,
|
|
591
667
|
);
|
|
592
668
|
}
|
|
669
|
+
/** One unit on its own: its part in range, and its mark the requirement's words in the line. */
|
|
670
|
+
function checkUnit(unit: Unit | undefined, at: string): void {
|
|
671
|
+
if (unit === undefined) return;
|
|
672
|
+
if (unit.part < 1 || unit.part > unit.of)
|
|
673
|
+
fail(
|
|
674
|
+
`${at}.unit.part: ${String(unit.part)} is not between 1 and ${String(unit.of)}`,
|
|
675
|
+
);
|
|
676
|
+
if (!unit.text.includes(unit.mark))
|
|
677
|
+
fail(
|
|
678
|
+
`${at}.unit.mark: ${repr(unit.mark)} is not in the line ${repr(unit.text)} — mark the requirement with the line's own words`,
|
|
679
|
+
);
|
|
680
|
+
}
|
|
593
681
|
function checkUnits(cfg: Config): void {
|
|
594
|
-
|
|
682
|
+
cardsOf(cfg).forEach(([at, card]) => {
|
|
683
|
+
checkUnit(card.unit, at);
|
|
684
|
+
});
|
|
685
|
+
for (const parts of linesOf(cfg).values()) {
|
|
595
686
|
parts.forEach((p) => {
|
|
596
687
|
checkPart(p, parts);
|
|
597
688
|
});
|
|
598
|
-
const [first] = parts;
|
|
599
|
-
const missing = Array.from({ length: first.unit.of }, (_, k) => k + 1).find(
|
|
600
|
-
(k) => !parts.some((p) => p.unit.part === k),
|
|
601
|
-
);
|
|
602
|
-
if (missing !== undefined)
|
|
603
|
-
fail(
|
|
604
|
-
`items[${String(first.n)}].unit: part ${String(missing)} of ${String(first.unit.of)} of line ${repr(line)} is not among the items`,
|
|
605
|
-
);
|
|
606
689
|
}
|
|
607
690
|
}
|
|
691
|
+
/** Every card of the config, items then practice, by path. */
|
|
692
|
+
const cardsOf = (cfg: Config) => [
|
|
693
|
+
...cfg.items.map((i, n) => [`items[${String(n)}]`, i] as const),
|
|
694
|
+
...cfg.practice.map((p, n) => [`practice[${String(n)}]`, p] as const),
|
|
695
|
+
];
|
|
608
696
|
function checkItemFields(cfg: Config): void {
|
|
609
697
|
const sids: string[] = [];
|
|
610
698
|
cfg.items.forEach((item, n) => {
|
|
@@ -631,6 +719,7 @@ function checkItemFields(cfg: Config): void {
|
|
|
631
719
|
const visibleTexts = (item: Item): readonly (readonly [string, string])[] => [
|
|
632
720
|
...PAGE_FIELDS.map((f): readonly [string, string] => [f, item[f] ?? ""]),
|
|
633
721
|
[UNIT_FIELD, item.unit?.text ?? ""],
|
|
722
|
+
[READINGS_FIELD, `${item.readings?.a ?? ""} ${item.readings?.b ?? ""}`],
|
|
634
723
|
];
|
|
635
724
|
const escapeRegExp = (s: string): string =>
|
|
636
725
|
s.replace(/[\\^$.*+?()[\]{}|/]/g, "\\$&");
|
|
@@ -715,11 +804,13 @@ function assignIds(
|
|
|
715
804
|
}
|
|
716
805
|
|
|
717
806
|
// ── a build, short of the page ──────────────────────────────────────────────────────────────────
|
|
718
|
-
/** A unit as the page shows it: the
|
|
807
|
+
/** A unit as the page shows it: the line, its mark and its part, without the line's private id. */
|
|
719
808
|
type ShownUnit = Omit<Unit, "id">;
|
|
720
809
|
const shownFields = (
|
|
721
|
-
item:
|
|
722
|
-
): Readonly<
|
|
810
|
+
item: Card,
|
|
811
|
+
): Readonly<
|
|
812
|
+
Record<string, string | ShownUnit | NonNullable<Card["readings"]>>
|
|
813
|
+
> => ({
|
|
723
814
|
...Object.fromEntries(
|
|
724
815
|
PAGE_FIELDS.flatMap((f) => {
|
|
725
816
|
const v = item[f];
|
|
@@ -731,37 +822,39 @@ const shownFields = (
|
|
|
731
822
|
: {
|
|
732
823
|
[UNIT_FIELD]: {
|
|
733
824
|
text: item.unit.text,
|
|
825
|
+
mark: item.unit.mark,
|
|
734
826
|
part: item.unit.part,
|
|
735
827
|
of: item.unit.of,
|
|
736
828
|
},
|
|
737
829
|
}),
|
|
830
|
+
...(item.readings === undefined ? {} : { [READINGS_FIELD]: item.readings }),
|
|
738
831
|
});
|
|
739
|
-
/** Rule → the linter's description, for every rule
|
|
740
|
-
type Described = ReadonlyMap<string,
|
|
832
|
+
/** Rule → the linter's description (and a ruff rule's family), for every rule a card names. */
|
|
833
|
+
type Described = ReadonlyMap<string, RuleText>;
|
|
741
834
|
const ruleKey = (r: RuleRef): string => `${r.linter} ${r.id}`;
|
|
742
835
|
/** Ask the linter for every rule named; refuse, by path, a rule it cannot describe. */
|
|
743
836
|
function describeRules(cfg: Config, fromDir: string): Described {
|
|
744
837
|
return new Map(
|
|
745
|
-
cfg.
|
|
746
|
-
(
|
|
838
|
+
cardsOf(cfg).flatMap(([at, card]) =>
|
|
839
|
+
(card.rules ?? []).map((r, k): readonly [string, RuleText] => {
|
|
747
840
|
const text = describeRule(r, fromDir);
|
|
748
841
|
return text === undefined
|
|
749
842
|
? fail(
|
|
750
|
-
|
|
843
|
+
`${at}.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
844
|
)
|
|
752
845
|
: [ruleKey(r), text];
|
|
753
846
|
}),
|
|
754
847
|
),
|
|
755
848
|
);
|
|
756
849
|
}
|
|
757
|
-
const rulesShown = (
|
|
758
|
-
|
|
850
|
+
const rulesShown = (card: Card, described: Described) =>
|
|
851
|
+
card.rules === undefined
|
|
759
852
|
? {}
|
|
760
853
|
: {
|
|
761
|
-
[RULES_FIELD]:
|
|
854
|
+
[RULES_FIELD]: card.rules.map((r) => ({
|
|
762
855
|
linter: r.linter,
|
|
763
856
|
id: r.id,
|
|
764
|
-
|
|
857
|
+
...described.get(ruleKey(r)),
|
|
765
858
|
})),
|
|
766
859
|
};
|
|
767
860
|
|
|
@@ -780,6 +873,15 @@ function itemsOnPage(cfg: Config, keyPath: string) {
|
|
|
780
873
|
}
|
|
781
874
|
const practiceRound = (cfg: Config): number => cfg.practice_round ?? 1;
|
|
782
875
|
|
|
876
|
+
/** The practice as its cards show it, with the answers the page checks against. */
|
|
877
|
+
const practiceShown = (cfg: Config, described: Described) =>
|
|
878
|
+
practiceOf(cfg).map((p) => ({
|
|
879
|
+
id: p.id,
|
|
880
|
+
expected: p.expected,
|
|
881
|
+
why: p.why,
|
|
882
|
+
...shownFields(p),
|
|
883
|
+
...rulesShown(p, described),
|
|
884
|
+
}));
|
|
783
885
|
/** What a page shows: the questions, the practice and the items, blind, with their neutral ids. */
|
|
784
886
|
function payloadOf(
|
|
785
887
|
cfg: Config,
|
|
@@ -799,7 +901,10 @@ function payloadOf(
|
|
|
799
901
|
familiarity: asksFamiliarity(cfg) ? FAMILIARITY : [],
|
|
800
902
|
allowed: allowedValues(cfg.questions, asksFamiliarity(cfg)),
|
|
801
903
|
questions: cfg.questions,
|
|
802
|
-
practice: {
|
|
904
|
+
practice: {
|
|
905
|
+
round: practiceRound(cfg),
|
|
906
|
+
items: practiceShown(cfg, described),
|
|
907
|
+
},
|
|
803
908
|
items: page.items.map((i) => ({
|
|
804
909
|
id: page.idOf(i),
|
|
805
910
|
...shownFields(i),
|
|
@@ -880,6 +985,8 @@ function keyOf(
|
|
|
880
985
|
export function prepareBuild(configPath: string, outDir: string) {
|
|
881
986
|
const cfg = readJson(configPath);
|
|
882
987
|
assertConfig(cfg);
|
|
988
|
+
// Once its `kind` is known to be one, the fields that kind needs on every card.
|
|
989
|
+
checkValue(cfg, configSchema(cfg.kind), "");
|
|
883
990
|
checkConfig(cfg);
|
|
884
991
|
checkItemFields(cfg);
|
|
885
992
|
checkUnits(cfg);
|
|
@@ -1119,7 +1226,7 @@ function checkNoFamiliarity(dump: JsonObject, allowed: Allowed): void {
|
|
|
1119
1226
|
const [first] = Object.keys(groups).sort(byText);
|
|
1120
1227
|
if (allowed.familiarity.length === 0 && first !== undefined) {
|
|
1121
1228
|
fail(
|
|
1122
|
-
`groups/${first}: familiarity is off for this page (items.json \`familiarity:
|
|
1229
|
+
`groups/${first}: familiarity is off for this page (items.json does not set \`"familiarity": true\`), so no group document is expected — take it out of the dump`,
|
|
1123
1230
|
);
|
|
1124
1231
|
}
|
|
1125
1232
|
}
|
|
@@ -412,29 +412,52 @@ 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("🔴
|
|
415
|
+
test("🔴 a card shows each field on its own labelled row: the line with its requirement marked, the rule, readings A and B, the file", async () => {
|
|
416
|
+
const line = "TypeScript only (no JavaScript); top-level await is allowed.";
|
|
417
|
+
const card = {
|
|
418
|
+
body: "top-level await is allowed",
|
|
419
|
+
unit: {
|
|
420
|
+
id: "L1",
|
|
421
|
+
text: line,
|
|
422
|
+
mark: "top-level await is allowed",
|
|
423
|
+
part: 2,
|
|
424
|
+
of: 2,
|
|
425
|
+
},
|
|
426
|
+
rules: [{ linter: "eslint", id: "no-console" }],
|
|
427
|
+
readings: { a: "allows it", b: "forbids it" },
|
|
428
|
+
file: "`src/a.ts`: B flags it",
|
|
429
|
+
};
|
|
416
430
|
const { page } = build(
|
|
417
431
|
setup(
|
|
418
|
-
"
|
|
419
|
-
config({
|
|
420
|
-
items: ITEMS.map((item, n) =>
|
|
421
|
-
n === 0
|
|
422
|
-
? { ...item, rules: [{ linter: "eslint", id: "no-console" }] }
|
|
423
|
-
: item,
|
|
424
|
-
),
|
|
425
|
-
}),
|
|
432
|
+
"card-rows",
|
|
433
|
+
config({ items: ITEMS.map((i, n) => (n === 0 ? { ...i, ...card } : i)) }),
|
|
426
434
|
),
|
|
427
435
|
);
|
|
428
|
-
const
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
436
|
+
const { app } = await openLive(page, { practice: DONE_PRACTICE });
|
|
437
|
+
// Guards: a short label per row, in this order, the two readings told apart by their class.
|
|
438
|
+
assert.deepEqual(
|
|
439
|
+
app
|
|
440
|
+
.all()
|
|
441
|
+
.filter((n) => n.className.startsWith("row"))
|
|
442
|
+
.map((n) => [n.className, ...n.children.map((c) => c.textContent)]),
|
|
443
|
+
[
|
|
444
|
+
[
|
|
445
|
+
"row",
|
|
446
|
+
"Line · part 2 of 2",
|
|
447
|
+
"TypeScript only (no JavaScript); \ntop-level await is allowed\n.",
|
|
448
|
+
],
|
|
449
|
+
["row", "Rule", "eslint \nno-console\n: Disallow the use of \nconsole"],
|
|
450
|
+
["row reading a", "Reading A", "allows it"],
|
|
451
|
+
["row reading b", "Reading B", "forbids it"],
|
|
452
|
+
["row", "File", "src/a.ts\n: B flags it"],
|
|
453
|
+
],
|
|
433
454
|
);
|
|
434
|
-
assert.
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
455
|
+
assert.deepEqual(
|
|
456
|
+
app
|
|
457
|
+
.all()
|
|
458
|
+
.filter((n) => n.tag === "mark")
|
|
459
|
+
.map((n) => n.textContent),
|
|
460
|
+
["top-level await is allowed"],
|
|
438
461
|
);
|
|
439
462
|
});
|
|
440
463
|
|
|
@@ -459,41 +482,3 @@ test("🔴 familiarity off: no group asks how well it is known, and a group is d
|
|
|
459
482
|
assert.doesNotMatch(shown, /how well you know it/);
|
|
460
483
|
assert.match(shown, /Nothing left/, shown);
|
|
461
484
|
});
|
|
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
|
-
});
|