paperlint 13.3.2 → 13.5.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.
@@ -14,6 +14,7 @@ import {
14
14
  ITEMS,
15
15
  PRACTICE,
16
16
  QUESTIONS,
17
+ WORK,
17
18
  approval,
18
19
  build,
19
20
  config,
@@ -26,7 +27,7 @@ import {
26
27
  /** A node of the declared config schema, as `example-generator.py schema` prints it. */
27
28
  const Node: z.ZodType<SchemaNode> = z.lazy(() =>
28
29
  z.object({
29
- kind: z.enum(["string", "integer", "map", "array", "object"]),
30
+ kind: z.enum(["string", "integer", "boolean", "map", "array", "object"]),
30
31
  required: z.boolean(),
31
32
  pattern: z.string().optional(),
32
33
  key_pattern: z.string().optional(),
@@ -39,7 +40,7 @@ const Node: z.ZodType<SchemaNode> = z.lazy(() =>
39
40
  );
40
41
 
41
42
  interface SchemaNode {
42
- readonly kind: "string" | "integer" | "map" | "array" | "object";
43
+ readonly kind: "string" | "integer" | "boolean" | "map" | "array" | "object";
43
44
  readonly required: boolean;
44
45
  readonly pattern?: string | undefined;
45
46
  readonly key_pattern?: string | undefined;
@@ -77,9 +78,18 @@ const FULL: DocObject = (() => {
77
78
  QUESTIONS[1],
78
79
  ],
79
80
  labeller_knows: ["python", "go", "rust"],
81
+ familiarity: true,
80
82
  familiarity_question: "How well do you know {group}?",
81
83
  items: ITEMS.map((i, n) =>
82
- n === 0 ? { ...i, title: "T", code: "c = 1", context: "ctx" } : i,
84
+ n === 0
85
+ ? {
86
+ ...i,
87
+ title: "T",
88
+ code: "c = 1",
89
+ context: "ctx",
90
+ unit: { id: "L0", text: "One line.", part: 1, of: 1 },
91
+ }
92
+ : i,
83
93
  ),
84
94
  }),
85
95
  );
@@ -175,6 +185,7 @@ function spoil(path: string, bad: Doc | undefined): DocObject {
175
185
  const WRONG_TYPES: Readonly<Record<SchemaNode["kind"], readonly Doc[]>> = {
176
186
  string: [null, 1, true, ["x"], {}, "", " "],
177
187
  integer: [null, "7", true, 7.5, []],
188
+ boolean: [null, "false", 0, [], {}],
178
189
  map: [null, "x", ["x"], 1],
179
190
  array: [null, "x", {}, 1],
180
191
  object: [null, "x", ["x"], 1],
@@ -238,6 +249,7 @@ const DOCUMENTED_FIELDS = [
238
249
  "seed",
239
250
  "groups",
240
251
  "labeller_knows",
252
+ "familiarity",
241
253
  "familiarity_question",
242
254
  "questions",
243
255
  "items",
@@ -1162,3 +1174,194 @@ test("🔴 a question dropped between practice rounds: the earlier round's answe
1162
1174
  assert.equal(r.status, 0, r.out);
1163
1175
  assert.match(r.out, /practice round 1: 3 of 3 matched/);
1164
1176
  });
1177
+
1178
+ /** Items 0 and 3 (both Rust) as the two requirements of one line. */
1179
+ const LINE = "TypeScript only (no JavaScript); top-level await is allowed.";
1180
+ const withUnit = (
1181
+ parts: readonly (readonly [n: number, unit: Record<string, unknown>])[],
1182
+ ): Record<string, unknown> =>
1183
+ config({
1184
+ items: ITEMS.map((item, n) => {
1185
+ const u = parts.find(([at]) => at === n);
1186
+ return u === undefined ? item : { ...item, unit: u[1] };
1187
+ }),
1188
+ });
1189
+
1190
+ /** A line whose parts do not add up, and the refusal that names it. */
1191
+ const UNIT_REFUSALS: readonly (readonly [
1192
+ string,
1193
+ Record<string, unknown>,
1194
+ string,
1195
+ ])[] = [
1196
+ [
1197
+ "unit-missing-part",
1198
+ withUnit([[0, { id: "L1", text: LINE, part: 1, of: 2 }]]),
1199
+ `items[0].unit: part 2 of 2 of line 'L1' is not among the items`,
1200
+ ],
1201
+ [
1202
+ "unit-part-twice",
1203
+ withUnit([
1204
+ [0, { id: "L1", text: LINE, part: 1, of: 2 }],
1205
+ [3, { id: "L1", text: LINE, part: 1, of: 2 }],
1206
+ ]),
1207
+ "items[3].unit.part: 1 repeats items[0].unit.part",
1208
+ ],
1209
+ [
1210
+ "unit-of-differs",
1211
+ withUnit([
1212
+ [0, { id: "L1", text: LINE, part: 1, of: 2 }],
1213
+ [3, { id: "L1", text: LINE, part: 2, of: 3 }],
1214
+ ]),
1215
+ "items[3].unit.of: 3, but items[0].unit.of says 2 for the same line",
1216
+ ],
1217
+ [
1218
+ "unit-part-range",
1219
+ withUnit([[0, { id: "L1", text: LINE, part: 3, of: 2 }]]),
1220
+ "items[0].unit.part: 3 is not between 1 and 2",
1221
+ ],
1222
+ [
1223
+ "unit-groups-differ",
1224
+ withUnit([
1225
+ [0, { id: "L1", text: LINE, part: 1, of: 2 }],
1226
+ [1, { id: "L1", text: LINE, part: 2, of: 2 }],
1227
+ ]),
1228
+ "items[1].group: 'python', but items[0] of the same line is in 'rust'",
1229
+ ],
1230
+ [
1231
+ "unit-text-differs",
1232
+ withUnit([
1233
+ [0, { id: "L1", text: LINE, part: 1, of: 2 }],
1234
+ [3, { id: "L1", text: "Another line.", part: 2, of: 2 }],
1235
+ ]),
1236
+ "items[3].unit.text: 'Another line.', but items[0] of line 'L1' says",
1237
+ ],
1238
+ [
1239
+ "unit-halves-of-two-occurrences",
1240
+ withUnit([
1241
+ [0, { id: "L1", text: LINE, part: 1, of: 2 }],
1242
+ [3, { id: "L2", text: LINE, part: 2, of: 2 }],
1243
+ ]),
1244
+ "items[0].unit: part 2 of 2 of line 'L1' is not among the items",
1245
+ ],
1246
+ [
1247
+ "unit-leaks-source",
1248
+ withUnit([[0, { id: "L1", text: "as in case-1003", part: 1, of: 1 }]]),
1249
+ "the source id 'case-1003' appears in the visible `unit` of item case-1000",
1250
+ ],
1251
+ ];
1252
+
1253
+ test("🔴 a line with several requirements: build refuses a unit whose parts do not add up, naming the item", () => {
1254
+ for (const [name, cfg, message] of UNIT_REFUSALS) {
1255
+ const dir = setup(name, cfg);
1256
+ const r = run(["build", join(dir, "items.json"), join(dir, "out")]);
1257
+ assert.equal(r.status, 1, `${name}\n${r.out}`);
1258
+ assert.ok(r.out.includes(`REFUSED: ${message}`), `${name}\n${r.out}`);
1259
+ }
1260
+ });
1261
+
1262
+ test("a line whose every requirement is an item builds, and each item carries its part and the whole line", () => {
1263
+ const dir = setup(
1264
+ "unit-ok",
1265
+ withUnit([
1266
+ [0, { id: "L1", text: LINE, part: 1, of: 2 }],
1267
+ [3, { id: "L1", text: LINE, part: 2, of: 2 }],
1268
+ ]),
1269
+ );
1270
+ const { page } = build(dir);
1271
+ const json =
1272
+ /<script type="application\/json" id="data">([\s\S]*?)<\/script>/.exec(
1273
+ page,
1274
+ )?.[1];
1275
+ assert.ok(json !== undefined);
1276
+ const units = z
1277
+ .object({
1278
+ items: z.array(
1279
+ z.object({
1280
+ unit: z
1281
+ .object({ text: z.string(), part: z.number(), of: z.number() })
1282
+ .optional(),
1283
+ }),
1284
+ ),
1285
+ })
1286
+ .parse(JSON.parse(json.replace(/<\\\//g, "</")))
1287
+ .items.flatMap((i) => (i.unit ? [i.unit] : []))
1288
+ .sort((a, b) => a.part - b.part);
1289
+ // what the page carries: the part and the line, never the line's private id
1290
+ assert.deepEqual(units, [
1291
+ { text: LINE, part: 1, of: 2 },
1292
+ { text: LINE, part: 2, of: 2 },
1293
+ ]);
1294
+ });
1295
+
1296
+ test("🔴 the same line in two places is two lines: four items, two ids, it builds, and the id stays off the page", () => {
1297
+ const dir = setup(
1298
+ "unit-same-text",
1299
+ withUnit([
1300
+ [0, { id: "a.md:3", text: LINE, part: 1, of: 2 }],
1301
+ [3, { id: "a.md:3", text: LINE, part: 2, of: 2 }],
1302
+ [6, { id: "b.md:9", text: LINE, part: 1, of: 2 }],
1303
+ [9, { id: "b.md:9", text: LINE, part: 2, of: 2 }],
1304
+ ]),
1305
+ );
1306
+ const { page } = build(dir);
1307
+ assert.ok(!page.includes("a.md:3"), "the line's id is private");
1308
+ });
1309
+
1310
+ test("🔴 familiarity off: the key asks for none, the export reports no missing familiarity, and a stored one is refused", () => {
1311
+ const dir = setup("familiarity-off", approval({ familiarity: false }));
1312
+ const { key } = build(dir);
1313
+ assert.deepEqual(key.allowed.familiarity, []);
1314
+ const [a] = Object.keys(key.ids);
1315
+ assert.ok(a !== undefined);
1316
+ writeFileSync(
1317
+ join(dir, "dump.json"),
1318
+ JSON.stringify({
1319
+ answers: { [a]: { answers: { keep: "keep" }, source: "page" } },
1320
+ groups: {},
1321
+ }),
1322
+ );
1323
+ const r = exportAt(dir);
1324
+ assert.equal(r.status, 0, r.out);
1325
+ assert.doesNotMatch(r.out, /no familiarity answer|invalid group/);
1326
+ assert.deepEqual(Exported.parse(JSON.parse(labelsFile(dir))).groups, {});
1327
+ writeFileSync(
1328
+ join(dir, "dump.json"),
1329
+ JSON.stringify({
1330
+ answers: {},
1331
+ groups: { python: { familiarity: "well", source: "chat" } },
1332
+ }),
1333
+ );
1334
+ const refused = exportAt(dir);
1335
+ assert.notEqual(refused.status, 0);
1336
+ assert.match(refused.out, /groups\/python: .*familiarity is off/);
1337
+ });
1338
+
1339
+ test("🔴 familiarity off with a familiarity_question is a contradiction the build refuses", () => {
1340
+ const r = run([
1341
+ "build",
1342
+ join(
1343
+ setup(
1344
+ "familiarity-contradiction",
1345
+ approval({ familiarity: false, familiarity_question: "How well?" }),
1346
+ ),
1347
+ "items.json",
1348
+ ),
1349
+ join(WORK, "contradiction-out"),
1350
+ ]);
1351
+ assert.notEqual(r.status, 0);
1352
+ assert.match(
1353
+ r.out,
1354
+ /REFUSED: familiarity_question: .*`familiarity` is false/,
1355
+ );
1356
+ });
1357
+
1358
+ test("familiarity left out: every group is still asked (rule 5), and a missing one is reported", () => {
1359
+ const dir = setup("familiarity-default", approval());
1360
+ const { key } = build(dir);
1361
+ assert.deepEqual(key.allowed.familiarity, ["well", "somewhat", "not_really"]);
1362
+ writeFileSync(
1363
+ join(dir, "dump.json"),
1364
+ JSON.stringify({ answers: {}, groups: {} }),
1365
+ );
1366
+ assert.match(exportAt(dir).out, /group python: no familiarity answer/);
1367
+ });
@@ -94,7 +94,14 @@ const readJson = (path: string): Json => {
94
94
  // labeller what the answer is, or where the item came from?" — never to make a build pass.
95
95
  const PAGE_FIELDS = ["group", "title", "body", "code", "context"] as const;
96
96
  const PRIVATE_FIELDS = ["source_id"] as const;
97
- const ALLOWED_FIELDS: readonly string[] = [...PAGE_FIELDS, ...PRIVATE_FIELDS];
97
+ // One requirement of a line that holds several: the whole line and which part this item is. Shown on
98
+ // the card beneath the item; the line is text the labeller may read, like `context`.
99
+ const UNIT_FIELD = "unit";
100
+ const ALLOWED_FIELDS: readonly string[] = [
101
+ ...PAGE_FIELDS,
102
+ UNIT_FIELD,
103
+ ...PRIVATE_FIELDS,
104
+ ];
98
105
 
99
106
  const FAMILIARITY = [
100
107
  { value: "well", label: "Well" },
@@ -132,6 +139,7 @@ const SOURCES_SHOWN = `(${SOURCES.map(quoted).join(", ")})`;
132
139
  type SchemaNode =
133
140
  | { kind: "string"; required: boolean; pattern?: string }
134
141
  | { kind: "integer"; required: boolean }
142
+ | { kind: "boolean"; required: boolean }
135
143
  | { kind: "array"; required: boolean; min: number; items: SchemaNode }
136
144
  | {
137
145
  kind: "map";
@@ -155,6 +163,10 @@ const integer = (required = true): SchemaNode => ({
155
163
  kind: "integer",
156
164
  required,
157
165
  });
166
+ const boolean = (required = true): SchemaNode => ({
167
+ kind: "boolean",
168
+ required,
169
+ });
158
170
  const array = (items: SchemaNode, required = true, min = 0): SchemaNode => ({
159
171
  kind: "array",
160
172
  required,
@@ -175,7 +187,8 @@ const mapping = (
175
187
  const obj = (
176
188
  fields: Readonly<Record<string, SchemaNode>>,
177
189
  closed = true,
178
- ): SchemaNode => ({ kind: "object", required: true, closed, fields });
190
+ required = true,
191
+ ): SchemaNode => ({ kind: "object", required, closed, fields });
179
192
 
180
193
  const ITEM_SCHEMA = obj(
181
194
  {
@@ -183,6 +196,12 @@ const ITEM_SCHEMA = obj(
183
196
  ...Object.fromEntries(
184
197
  PAGE_FIELDS.map((f) => [f, string(f === "group" || f === "body")]),
185
198
  ),
199
+ [UNIT_FIELD]: obj(
200
+ // `id` names one occurrence of the line (two files may hold the same words): never shown.
201
+ { id: string(), text: string(), part: integer(), of: integer() },
202
+ true,
203
+ false,
204
+ ),
186
205
  },
187
206
  false, // an unlisted item field is the leak guard's refusal, with its own message
188
207
  );
@@ -220,6 +239,9 @@ export const CONFIG_SCHEMA = obj({
220
239
  seed: integer(),
221
240
  groups: mapping(string(), NAME_PATTERN, 1), // ids become store document ids
222
241
  labeller_knows: array(string(true, NAME_PATTERN), false),
242
+ // false: no "how well do you know {group}" question (an approval page: the author has never seen
243
+ // these repositories, so its answer means nothing). Left out, every group is asked (rule 5).
244
+ familiarity: boolean(false),
223
245
  familiarity_question: string(false),
224
246
  questions: array(QUESTION_SCHEMA, true, 1),
225
247
  practice: array(PRACTICE_SCHEMA, true, MIN_PRACTICE),
@@ -296,6 +318,10 @@ function checkObject(
296
318
  );
297
319
  }
298
320
  }
321
+ function checkBoolean(value: Json, where: string): void {
322
+ if (typeof value !== "boolean")
323
+ fail(`${where}: must be true or false, got ${repr(value)}`);
324
+ }
299
325
  /** Refuse the first place `value` departs from `node`; the message starts with the field's path. */
300
326
  function checkValue(value: Json, node: SchemaNode, path: string): void {
301
327
  const where = path || "items.json";
@@ -308,6 +334,9 @@ function checkValue(value: Json, node: SchemaNode, path: string): void {
308
334
  fail(`${where}: must be an integer, got ${repr(value)}`);
309
335
  }
310
336
  break;
337
+ case "boolean":
338
+ checkBoolean(value, where);
339
+ break;
311
340
  case "array":
312
341
  checkArray(value, node, path, where);
313
342
  break;
@@ -333,9 +362,19 @@ interface Question {
333
362
  readonly short?: string;
334
363
  readonly options: readonly Option[];
335
364
  }
336
- type Item = Readonly<Record<string, string>> & {
365
+ interface Unit {
366
+ readonly id: string;
367
+ readonly text: string;
368
+ readonly part: number;
369
+ readonly of: number;
370
+ }
371
+ type Item = {
372
+ readonly [F in (typeof PAGE_FIELDS)[number]]?: string;
373
+ } & {
337
374
  readonly source_id: string;
338
375
  readonly group: string;
376
+ readonly body: string;
377
+ readonly unit?: Unit;
339
378
  };
340
379
  interface Practice {
341
380
  readonly title?: string;
@@ -352,6 +391,7 @@ interface Config {
352
391
  readonly seed: number;
353
392
  readonly groups: Readonly<Record<string, string>>;
354
393
  readonly labeller_knows?: readonly string[];
394
+ readonly familiarity?: boolean;
355
395
  readonly familiarity_question?: string;
356
396
  readonly questions: readonly Question[];
357
397
  readonly practice: readonly Practice[];
@@ -368,7 +408,10 @@ function assertConfig(value: Json): asserts value is Json & Config {
368
408
  * The page script (`DATA.allowed`) and the exporter (`key.json`) both read this one result, so a
369
409
  * stored value counts as an answer on the page exactly when it counts in the export.
370
410
  */
371
- const allowedValues = (questions: readonly Question[]): JsonObject => ({
411
+ const allowedValues = (
412
+ questions: readonly Question[],
413
+ asksFamiliarity: boolean,
414
+ ): JsonObject => ({
372
415
  questions: Object.fromEntries(
373
416
  questions.map((q) => [q.id, q.options.map((o) => o.value)]),
374
417
  ),
@@ -379,8 +422,10 @@ const allowedValues = (questions: readonly Question[]): JsonObject => ({
379
422
  q.options.filter((o) => o.ask_why !== undefined).map((o) => o.value),
380
423
  ]),
381
424
  ),
382
- familiarity: FAMILIARITY.map((o) => o.value),
425
+ // Empty when the page asks no familiarity: the export then expects no group document at all.
426
+ familiarity: asksFamiliarity ? FAMILIARITY.map((o) => o.value) : [],
383
427
  });
428
+ const asksFamiliarity = (cfg: Config): boolean => cfg.familiarity !== false;
384
429
 
385
430
  /** Two values that read the same once case and surrounding space are gone. */
386
431
  const readsAs = (s: string): string => s.trim().toLowerCase();
@@ -464,6 +509,11 @@ function checkPractice(cfg: Config): void {
464
509
  }
465
510
  function checkConfig(cfg: Config): void {
466
511
  checkGroups(cfg);
512
+ if (!asksFamiliarity(cfg) && cfg.familiarity_question !== undefined) {
513
+ fail(
514
+ `familiarity_question: ${repr(cfg.familiarity_question)} is never asked, because \`familiarity\` is false — remove one of the two`,
515
+ );
516
+ }
467
517
  cfg.questions.forEach((q, qn) => {
468
518
  checkQuestion(
469
519
  q,
@@ -481,6 +531,69 @@ const practiceOf = (
481
531
  cfg.practice.map((item, n) => ({ id: `P${String(n + 1)}`, ...item }));
482
532
 
483
533
  // ── the items: the leak guard, and the references to the config ───────────────────────────────
534
+ /**
535
+ * A line with several requirements is split upstream into one item per requirement, each carrying
536
+ * `unit: {text, part, of}`. Every part of a line must be an item, once, in one group (so
537
+ * `labeller_knows` keeps or drops the whole line): a lost part is a requirement nobody judged.
538
+ */
539
+ interface Part {
540
+ readonly item: Item;
541
+ readonly unit: Unit;
542
+ readonly n: number;
543
+ }
544
+ type Parts = readonly [Part, ...Part[]];
545
+ /** The items of each line, in items.json order; a line's list is never empty. */
546
+ const linesOf = (cfg: Config): ReadonlyMap<string, Parts> =>
547
+ cfg.items.reduce((byLine, item, n) => {
548
+ if (item.unit === undefined) return byLine;
549
+ const part: Part = { item, unit: item.unit, n };
550
+ const seen = byLine.get(item.unit.id);
551
+ return byLine.set(
552
+ item.unit.id,
553
+ seen ? [seen[0], ...seen.slice(1), part] : [part],
554
+ );
555
+ }, new Map<string, Parts>());
556
+ /** One part against the first part of its line: same count, in range, not repeated, same group. */
557
+ function checkPart({ item, unit, n }: Part, parts: Parts): void {
558
+ const [first] = parts;
559
+ const at = `items[${String(n)}]`;
560
+ if (unit.text !== first.unit.text)
561
+ fail(
562
+ `${at}.unit.text: ${repr(unit.text)}, but items[${String(first.n)}] of line ${repr(unit.id)} says ${repr(first.unit.text)}`,
563
+ );
564
+ if (unit.of !== first.unit.of)
565
+ fail(
566
+ `${at}.unit.of: ${String(unit.of)}, but items[${String(first.n)}].unit.of says ${String(first.unit.of)} for the same line`,
567
+ );
568
+ if (unit.part < 1 || unit.part > unit.of)
569
+ fail(
570
+ `${at}.unit.part: ${String(unit.part)} is not between 1 and ${String(unit.of)}`,
571
+ );
572
+ const earlier = parts.find((p) => p.n < n && p.unit.part === unit.part);
573
+ if (earlier !== undefined)
574
+ fail(
575
+ `${at}.unit.part: ${String(unit.part)} repeats items[${String(earlier.n)}].unit.part`,
576
+ );
577
+ if (item.group !== first.item.group)
578
+ fail(
579
+ `${at}.group: ${repr(item.group)}, but items[${String(first.n)}] of the same line is in ${repr(first.item.group)}`,
580
+ );
581
+ }
582
+ function checkUnits(cfg: Config): void {
583
+ for (const [line, parts] of linesOf(cfg)) {
584
+ parts.forEach((p) => {
585
+ checkPart(p, parts);
586
+ });
587
+ const [first] = parts;
588
+ const missing = Array.from({ length: first.unit.of }, (_, k) => k + 1).find(
589
+ (k) => !parts.some((p) => p.unit.part === k),
590
+ );
591
+ if (missing !== undefined)
592
+ fail(
593
+ `items[${String(first.n)}].unit: part ${String(missing)} of ${String(first.unit.of)} of line ${repr(line)} is not among the items`,
594
+ );
595
+ }
596
+ }
484
597
  function checkItemFields(cfg: Config): void {
485
598
  const sids: string[] = [];
486
599
  cfg.items.forEach((item, n) => {
@@ -503,6 +616,11 @@ function checkItemFields(cfg: Config): void {
503
616
  );
504
617
  });
505
618
  }
619
+ /** Every text of an item the labeller sees, by field: the page fields and the line of a unit. */
620
+ const visibleTexts = (item: Item): readonly (readonly [string, string])[] => [
621
+ ...PAGE_FIELDS.map((f): readonly [string, string] => [f, item[f] ?? ""]),
622
+ [UNIT_FIELD, item.unit?.text ?? ""],
623
+ ];
506
624
  const escapeRegExp = (s: string): string =>
507
625
  s.replace(/[\\^$.*+?()[\]{}|/]/g, "\\$&");
508
626
  /**
@@ -519,7 +637,7 @@ function checkNoSourceShown(cfg: Config): void {
519
637
  `(?<![\\p{L}\\p{N}_-])${escapeRegExp(sid)}(?![\\p{L}\\p{N}_-])`,
520
638
  "u",
521
639
  );
522
- const field = PAGE_FIELDS.find((f) => word.test(item[f] ?? ""));
640
+ const field = visibleTexts(item).find(([, text]) => word.test(text))?.[0];
523
641
  if (field !== undefined) {
524
642
  fail(
525
643
  `the source id ${repr(sid)} appears in the visible \`${field}\` of item ${item.source_id}. Take it out of the text (or use source ids that are not natural words).`,
@@ -586,13 +704,27 @@ function assignIds(
586
704
  }
587
705
 
588
706
  // ── a build, short of the page ──────────────────────────────────────────────────────────────────
589
- const shownFields = (item: Item): Readonly<Record<string, string>> =>
590
- Object.fromEntries(
707
+ /** A unit as the page shows it: the part and the line, without the line's private id. */
708
+ type ShownUnit = Omit<Unit, "id">;
709
+ const shownFields = (
710
+ item: Item,
711
+ ): Readonly<Record<string, string | ShownUnit>> => ({
712
+ ...Object.fromEntries(
591
713
  PAGE_FIELDS.flatMap((f) => {
592
714
  const v = item[f];
593
715
  return v === undefined ? [] : [[f, v]];
594
716
  }),
595
- );
717
+ ),
718
+ ...(item.unit === undefined
719
+ ? {}
720
+ : {
721
+ [UNIT_FIELD]: {
722
+ text: item.unit.text,
723
+ part: item.unit.part,
724
+ of: item.unit.of,
725
+ },
726
+ }),
727
+ });
596
728
 
597
729
  /** Only what the labeller can answer goes on the page, and only that gets an id, in id order. */
598
730
  function itemsOnPage(cfg: Config, keyPath: string) {
@@ -618,10 +750,11 @@ function payloadOf(cfg: Config, page: ReturnType<typeof itemsOnPage>) {
618
750
  title: cfg.title,
619
751
  intro: cfg.intro ?? "",
620
752
  groups: used.map((g) => ({ id: g, label: String(cfg.groups[g]) })),
621
- familiarity_question:
622
- cfg.familiarity_question ?? "How well do you know {group}?",
623
- familiarity: FAMILIARITY,
624
- allowed: allowedValues(cfg.questions),
753
+ familiarity_question: asksFamiliarity(cfg)
754
+ ? (cfg.familiarity_question ?? "How well do you know {group}?")
755
+ : "",
756
+ familiarity: asksFamiliarity(cfg) ? FAMILIARITY : [],
757
+ allowed: allowedValues(cfg.questions, asksFamiliarity(cfg)),
625
758
  questions: cfg.questions,
626
759
  practice: { round: practiceRound(cfg), items: practiceOf(cfg) },
627
760
  items: page.items.map((i) => ({ id: page.idOf(i), ...shownFields(i) })),
@@ -675,7 +808,7 @@ function keyOf(
675
808
  questions: cfg.questions.map((q) => q.id),
676
809
  // What counts as an answer: the same lists the page was given (`allowedValues`). `export`
677
810
  // checks every stored answer and familiarity against them, so the build's options decide.
678
- allowed: allowedValues(cfg.questions),
811
+ allowed: allowedValues(cfg.questions, asksFamiliarity(cfg)),
679
812
  // The practice is scored against this at export; its answers never go into the labels.
680
813
  practice: {
681
814
  round: practiceRound(cfg),
@@ -702,6 +835,7 @@ export function prepareBuild(configPath: string, outDir: string) {
702
835
  assertConfig(cfg);
703
836
  checkConfig(cfg);
704
837
  checkItemFields(cfg);
838
+ checkUnits(cfg);
705
839
  checkNoSourceShown(cfg);
706
840
  mkdirSync(outDir, { recursive: true });
707
841
  const keyPath = join(outDir, "key.json");
@@ -931,6 +1065,17 @@ function checkDump(dump: Json, key: Key): asserts dump is JsonObject {
931
1065
  }
932
1066
  }
933
1067
 
1068
+ /** A page built with `familiarity: false` asks no group anything: a group document came from elsewhere. */
1069
+ function checkNoFamiliarity(dump: JsonObject, allowed: Allowed): void {
1070
+ const groups = isObject(dump["groups"]) ? dump["groups"] : {};
1071
+ const [first] = Object.keys(groups).sort(byText);
1072
+ if (allowed.familiarity.length === 0 && first !== undefined) {
1073
+ fail(
1074
+ `groups/${first}: familiarity is off for this page (items.json \`familiarity: false\`), so no group document is expected — take it out of the dump`,
1075
+ );
1076
+ }
1077
+ }
1078
+
934
1079
  // ── export ────────────────────────────────────────────────────────────────────────────────────
935
1080
  interface Allowed {
936
1081
  readonly questions: Readonly<Record<string, readonly string[]>>;
@@ -1165,11 +1310,13 @@ function labelsOf(dump: JsonObject, ctx: Ctx, day: string) {
1165
1310
  const items = onPage.map(([nid, meta]) =>
1166
1311
  labelOf(nid, meta, docOf(dump["answers"], nid), ctx),
1167
1312
  );
1168
- const groups = groupsOf(
1169
- [...new Set(onPage.map(([, m]) => m.group))],
1170
- dump["groups"],
1171
- ctx.allowed,
1172
- );
1313
+ const groups = ctx.allowed.familiarity.length
1314
+ ? groupsOf(
1315
+ [...new Set(onPage.map(([, m]) => m.group))],
1316
+ dump["groups"],
1317
+ ctx.allowed,
1318
+ )
1319
+ : {};
1173
1320
  return {
1174
1321
  study: ctx.key.study,
1175
1322
  exported: day,
@@ -1197,6 +1344,7 @@ export function exportLabels(
1197
1344
  const allowed = key.allowed;
1198
1345
  assertAllowed(allowed, key, keyPath);
1199
1346
  checkDump(dump, key);
1347
+ checkNoFamiliarity(dump, allowed);
1200
1348
  const out = labelsOf(dump, { key, allowed }, day);
1201
1349
  mkdirSync(outDir, { recursive: true });
1202
1350
  const path = join(outDir, `${key.study}-labels-${day}.json`);
@@ -411,3 +411,63 @@ test("🔴 without the store the page is read-only, and the real items stay read
411
411
  assert.match(shown, /cannot be saved in this view/);
412
412
  assert.match(shown, /G001/, "a real item is shown read-only");
413
413
  });
414
+
415
+ test("🔴 one requirement of a line: its card shows which part it is and the whole line beneath", async () => {
416
+ const line = "TypeScript only (no JavaScript); top-level await is allowed.";
417
+ const { page } = build(
418
+ setup(
419
+ "unit-card",
420
+ config({
421
+ items: ITEMS.map((item, n) =>
422
+ n === 0
423
+ ? {
424
+ ...item,
425
+ body: "TypeScript only (no JavaScript)",
426
+ unit: { id: "L1", text: line, part: 1, of: 2 },
427
+ }
428
+ : n === 3
429
+ ? {
430
+ ...item,
431
+ body: "top-level await is allowed",
432
+ unit: { id: "L1", text: line, part: 2, of: 2 },
433
+ }
434
+ : item,
435
+ ),
436
+ }),
437
+ ),
438
+ );
439
+ const shown = await showPage(page, {}, {});
440
+ assert.match(
441
+ shown,
442
+ /From the line \(requirement 1 of 2\):\nTypeScript only \(no JavaScript\); top-level await is allowed\./,
443
+ shown,
444
+ );
445
+ assert.match(shown, /From the line \(requirement 2 of 2\):/);
446
+ assert.equal(
447
+ occurrences(shown, "From the line"),
448
+ 2,
449
+ "only the two parts carry the line",
450
+ );
451
+ });
452
+
453
+ test("🔴 familiarity off: no group asks how well it is known, and a group is done when its items are", async () => {
454
+ const { page, key } = build(
455
+ setup("familiarity-off-page", approval({ familiarity: false })),
456
+ );
457
+ const practice = Object.fromEntries(
458
+ APPROVAL_PRACTICE.map((p, n) => [
459
+ `r1-P${String(n + 1)}`,
460
+ { answers: p.expected, source: "page" },
461
+ ]),
462
+ );
463
+ const answers = Object.fromEntries(
464
+ Object.keys(key.ids).map((id) => [
465
+ id,
466
+ { answers: { keep: "keep" }, source: "page" },
467
+ ]),
468
+ );
469
+ const shown = await showPage(page, answers, {}, practice);
470
+ assert.doesNotMatch(shown, /How well/);
471
+ assert.doesNotMatch(shown, /how well you know it/);
472
+ assert.match(shown, /Nothing left/, shown);
473
+ });
@@ -40,6 +40,7 @@
40
40
  * "seed": 7, fixes the order; change it and every id changes
41
41
  * "groups": {"python": "Python"},
42
42
  * "labeller_knows": ["python"], optional: items of other groups are not put on the page
43
+ * "familiarity": false, optional: no "how well do you know" question (approval page)
43
44
  * "familiarity_question": "How well do you know {group}?", optional
44
45
  * "questions": [{"id": "label", "text": "Is this correct?", "short": "correct?",
45
46
  * "options": [{"value": "yes", "label": "Yes", "example": "one real case",
@@ -52,7 +53,11 @@
52
53
  * "practice": [{"body": "…", "expected": {"label": "yes"}, "why": "one line"}, …], 3 or more
53
54
  * "practice_round": 1, optional; raise it after rewording, the practice reopens
54
55
  * "items": [{"source_id": "case-0412-b", "group": "python", "body": "…", "code": "…",
55
- * "context": "…"}]
56
+ * "context": "…",
57
+ * "unit": {"id": "a.md:12", "text": "the whole line", "part": 1, "of": 2}}]
58
+ * unit: optional; one requirement of a line that holds
59
+ * several. Every part of the line is an item, in one group;
60
+ * id (never shown) tells two places with the same words apart.
56
61
  * }
57
62
  *
58
63
  * TypeScript, compiled beside itself to `label-page.js` by `npm run build`: a consumer runs the skill