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.
@@ -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 { describeRule, type RuleRef } from "./rule-descriptions.ts";
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
- const PAGE_FIELDS = ["group", "title", "body", "code", "context"] as const;
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
- // One requirement of a line that holds several: the whole line and which part this item is. Shown on
102
- // the card beneath the item; the line is text the labeller may read, like `context`.
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
- type SchemaNode =
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
- : { kind: "string", required, pattern };
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
- const ITEM_SCHEMA = obj(
199
- {
200
- source_id: string(),
201
- ...Object.fromEntries(
202
- PAGE_FIELDS.map((f) => [f, string(f === "group" || f === "body")]),
203
- ),
204
- [RULES_FIELD]: array(
205
- obj({ linter: string(true, "eslint|ruff"), id: string() }),
206
- false,
207
- 1,
208
- ),
209
- [UNIT_FIELD]: obj(
210
- // `id` names one occurrence of the line (two files may hold the same words): never shown.
211
- { id: string(), text: string(), part: integer(), of: integer() },
212
- true,
213
- false,
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
- false, // an unlisted item field is the leak guard's refusal, with its own message
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 PRACTICE_SCHEMA = obj({
229
- ...Object.fromEntries(
230
- PAGE_FIELDS.filter((f) => f !== "group").map((f) => [
231
- f,
232
- string(f === "body"),
233
- ]),
234
- ),
235
- expected: mapping(string(), NAME_PATTERN, 1),
236
- why: string(),
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
- export const CONFIG_SCHEMA = obj({
246
- study: string(true, NAME_PATTERN), // becomes a file name
247
- title: string(),
248
- intro: string(false),
249
- seed: integer(),
250
- groups: mapping(string(), NAME_PATTERN, 1), // ids become store document ids
251
- labeller_knows: array(string(true, NAME_PATTERN), false),
252
- // false: no "how well do you know {group}" question (an approval page: the author has never seen
253
- // these repositories, so its answer means nothing). Left out, every group is asked (rule 5).
254
- familiarity: boolean(false),
255
- familiarity_question: string(false),
256
- questions: array(QUESTION_SCHEMA, true, 1),
257
- practice: array(PRACTICE_SCHEMA, true, MIN_PRACTICE),
258
- practice_round: integer(false),
259
- items: array(ITEM_SCHEMA, true, 1),
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) fail(`${at(path, name)}: is required and missing`);
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
- type Item = {
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
- interface Practice {
391
- readonly title?: string;
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 !== false;
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 false — remove one of the two`,
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}`. Every part of a line must be an item, once, in one group (so
548
- * `labeller_knows` keeps or drops the whole line): a lost part is a requirement nobody judged.
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
- for (const [line, parts] of linesOf(cfg)) {
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 part and the line, without the line's private id. */
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: Item,
722
- ): Readonly<Record<string, string | ShownUnit>> => ({
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 an item on the page names. */
740
- type Described = ReadonlyMap<string, 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.items.flatMap((item, n) =>
746
- (item.rules ?? []).map((r, k): readonly [string, string] => {
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
- `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`,
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 = (item: Item, described: Described) =>
758
- item.rules === undefined
850
+ const rulesShown = (card: Card, described: Described) =>
851
+ card.rules === undefined
759
852
  ? {}
760
853
  : {
761
- [RULES_FIELD]: item.rules.map((r) => ({
854
+ [RULES_FIELD]: card.rules.map((r) => ({
762
855
  linter: r.linter,
763
856
  id: r.id,
764
- description: String(described.get(ruleKey(r))),
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: { round: practiceRound(cfg), items: practiceOf(cfg) },
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: false\`), so no group document is expected — take it out of the dump`,
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("🔴 an item that names a rule: its card says which rule and what it does, in the linter's words", async () => {
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
- "rules-card",
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 shown = await showPage(page, {}, {});
429
- assert.match(
430
- shown,
431
- /Rule eslint no-console: Disallow the use of \nconsole\n/,
432
- shown,
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.equal(
435
- occurrences(shown, "Rule eslint"),
436
- 1,
437
- "only the item that names it",
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
- });