paperlint 13.6.1 → 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,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. */
@@ -9,8 +9,10 @@ import { join } from "node:path";
9
9
  import { test } from "vitest";
10
10
  import { z } from "zod";
11
11
  import {
12
+ APPROVAL_PRACTICE,
12
13
  DONE_PRACTICE,
13
14
  Exported,
15
+ approvalCard,
14
16
  ITEMS,
15
17
  PRACTICE,
16
18
  QUESTIONS,
@@ -62,6 +64,16 @@ interface DocObject {
62
64
  const isObject = (v: Doc | undefined): v is DocObject =>
63
65
  typeof v === "object" && v !== null && !Array.isArray(v);
64
66
 
67
+ /** Every optional field of a card, for an item and a practice item alike. */
68
+ const CARD = {
69
+ title: "T",
70
+ code: "c = 1",
71
+ context: "ctx",
72
+ file: "`src/a.ts`",
73
+ rules: [{ linter: "eslint", id: "no-console" }],
74
+ unit: { id: "L0", text: "One line.", mark: "One", part: 1, of: 1 },
75
+ readings: { a: "on", b: "off" },
76
+ };
65
77
  /** A config with EVERY optional field present, so every schema path has a value to spoil. */
66
78
  const FULL: DocObject = (() => {
67
79
  const doc = Doc.parse(
@@ -80,18 +92,8 @@ const FULL: DocObject = (() => {
80
92
  labeller_knows: ["python", "go", "rust"],
81
93
  familiarity: true,
82
94
  familiarity_question: "How well do you know {group}?",
83
- items: ITEMS.map((i, n) =>
84
- n === 0
85
- ? {
86
- ...i,
87
- title: "T",
88
- code: "c = 1",
89
- context: "ctx",
90
- rules: [{ linter: "eslint", id: "no-console" }],
91
- unit: { id: "L0", text: "One line.", part: 1, of: 1 },
92
- }
93
- : i,
94
- ),
95
+ practice: PRACTICE.map((p, n) => (n === 0 ? { ...p, ...CARD } : p)),
96
+ items: ITEMS.map((i, n) => (n === 0 ? { ...i, ...CARD } : i)),
95
97
  }),
96
98
  );
97
99
  assert.ok(isObject(doc));
@@ -235,9 +237,17 @@ function expectRefused(leaf: Leaf): void {
235
237
  assert.doesNotMatch(r.out, /Traceback/, `${what}\n${r.out}`);
236
238
  });
237
239
  if (!leaf.node.required && !leaf.member) {
240
+ const absent = spoil(leaf.path, undefined);
241
+ // familiarity_question asks nothing unless familiarity is on: the two leave together.
238
242
  const r = tryBuild(
239
243
  `schema-${leaf.path.replace(/\W/g, "_")}-absent`,
240
- spoil(leaf.path, undefined),
244
+ leaf.path === "familiarity"
245
+ ? Object.fromEntries(
246
+ Object.entries(absent).filter(
247
+ ([k]) => k !== "familiarity_question",
248
+ ),
249
+ )
250
+ : absent,
241
251
  );
242
252
  assert.equal(r.status, 0, `${leaf.path} is optional: ${r.out}`);
243
253
  }
@@ -245,6 +255,7 @@ function expectRefused(leaf: Leaf): void {
245
255
 
246
256
  const DOCUMENTED_FIELDS = [
247
257
  "study",
258
+ "kind",
248
259
  "title",
249
260
  "intro",
250
261
  "seed",
@@ -1257,19 +1268,42 @@ test("🔴 familiarity off with a familiarity_question is a contradiction the bu
1257
1268
  assert.notEqual(r.status, 0);
1258
1269
  assert.match(
1259
1270
  r.out,
1260
- /REFUSED: familiarity_question: .*`familiarity` is false/,
1271
+ /REFUSED: familiarity_question: .*`familiarity` is not true/,
1261
1272
  );
1262
1273
  });
1263
1274
 
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"]);
1275
+ test('🔴 familiarity left out asks no group, approval page or not; a page asks it only with `"familiarity": true`', () => {
1276
+ // Guards: the default is off for every page (#373), so an approval page built the old way asks nothing.
1277
+ for (const [name, cfg] of [
1278
+ ["fam-default-approval", approval()],
1279
+ ["fam-default-gold", config({ familiarity: undefined })],
1280
+ ] as const) {
1281
+ assert.deepEqual(build(setup(name, cfg)).key.allowed.familiarity, []);
1282
+ }
1283
+ const asks = setup("fam-on", config());
1284
+ assert.deepEqual(build(asks).key.allowed.familiarity, [
1285
+ "well",
1286
+ "somewhat",
1287
+ "not_really",
1288
+ ]);
1268
1289
  writeFileSync(
1269
- join(dir, "dump.json"),
1290
+ join(asks, "dump.json"),
1270
1291
  JSON.stringify({ answers: {}, groups: {} }),
1271
1292
  );
1272
- assert.match(exportAt(dir).out, /group python: no familiarity answer/);
1293
+ assert.match(exportAt(asks).out, /group python: no familiarity answer/);
1294
+ const r = run([
1295
+ "build",
1296
+ join(
1297
+ setup(
1298
+ "fam-question-alone",
1299
+ config({ familiarity: undefined, familiarity_question: "How well?" }),
1300
+ ),
1301
+ "items.json",
1302
+ ),
1303
+ join(WORK, "fam-question-alone-out"),
1304
+ ]);
1305
+ assert.equal(r.status, 1, r.out);
1306
+ assert.match(r.out, /REFUSED: familiarity_question: .*is not true/);
1273
1307
  });
1274
1308
 
1275
1309
  /** Items 0 and 3 (both Rust) as the two requirements of one line. */
@@ -1280,7 +1314,13 @@ const withUnit = (
1280
1314
  config({
1281
1315
  items: ITEMS.map((item, n) => {
1282
1316
  const u = parts.find(([at]) => at === n);
1283
- return u === undefined ? item : { ...item, unit: u[1] };
1317
+ // the first word of the line is marked unless the case says otherwise
1318
+ return u === undefined
1319
+ ? item
1320
+ : {
1321
+ ...item,
1322
+ unit: { mark: String(u[1]["text"]).split(" ")[0], ...u[1] },
1323
+ };
1284
1324
  }),
1285
1325
  });
1286
1326
 
@@ -1291,9 +1331,11 @@ const UNIT_REFUSALS: readonly (readonly [
1291
1331
  string,
1292
1332
  ])[] = [
1293
1333
  [
1294
- "unit-missing-part",
1295
- withUnit([[0, { id: "L1", text: LINE, part: 1, of: 2 }]]),
1296
- `items[0].unit: part 2 of 2 of line 'L1' is not among the items`,
1334
+ "unit-mark-not-in-line",
1335
+ withUnit([
1336
+ [0, { id: "L1", text: LINE, mark: "JavaScript only", part: 1, of: 2 }],
1337
+ ]),
1338
+ "items[0].unit.mark: 'JavaScript only' is not in the line 'TypeScript only (no JavaScript); top-level await is allowed.' — mark the requirement with the line's own words",
1297
1339
  ],
1298
1340
  [
1299
1341
  "unit-part-twice",
@@ -1332,14 +1374,6 @@ const UNIT_REFUSALS: readonly (readonly [
1332
1374
  ]),
1333
1375
  "items[3].unit.text: 'Another line.', but items[0] of line 'L1' says",
1334
1376
  ],
1335
- [
1336
- "unit-halves-of-two-occurrences",
1337
- withUnit([
1338
- [0, { id: "L1", text: LINE, part: 1, of: 2 }],
1339
- [3, { id: "L2", text: LINE, part: 2, of: 2 }],
1340
- ]),
1341
- "items[0].unit: part 2 of 2 of line 'L1' is not among the items",
1342
- ],
1343
1377
  [
1344
1378
  "unit-leaks-source",
1345
1379
  withUnit([[0, { id: "L1", text: "as in case-1003", part: 1, of: 1 }]]),
@@ -1347,6 +1381,32 @@ const UNIT_REFUSALS: readonly (readonly [
1347
1381
  ],
1348
1382
  ];
1349
1383
 
1384
+ test("🔴 a drawn sample holds one requirement of a line that has two: it builds, the drawn part marked", () => {
1385
+ // Guards: a unit with `of` above the parts present is a drawn part, not a lost one (#373, 5 of 9 cards).
1386
+ const { page } = build(
1387
+ setup(
1388
+ "unit-drawn-part",
1389
+ withUnit([
1390
+ [
1391
+ 0,
1392
+ {
1393
+ id: "L1",
1394
+ text: LINE,
1395
+ mark: "top-level await is allowed",
1396
+ part: 2,
1397
+ of: 2,
1398
+ },
1399
+ ],
1400
+ ]),
1401
+ ),
1402
+ );
1403
+ assert.ok(
1404
+ page.includes(
1405
+ `"unit": {"text": "${LINE}", "mark": "top-level await is allowed", "part": 2, "of": 2}`,
1406
+ ),
1407
+ );
1408
+ });
1409
+
1350
1410
  test("🔴 a line with several requirements: build refuses a unit whose parts do not add up, naming the item", () => {
1351
1411
  for (const [name, cfg, message] of UNIT_REFUSALS) {
1352
1412
  const dir = setup(name, cfg);
@@ -1375,7 +1435,12 @@ test("a line whose every requirement is an item builds, and each item carries it
1375
1435
  items: z.array(
1376
1436
  z.object({
1377
1437
  unit: z
1378
- .object({ text: z.string(), part: z.number(), of: z.number() })
1438
+ .object({
1439
+ text: z.string(),
1440
+ mark: z.string(),
1441
+ part: z.number(),
1442
+ of: z.number(),
1443
+ })
1379
1444
  .optional(),
1380
1445
  }),
1381
1446
  ),
@@ -1385,8 +1450,8 @@ test("a line whose every requirement is an item builds, and each item carries it
1385
1450
  .sort((a, b) => a.part - b.part);
1386
1451
  // what the page carries: the part and the line, never the line's private id
1387
1452
  assert.deepEqual(units, [
1388
- { text: LINE, part: 1, of: 2 },
1389
- { text: LINE, part: 2, of: 2 },
1453
+ { text: LINE, mark: "TypeScript", part: 1, of: 2 },
1454
+ { text: LINE, mark: "TypeScript", part: 2, of: 2 },
1390
1455
  ]);
1391
1456
  });
1392
1457
 
@@ -1403,3 +1468,95 @@ test("🔴 the same line in two places is two lines: four items, two ids, it bui
1403
1468
  const { page } = build(dir);
1404
1469
  assert.ok(!page.includes("a.md:3"), "the line's id is private");
1405
1470
  });
1471
+
1472
+ /** A witness card: an approval card's line and rule, plus the two readings and the file. */
1473
+ const witnessCard = <T extends { readonly body: string }>(c: T, n: number) => ({
1474
+ ...approvalCard(c, n),
1475
+ readings: { a: "on", b: "off" },
1476
+ file: "`src/a.ts`: B flags it",
1477
+ });
1478
+ const witnessPage = (
1479
+ items: readonly Record<string, unknown>[] = ITEMS.map(witnessCard),
1480
+ ) =>
1481
+ approval({
1482
+ kind: "witness",
1483
+ practice: APPROVAL_PRACTICE.map(witnessCard),
1484
+ items,
1485
+ });
1486
+
1487
+ /** Pages that miss their kind or a field it needs, each with its refusal. */
1488
+ function kindRefusals(): readonly (readonly [
1489
+ string,
1490
+ Record<string, unknown>,
1491
+ string,
1492
+ ])[] {
1493
+ const plain = { items: ITEMS, practice: APPROVAL_PRACTICE };
1494
+ const rulesOnly = {
1495
+ items: ITEMS.map((c, n) => ({ ...c, rules: approvalCard(c, n).rules })),
1496
+ practice: APPROVAL_PRACTICE.map((c, n) => ({
1497
+ ...c,
1498
+ rules: approvalCard(c, n).rules,
1499
+ })),
1500
+ };
1501
+ return [
1502
+ // the shape pages were built in until now: no kind, the line and the rule in free text
1503
+ [
1504
+ "kind-missing",
1505
+ approval({ ...plain, kind: undefined }),
1506
+ "kind: is required and missing — one of approval, witness, labels",
1507
+ ],
1508
+ [
1509
+ "kind-unknown",
1510
+ approval({ kind: "audit" }),
1511
+ "kind: 'audit' must be one of approval, witness, labels",
1512
+ ],
1513
+ [
1514
+ "approval-no-rules",
1515
+ approval(plain),
1516
+ "practice[0].rules: is required and missing — an approval card shows the rule read for it, with the linter's own description",
1517
+ ],
1518
+ [
1519
+ "approval-no-unit",
1520
+ approval(rulesOnly),
1521
+ "practice[0].unit: is required and missing — an approval card shows the line its requirement was drawn from, the requirement marked",
1522
+ ],
1523
+ [
1524
+ "witness-no-readings",
1525
+ approval({ kind: "witness" }),
1526
+ "practice[0].readings: is required and missing — a witness card shows the two readings, one row each",
1527
+ ],
1528
+ [
1529
+ "witness-no-file",
1530
+ witnessPage(
1531
+ ITEMS.map((c, n) => ({ ...witnessCard(c, n), file: undefined })),
1532
+ ),
1533
+ "items[0].file: is required and missing — a witness card shows the file the readings disagree on, on its own row",
1534
+ ],
1535
+ ];
1536
+ }
1537
+
1538
+ test("🔴 a page without its kind, or a card without its kind's fields, fails the schema naming the field", () => {
1539
+ const refused = kindRefusals();
1540
+ // Guards: the whole refusal, the field and why the page's kind needs it.
1541
+ assert.deepEqual(
1542
+ refused.map(([name, cfg]) => {
1543
+ const dir = setup(name, cfg);
1544
+ return run(["build", join(dir, "items.json"), join(dir, "out")]);
1545
+ }),
1546
+ refused.map(([, , message]) => ({
1547
+ status: 1,
1548
+ out: `REFUSED: ${message}\n`,
1549
+ })),
1550
+ );
1551
+ });
1552
+
1553
+ test("a page of each kind with its fields builds; a practice card shows its rule like a real one", () => {
1554
+ const witness = build(setup("witness-ok", witnessPage()));
1555
+ assert.ok(witness.page.includes('"readings": {"a": "on", "b": "off"}'));
1556
+ assert.ok(
1557
+ build(setup("approval-ok", approval())).page.includes(
1558
+ '"rules": [{"linter": "eslint", "id": "no-console", "description": "Disallow the use of `console`"}]',
1559
+ ),
1560
+ );
1561
+ assert.equal(build(setup("labels-ok", config())).key.seed, 7);
1562
+ });