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.
@@ -81,11 +81,11 @@ the text of an allowed field says.
81
81
 
82
82
  - Restrict the items to the languages and tools the labeller knows (`labeller_knows`). Items of
83
83
  other groups are not put on the page and get no id.
84
- - Add one question per group, "how well do you know this language / tool" (well / somewhat / not
85
- really). It is an analysis filter: the study can report the labels with and without the groups
86
- the labeller does not know well. An approval page asks none (`"familiarity": false` in
87
- `items.json`): its question is about a drawn unit, not about repositories the author has seen,
88
- and an answer to "how well do you know the projects of this group" there means nothing.
84
+ - When the study filters on it, ask one question per group, "how well do you know this language /
85
+ tool" (well / somewhat / not really), with `"familiarity": true` in `items.json`: the study can
86
+ then report the labels with and without the groups the labeller does not know well. Left out, no
87
+ page asks it: on an approval page the question is about a drawn unit, not about repositories the
88
+ author has seen, and "how well do you know the projects of this group" there means nothing.
89
89
  - The per-item judgement "clear-cut or a real boundary case" is the labeller's. The study design
90
90
  does not decide it in advance and does not pre-fill it.
91
91
 
@@ -94,7 +94,7 @@ answer is the only way to find it afterwards. And a boundary case is boundary be
94
94
  person hesitates; a designer who tags it in advance has measured the design, not the item.
95
95
 
96
96
  **Check.** The build prints how many items were left out; the export has a familiarity value for
97
- every group, or the page was built with `"familiarity": false` and the export has no groups.
97
+ every group, or the page was built without `"familiarity": true` and the export has no groups.
98
98
 
99
99
  ## 6. Export into the repository right away
100
100
 
@@ -33,24 +33,45 @@ length.
33
33
  required `example`: a real case of the study, in a line, that takes that answer. `icon` (an
34
34
  emoji) is optional. Two labels that read the same, or two equal values, stop the build.
35
35
  - **`practice`**: 3 or more real cases whose answer you are sure of, each with `expected` (one answer
36
- per question) and a one-line `why`. Never an item that is also on the page to be labelled.
36
+ per question) and a one-line `why`. Never an item that is also on the page to be labelled. A
37
+ practice card carries the same fields as the real cards it rehearses (`rules`, `unit`,
38
+ `readings`, `file`).
37
39
  - **`practice_round`**: raise it after rewording a question; the practice reopens and the answers of
38
40
  the earlier round are kept but not counted.
39
41
  - **`items`**: `source_id` (the study's own, never shown), `group`, `body`, and optionally `title`,
40
- `code`, `context`, `rules`, `unit`. Nothing else: the allow-list is fixed in the generator, and a field outside it
41
- is taken out of the item, not added to the list to make a build pass.
42
+ `code`, `context`, `rules`, `unit`, `readings`, `file`. Nothing else: the allow-list is fixed in
43
+ the generator, and a field outside it is taken out of the item, not added to the list to make a
44
+ build pass.
45
+ - **`kind`** (required): what the page asks, and so which card fields every item and practice card
46
+ must carry. A card without one fails the schema, naming the field and why the kind needs it:
47
+
48
+ | kind | the labeller decides | required on every card |
49
+ | ---------- | ---------------------------------------------------- | -------------------------------------------------- |
50
+ | `approval` | keep or drop a drawn requirement | `unit` (the line, the requirement marked), `rules` |
51
+ | `witness` | why two readings of a requirement disagree on a file | `rules`, `readings`, `file` |
52
+ | `labels` | a gold label, a blind review | nothing beyond the item |
53
+
54
+ Nothing reads `context`: it stays free text. A rule, a line or two readings written there show
55
+ none of what their fields show, so on an `approval` or `witness` page they go in the fields.
56
+
42
57
  - **`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
58
+ The card's "Rule" row shows "ruff `TID253` (flake8-tidy-imports): Checks for module-level
59
+ imports that should instead be imported lazily…", taken at build time from the linter itself:
60
+ `ruff rule <id>`'s "What it does" paragraph and family, or the ESLint rule's
61
+ `meta.docs.description` from the ESLint and plugins installed where `items.json` is (a plugin
62
+ rule's own name may hold a slash: `n/no-unsupported-features/node-builtins`). The build refuses a rule the linter cannot describe; a
47
63
  description is never typed into `items.json`.
48
- - **`unit`**: `{id, text, part, of}` on an item that is one requirement of a line holding several
49
- ("TypeScript only (no JavaScript); top-level await is allowed" is two). Each requirement is its
50
- own item with its own question, and its card shows "From the line (requirement 1 of 2):" and the
51
- whole line beneath, so the labeller judges one requirement at a time and sees where it came from.
52
- The build refuses a line whose parts do not add up (one missing, repeated, a different `of`, or
53
- split across groups), naming the item. `id` names one occurrence of the line (a file and line
64
+ - **`unit`**: `{id, text, mark, part, of}` on an item drawn from a source line: the whole line,
65
+ the requirement's own words in it (`mark`, shown highlighted), and which requirement of the line
66
+ it is ("TypeScript only (no JavaScript); top-level await is allowed" is two). The card's "Line"
67
+ row shows the line with the mark, and "part 1 of 2" when the line holds several. A drawn sample
68
+ may hold one part of a line and not the others; the build refuses a `mark` that is not in the
69
+ line, and parts of one line that disagree (another text or `of`, a part repeated, split across
70
+ groups), naming the item.
71
+ - **`readings`**: `{a, b}` on an item that compares two readings of one requirement: two rows,
72
+ "Reading A" on a solid accent rule and "Reading B" on a dashed one, in the same place on every
73
+ card. **`file`**: the witness file (a path, a link, what each reading did with it), on its own
74
+ "File" row. `id` names one occurrence of the line (a file and line
54
75
  number, say) and is never shown: two files holding the same words are two lines, each judged in
55
76
  full. Splitting the line is the study's job, before the page.
56
77
  - **`seed`**: fixes the order. Rebuilding with the same seed keeps every id, and a new item gets the
@@ -89,9 +110,10 @@ drop, the drop carrying `ask_why`.
89
110
  }
90
111
  ```
91
112
 
92
- The page sets `"familiarity": false`: the author is judging drawn units, not repositories they
93
- have seen, so the per-group "how well do you know it" question is left out (and a
94
- `familiarity_question` beside it is refused).
113
+ The page sets `"kind": "approval"`, so every card carries its `unit` and `rules`, and leaves
114
+ `familiarity` out: the author is judging drawn units, not repositories they have
115
+ seen, so no per-group "how well do you know it" question is asked. Only a page that sets
116
+ `"familiarity": true` asks it (and a `familiarity_question` without it is refused).
95
117
 
96
118
  The items are the drawn units verbatim, in a language the author reads. Units in other languages
97
119
  are taken out before the draw, as a population rule the study's `plan.json` states ("English-language
@@ -4,8 +4,13 @@
4
4
  * cannot show: focus, typing, and a page that is left without the box ever losing focus.
5
5
  */
6
6
  import assert from "node:assert/strict";
7
- import { chromium, type Page } from "playwright-core";
8
- import { test } from "vitest";
7
+ import {
8
+ type Browser,
9
+ chromium,
10
+ type Locator,
11
+ type Page,
12
+ } from "playwright-core";
13
+ import { afterAll, test } from "vitest";
9
14
  import { z } from "zod";
10
15
  import { chromiumPath } from "../../../test/chromium.ts";
11
16
  import {
@@ -66,6 +71,20 @@ async function reasonStored(page: Page, text: string): Promise<void> {
66
71
  assert.equal(await storedReason(page), text);
67
72
  }
68
73
 
74
+ /**
75
+ * One Chromium for the whole file, each test in a context of its own (`newPage` opens one): a launch
76
+ * per test cost about 5 s apiece on the macOS runner, and the file's wall time grew with every test.
77
+ */
78
+ let launched: Browser | undefined;
79
+ const sharedBrowser = async (): Promise<Browser> =>
80
+ (launched ??= await chromium.launch({
81
+ executablePath: chromiumPath(),
82
+ args: ["--no-sandbox"],
83
+ }));
84
+ afterAll(async () => {
85
+ await launched?.close();
86
+ });
87
+
69
88
  /** A browser test launches Chromium and types: far above vitest's 5 s default on a slow runner. */
70
89
  const BROWSER_TEST_MS = 60_000;
71
90
 
@@ -76,12 +95,9 @@ async function onApprovalPage(
76
95
  filter: "all" | "next" = "all",
77
96
  ): Promise<void> {
78
97
  const { page: html } = build(setup(name, approval()));
79
- const browser = await chromium.launch({
80
- executablePath: chromiumPath(),
81
- args: ["--no-sandbox"],
82
- });
98
+ const browser = await sharedBrowser();
99
+ const page = await browser.newPage();
83
100
  try {
84
- const page = await browser.newPage();
85
101
  await page.addInitScript(STORE(PRACTICE_DONE, echoMs));
86
102
  await page.goto("about:blank");
87
103
  await page.setContent(html, { waitUntil: "domcontentloaded" });
@@ -90,7 +106,7 @@ async function onApprovalPage(
90
106
  await page.getByRole("button", { name: /Drop/ }).first().click();
91
107
  await steps(page);
92
108
  } finally {
93
- await browser.close();
109
+ await page.context().close();
94
110
  }
95
111
  }
96
112
 
@@ -163,12 +179,9 @@ test(
163
179
  "a Keep needs no reason and writes none: the page stays quiet",
164
180
  async () => {
165
181
  const { page: html } = build(setup("browser-keep", approval()));
166
- const browser = await chromium.launch({
167
- executablePath: chromiumPath(),
168
- args: ["--no-sandbox"],
169
- });
182
+ const browser = await sharedBrowser();
183
+ const page = await browser.newPage();
170
184
  try {
171
- const page = await browser.newPage();
172
185
  await page.addInitScript(STORE(PRACTICE_DONE, 20));
173
186
  await page.goto("about:blank");
174
187
  await page.setContent(html, { waitUntil: "domcontentloaded" });
@@ -180,7 +193,89 @@ test(
180
193
  [{}],
181
194
  );
182
195
  } finally {
183
- await browser.close();
196
+ await page.context().close();
197
+ }
198
+ },
199
+ BROWSER_TEST_MS,
200
+ );
201
+
202
+ /** A two-reading card with a long URL and compact JSON: what pushed the page sideways on a phone. */
203
+ const URL_LONG = `https://github.com/a/b/blob/${"0".repeat(40)}/src/a/long/path/to/witness.ts`;
204
+ const WITNESS = {
205
+ source_id: "s1",
206
+ group: "python",
207
+ body: "Warn for unused variables, except those prefixed with `_`",
208
+ unit: {
209
+ id: "L",
210
+ text: "Warn for unused variables",
211
+ mark: "unused",
212
+ part: 1,
213
+ of: 1,
214
+ },
215
+ rules: [{ linter: "eslint", id: "no-console" }],
216
+ readings: {
217
+ a: `[{"argsIgnorePattern":"^_","varsIgnorePattern":"^_","caughtErrors":"all"}]`,
218
+ b: "[]",
219
+ },
220
+ file: URL_LONG,
221
+ context: URL_LONG,
222
+ };
223
+
224
+ /** Where the line, the rule, reading A, reading B and the question sit on a card, top to bottom. */
225
+ const blocksOf = async (card: Locator) =>
226
+ Promise.all(
227
+ [
228
+ ".quote",
229
+ ".row:has(.lbl:text-is('Rule'))",
230
+ ".reading.a",
231
+ ".reading.b",
232
+ ".ask",
233
+ ].map(async (sel) => card.locator(sel).first().boundingBox()),
234
+ );
235
+
236
+ test(
237
+ "🔴 at phone width the card reads by position: line, rule, reading A above reading B, then the question; nothing pushes the page sideways",
238
+ async () => {
239
+ const { page: html } = build(
240
+ setup("browser-layout", approval({ items: [WITNESS] })),
241
+ );
242
+ const page = await (
243
+ await sharedBrowser()
244
+ ).newPage({ viewport: { width: 400, height: 900 } });
245
+ try {
246
+ await page.addInitScript(STORE(PRACTICE_DONE, 20));
247
+ await page.setContent(html, { waitUntil: "domcontentloaded" });
248
+ const card = page.locator("article.item").last();
249
+ const boxes = await blocksOf(card);
250
+ const [line, rule, a, b, ask] = boxes;
251
+ assert.ok(line && rule && a && b && ask, "every block is on the card");
252
+ // Guards: each block below the one before, A and B in one column, a gap before the question.
253
+ assert.deepEqual(
254
+ boxes
255
+ .slice(1)
256
+ .map(
257
+ (x, i) =>
258
+ (x?.y ?? 0) >= (boxes[i]?.y ?? 0) + (boxes[i]?.height ?? 0),
259
+ ),
260
+ [true, true, true, true],
261
+ );
262
+ assert.equal(a.x, b.x);
263
+ assert.ok(ask.y - (b.y + b.height) >= 16, "space before the question");
264
+ assert.deepEqual(
265
+ await card
266
+ .locator(".reading")
267
+ .evaluateAll((els) =>
268
+ els.map((e) => getComputedStyle(e).borderLeftStyle),
269
+ ),
270
+ ["solid", "dashed"],
271
+ );
272
+ // Guards: a long URL or compact JSON wraps; the page itself never scrolls sideways.
273
+ assert.equal(
274
+ await page.evaluate(() => document.documentElement.scrollWidth),
275
+ 400,
276
+ );
277
+ } finally {
278
+ await page.context().close();
184
279
  }
185
280
  },
186
281
  BROWSER_TEST_MS,
@@ -8,7 +8,7 @@ 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 { describeRule } from "./rule-descriptions.js";
11
+ import { describeRule, } from "./rule-descriptions.js";
12
12
  // ── refusals and output ───────────────────────────────────────────────────────────────────────
13
13
  export class Refusal extends Error {
14
14
  exitCode;
@@ -88,18 +88,22 @@ const readJson = (path) => {
88
88
  // stops the build: a blind page fails by what leaked in, and a field nobody listed is exactly the
89
89
  // one nobody thought of. A NEW FIELD IS ADDED HERE ON PURPOSE, after asking "does this tell the
90
90
  // labeller what the answer is, or where the item came from?" — never to make a build pass.
91
- const PAGE_FIELDS = ["group", "title", "body", "code", "context"];
91
+ // prettier-ignore
92
+ const PAGE_FIELDS = ["group", "title", "body", "code", "context", "file"];
92
93
  const PRIVATE_FIELDS = ["source_id"];
93
94
  // The linter rules an item names ("read as ruff `TID253`"): the page shows each with the linter's
94
95
  // own description, looked up at build time (rule-descriptions.ts), never typed into items.json.
95
96
  const RULES_FIELD = "rules";
96
- // One requirement of a line that holds several: the whole line and which part this item is. Shown on
97
- // the card beneath the item; the line is text the labeller may read, like `context`.
97
+ // The source line an item was drawn from, with its requirement marked (`mark`, words of the line)
98
+ // and which part of the line it is. Shown on the card; the line is text the labeller may read.
98
99
  const UNIT_FIELD = "unit";
100
+ // Two readings of one requirement, shown as rows A and B (#374).
101
+ const READINGS_FIELD = "readings";
99
102
  const ALLOWED_FIELDS = [
100
103
  ...PAGE_FIELDS,
101
104
  RULES_FIELD,
102
105
  UNIT_FIELD,
106
+ READINGS_FIELD,
103
107
  ...PRIVATE_FIELDS,
104
108
  ];
105
109
  const FAMILIARITY = [
@@ -128,9 +132,14 @@ const RESERVED_NAMES = [
128
132
  const NAME_PATTERN = `(?!(?:${RESERVED_NAMES.join("|")})$)[A-Za-z0-9_-]+`;
129
133
  const SOURCES = ["page", "chat"];
130
134
  const SOURCES_SHOWN = `(${SOURCES.map(quoted).join(", ")})`;
131
- const string = (required = true, pattern) => pattern === undefined
135
+ const string = (required = true, pattern, why) => pattern === undefined
132
136
  ? { kind: "string", required }
133
- : { kind: "string", required, pattern };
137
+ : {
138
+ kind: "string",
139
+ required,
140
+ pattern,
141
+ ...(why === undefined ? {} : { why }),
142
+ };
134
143
  const integer = (required = true) => ({
135
144
  kind: "integer",
136
145
  required,
@@ -153,13 +162,56 @@ const mapping = (values, keyPattern, min = 0) => ({
153
162
  values,
154
163
  });
155
164
  const obj = (fields, closed = true, required = true) => ({ kind: "object", required, closed, fields });
156
- const ITEM_SCHEMA = obj({
157
- source_id: string(),
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),
165
+ // What an item and a practice item both may carry beyond text: a practice card looks like the real
166
+ // cards it rehearses. Each is optional here; a page's `kind` makes some of them required (KINDS).
167
+ const CARD_FIELDS = {
168
+ [RULES_FIELD]: array(obj({
169
+ linter: string(true, "eslint|ruff", "eslint or ruff"),
170
+ id: string(),
171
+ }), false, 1),
160
172
  [UNIT_FIELD]: obj(
161
173
  // `id` names one occurrence of the line (two files may hold the same words): never shown.
162
- { id: string(), text: string(), part: integer(), of: integer() }, true, false),
174
+ {
175
+ id: string(),
176
+ text: string(),
177
+ mark: string(),
178
+ part: integer(),
179
+ of: integer(),
180
+ }, true, false),
181
+ [READINGS_FIELD]: obj({ a: string(), b: string() }, true, false),
182
+ file: string(false),
183
+ };
184
+ const KINDS = {
185
+ // keep or drop a drawn requirement: the line it came from, marked, and the rule read for it
186
+ approval: {
187
+ [UNIT_FIELD]: "an approval card shows the line its requirement was drawn from, the requirement marked",
188
+ [RULES_FIELD]: "an approval card shows the rule read for it, with the linter's own description",
189
+ },
190
+ // why two readings of one requirement disagree on a file
191
+ witness: {
192
+ [RULES_FIELD]: "a witness card shows the rule both readings configure, with the linter's description",
193
+ [READINGS_FIELD]: "a witness card shows the two readings, one row each",
194
+ file: "a witness card shows the file the readings disagree on, on its own row",
195
+ },
196
+ // gold labels, a blind review: the card is the item, nothing more is required
197
+ labels: {},
198
+ };
199
+ const KIND_NAMES = Object.keys(KINDS).join(", ");
200
+ /** The card fields of a page of `kind`: those it needs, required and saying why. */
201
+ const cardFields = (kind) => Object.fromEntries(Object.entries(CARD_FIELDS).map(([name, node]) => {
202
+ const why = KINDS[kind][name];
203
+ return [
204
+ name,
205
+ why === undefined ? node : { ...node, required: true, why },
206
+ ];
207
+ }));
208
+ const itemSchema = (kind) => obj({
209
+ source_id: string(),
210
+ ...Object.fromEntries(PAGE_FIELDS.filter((f) => f !== "file").map((f) => [
211
+ f,
212
+ string(f === "group" || f === "body"),
213
+ ])),
214
+ ...cardFields(kind),
163
215
  }, false);
164
216
  // `icon` (an emoji) tells the buttons apart at a glance. No coloured side stripe on a button: it
165
217
  // reads as decoration, not meaning (the build-label-page look test refuses one).
@@ -171,11 +223,12 @@ const OPTION_SCHEMA = obj({
171
223
  icon: string(false),
172
224
  ask_why: string(false),
173
225
  });
174
- const PRACTICE_SCHEMA = obj({
175
- ...Object.fromEntries(PAGE_FIELDS.filter((f) => f !== "group").map((f) => [
226
+ const practiceSchema = (kind) => obj({
227
+ ...Object.fromEntries(PAGE_FIELDS.filter((f) => f !== "group" && f !== "file").map((f) => [
176
228
  f,
177
229
  string(f === "body"),
178
230
  ])),
231
+ ...cardFields(kind),
179
232
  expected: mapping(string(), NAME_PATTERN, 1),
180
233
  why: string(),
181
234
  });
@@ -186,22 +239,25 @@ const QUESTION_SCHEMA = obj({
186
239
  short: string(false),
187
240
  options: array(OPTION_SCHEMA, true, 2),
188
241
  });
189
- export const CONFIG_SCHEMA = obj({
242
+ const configSchema = (kind) => obj({
190
243
  study: string(true, NAME_PATTERN), // becomes a file name
244
+ kind: string(true, Object.keys(KINDS).join("|"), `one of ${KIND_NAMES}`),
191
245
  title: string(),
192
246
  intro: string(false),
193
247
  seed: integer(),
194
248
  groups: mapping(string(), NAME_PATTERN, 1), // ids become store document ids
195
249
  labeller_knows: array(string(true, NAME_PATTERN), false),
196
- // false: no "how well do you know {group}" question (an approval page: the author has never seen
197
- // these repositories, so its answer means nothing). Left out, every group is asked (rule 5).
250
+ // true: a "how well do you know {group}" question per group, for a study that filters on it
251
+ // (rule 5). Left out, none is asked: an approval page's author has never seen the repositories.
198
252
  familiarity: boolean(false),
199
253
  familiarity_question: string(false),
200
254
  questions: array(QUESTION_SCHEMA, true, 1),
201
- practice: array(PRACTICE_SCHEMA, true, MIN_PRACTICE),
255
+ practice: array(practiceSchema(kind), true, MIN_PRACTICE),
202
256
  practice_round: integer(false),
203
- items: array(ITEM_SCHEMA, true, 1),
257
+ items: array(itemSchema(kind), true, 1),
204
258
  });
259
+ /** The declared schema (`schema` prints it): every field, those a kind requires left optional. */
260
+ export const CONFIG_SCHEMA = configSchema("labels");
205
261
  // ── checking a value against the schema ──────────────────────────────────────────────────────
206
262
  const at = (path, name) => path ? `${path}.${name}` : name;
207
263
  const fullMatch = (pattern, s) => new RegExp(`^(?:${pattern})$`, "u").test(s);
@@ -211,7 +267,7 @@ function checkString(value, node, where) {
211
267
  fail(`${where}: must be a non-empty string, got ${repr(value)}`);
212
268
  }
213
269
  else if (node.pattern !== undefined && !fullMatch(node.pattern, value)) {
214
- fail(`${where}: ${repr(value)} must be ${ID_RULE}`);
270
+ fail(`${where}: ${repr(value)} must be ${node.why ?? ID_RULE}`);
215
271
  }
216
272
  }
217
273
  const checkCount = (n, min, where) => {
@@ -243,7 +299,7 @@ function checkObject(value, node, path, where) {
243
299
  if (has(value, name))
244
300
  checkValue(value[name] ?? null, sub, at(path, name));
245
301
  else if (sub.required)
246
- fail(`${at(path, name)}: is required and missing`);
302
+ fail(`${at(path, name)}: is required and missing${sub.why === undefined ? "" : ` — ${sub.why}`}`);
247
303
  }
248
304
  const unknown = Object.keys(value).find((name) => !has(node.fields, name));
249
305
  if (node.closed && unknown !== undefined) {
@@ -298,7 +354,7 @@ const allowedValues = (questions, asksFamiliarity) => ({
298
354
  // Empty when the page asks no familiarity: the export then expects no group document at all.
299
355
  familiarity: asksFamiliarity ? FAMILIARITY.map((o) => o.value) : [],
300
356
  });
301
- const asksFamiliarity = (cfg) => cfg.familiarity !== false;
357
+ const asksFamiliarity = (cfg) => cfg.familiarity === true;
302
358
  /** Two values that read the same once case and surrounding space are gone. */
303
359
  const readsAs = (s) => s.trim().toLowerCase();
304
360
  /** The first entry whose reading repeats an earlier one, with the index of that earlier one. */
@@ -363,7 +419,7 @@ function checkPractice(cfg) {
363
419
  function checkConfig(cfg) {
364
420
  checkGroups(cfg);
365
421
  if (!asksFamiliarity(cfg) && cfg.familiarity_question !== undefined) {
366
- fail(`familiarity_question: ${repr(cfg.familiarity_question)} is never asked, because \`familiarity\` is false — remove one of the two`);
422
+ fail(`familiarity_question: ${repr(cfg.familiarity_question)} is never asked, because \`familiarity\` is not true — remove it, or set "familiarity": true`);
367
423
  }
368
424
  cfg.questions.forEach((q, qn) => {
369
425
  checkQuestion(q, qn, cfg.questions.slice(0, qn).map((x) => x.id));
@@ -388,25 +444,36 @@ function checkPart({ item, unit, n }, parts) {
388
444
  fail(`${at}.unit.text: ${repr(unit.text)}, but items[${String(first.n)}] of line ${repr(unit.id)} says ${repr(first.unit.text)}`);
389
445
  if (unit.of !== first.unit.of)
390
446
  fail(`${at}.unit.of: ${String(unit.of)}, but items[${String(first.n)}].unit.of says ${String(first.unit.of)} for the same line`);
391
- if (unit.part < 1 || unit.part > unit.of)
392
- fail(`${at}.unit.part: ${String(unit.part)} is not between 1 and ${String(unit.of)}`);
393
447
  const earlier = parts.find((p) => p.n < n && p.unit.part === unit.part);
394
448
  if (earlier !== undefined)
395
449
  fail(`${at}.unit.part: ${String(unit.part)} repeats items[${String(earlier.n)}].unit.part`);
396
450
  if (item.group !== first.item.group)
397
451
  fail(`${at}.group: ${repr(item.group)}, but items[${String(first.n)}] of the same line is in ${repr(first.item.group)}`);
398
452
  }
453
+ /** One unit on its own: its part in range, and its mark the requirement's words in the line. */
454
+ function checkUnit(unit, at) {
455
+ if (unit === undefined)
456
+ return;
457
+ if (unit.part < 1 || unit.part > unit.of)
458
+ fail(`${at}.unit.part: ${String(unit.part)} is not between 1 and ${String(unit.of)}`);
459
+ if (!unit.text.includes(unit.mark))
460
+ fail(`${at}.unit.mark: ${repr(unit.mark)} is not in the line ${repr(unit.text)} — mark the requirement with the line's own words`);
461
+ }
399
462
  function checkUnits(cfg) {
400
- for (const [line, parts] of linesOf(cfg)) {
463
+ cardsOf(cfg).forEach(([at, card]) => {
464
+ checkUnit(card.unit, at);
465
+ });
466
+ for (const parts of linesOf(cfg).values()) {
401
467
  parts.forEach((p) => {
402
468
  checkPart(p, parts);
403
469
  });
404
- const [first] = parts;
405
- const missing = Array.from({ length: first.unit.of }, (_, k) => k + 1).find((k) => !parts.some((p) => p.unit.part === k));
406
- if (missing !== undefined)
407
- fail(`items[${String(first.n)}].unit: part ${String(missing)} of ${String(first.unit.of)} of line ${repr(line)} is not among the items`);
408
470
  }
409
471
  }
472
+ /** Every card of the config, items then practice, by path. */
473
+ const cardsOf = (cfg) => [
474
+ ...cfg.items.map((i, n) => [`items[${String(n)}]`, i]),
475
+ ...cfg.practice.map((p, n) => [`practice[${String(n)}]`, p]),
476
+ ];
410
477
  function checkItemFields(cfg) {
411
478
  const sids = [];
412
479
  cfg.items.forEach((item, n) => {
@@ -427,6 +494,7 @@ function checkItemFields(cfg) {
427
494
  const visibleTexts = (item) => [
428
495
  ...PAGE_FIELDS.map((f) => [f, item[f] ?? ""]),
429
496
  [UNIT_FIELD, item.unit?.text ?? ""],
497
+ [READINGS_FIELD, `${item.readings?.a ?? ""} ${item.readings?.b ?? ""}`],
430
498
  ];
431
499
  const escapeRegExp = (s) => s.replace(/[\\^$.*+?()[\]{}|/]/g, "\\$&");
432
500
  /**
@@ -491,28 +559,30 @@ const shownFields = (item) => ({
491
559
  : {
492
560
  [UNIT_FIELD]: {
493
561
  text: item.unit.text,
562
+ mark: item.unit.mark,
494
563
  part: item.unit.part,
495
564
  of: item.unit.of,
496
565
  },
497
566
  }),
567
+ ...(item.readings === undefined ? {} : { [READINGS_FIELD]: item.readings }),
498
568
  });
499
569
  const ruleKey = (r) => `${r.linter} ${r.id}`;
500
570
  /** Ask the linter for every rule named; refuse, by path, a rule it cannot describe. */
501
571
  function describeRules(cfg, fromDir) {
502
- return new Map(cfg.items.flatMap((item, n) => (item.rules ?? []).map((r, k) => {
572
+ return new Map(cardsOf(cfg).flatMap(([at, card]) => (card.rules ?? []).map((r, k) => {
503
573
  const text = describeRule(r, fromDir);
504
574
  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`)
575
+ ? fail(`${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`)
506
576
  : [ruleKey(r), text];
507
577
  })));
508
578
  }
509
- const rulesShown = (item, described) => item.rules === undefined
579
+ const rulesShown = (card, described) => card.rules === undefined
510
580
  ? {}
511
581
  : {
512
- [RULES_FIELD]: item.rules.map((r) => ({
582
+ [RULES_FIELD]: card.rules.map((r) => ({
513
583
  linter: r.linter,
514
584
  id: r.id,
515
- description: String(described.get(ruleKey(r))),
585
+ ...described.get(ruleKey(r)),
516
586
  })),
517
587
  };
518
588
  /** Only what the labeller can answer goes on the page, and only that gets an id, in id order. */
@@ -528,6 +598,14 @@ function itemsOnPage(cfg, keyPath) {
528
598
  return { items: sorted, ids, entries, idOf };
529
599
  }
530
600
  const practiceRound = (cfg) => cfg.practice_round ?? 1;
601
+ /** The practice as its cards show it, with the answers the page checks against. */
602
+ const practiceShown = (cfg, described) => practiceOf(cfg).map((p) => ({
603
+ id: p.id,
604
+ expected: p.expected,
605
+ why: p.why,
606
+ ...shownFields(p),
607
+ ...rulesShown(p, described),
608
+ }));
531
609
  /** What a page shows: the questions, the practice and the items, blind, with their neutral ids. */
532
610
  function payloadOf(cfg, page, described) {
533
611
  const used = Object.keys(cfg.groups).filter((g) => page.items.some((i) => i.group === g));
@@ -541,7 +619,10 @@ function payloadOf(cfg, page, described) {
541
619
  familiarity: asksFamiliarity(cfg) ? FAMILIARITY : [],
542
620
  allowed: allowedValues(cfg.questions, asksFamiliarity(cfg)),
543
621
  questions: cfg.questions,
544
- practice: { round: practiceRound(cfg), items: practiceOf(cfg) },
622
+ practice: {
623
+ round: practiceRound(cfg),
624
+ items: practiceShown(cfg, described),
625
+ },
545
626
  items: page.items.map((i) => ({
546
627
  id: page.idOf(i),
547
628
  ...shownFields(i),
@@ -610,6 +691,8 @@ function keyOf(cfg, page, earlier) {
610
691
  export function prepareBuild(configPath, outDir) {
611
692
  const cfg = readJson(configPath);
612
693
  assertConfig(cfg);
694
+ // Once its `kind` is known to be one, the fields that kind needs on every card.
695
+ checkValue(cfg, configSchema(cfg.kind), "");
613
696
  checkConfig(cfg);
614
697
  checkItemFields(cfg);
615
698
  checkUnits(cfg);
@@ -774,7 +857,7 @@ function checkNoFamiliarity(dump, allowed) {
774
857
  const groups = isObject(dump["groups"]) ? dump["groups"] : {};
775
858
  const [first] = Object.keys(groups).sort(byText);
776
859
  if (allowed.familiarity.length === 0 && first !== undefined) {
777
- fail(`groups/${first}: familiarity is off for this page (items.json \`familiarity: false\`), so no group document is expected — take it out of the dump`);
860
+ fail(`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`);
778
861
  }
779
862
  }
780
863
  /** The key's `allowed` lists, or a refusal naming a key made by an older build. */