paperlint 13.0.3 → 13.2.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.
Files changed (114) hide show
  1. package/CLAUDE.md +9 -1
  2. package/dist/adapters/git/index.d.ts.map +1 -1
  3. package/dist/adapters/git/index.js +13 -0
  4. package/dist/adapters/git/index.js.map +1 -1
  5. package/dist/adapters/study-registry/index.d.ts +142 -1
  6. package/dist/adapters/study-registry/index.d.ts.map +1 -1
  7. package/dist/adapters/study-registry/index.js +160 -1
  8. package/dist/adapters/study-registry/index.js.map +1 -1
  9. package/dist/cli.d.ts.map +1 -1
  10. package/dist/cli.js +3 -0
  11. package/dist/cli.js.map +1 -1
  12. package/dist/domain/decide-first.d.ts +43 -0
  13. package/dist/domain/decide-first.d.ts.map +1 -1
  14. package/dist/domain/decide-first.js +45 -2
  15. package/dist/domain/decide-first.js.map +1 -1
  16. package/dist/domain/study-gates.d.ts +96 -0
  17. package/dist/domain/study-gates.d.ts.map +1 -0
  18. package/dist/domain/study-gates.js +56 -0
  19. package/dist/domain/study-gates.js.map +1 -0
  20. package/dist/domain/study-mode.d.ts +6 -0
  21. package/dist/domain/study-mode.d.ts.map +1 -1
  22. package/dist/domain/study-mode.js.map +1 -1
  23. package/dist/domain/study-registry.d.ts +24 -4
  24. package/dist/domain/study-registry.d.ts.map +1 -1
  25. package/dist/domain/study-registry.js +39 -2
  26. package/dist/domain/study-registry.js.map +1 -1
  27. package/dist/domain/study-result.d.ts +151 -0
  28. package/dist/domain/study-result.d.ts.map +1 -0
  29. package/dist/domain/study-result.js +175 -0
  30. package/dist/domain/study-result.js.map +1 -0
  31. package/dist/outputs-before-decision-rule.d.ts.map +1 -1
  32. package/dist/outputs-before-decision-rule.js +37 -3
  33. package/dist/outputs-before-decision-rule.js.map +1 -1
  34. package/dist/ports/git-history.d.ts +12 -0
  35. package/dist/ports/git-history.d.ts.map +1 -1
  36. package/dist/ports/study-registry-codec.d.ts +3 -0
  37. package/dist/ports/study-registry-codec.d.ts.map +1 -1
  38. package/dist/skills/build-label-page/scripts/label-page-cli.d.ts +6 -0
  39. package/dist/skills/build-label-page/scripts/label-page-commands.d.ts +14 -0
  40. package/dist/skills/build-label-page/scripts/label-page-core.d.ts +149 -0
  41. package/dist/skills/build-label-page/scripts/label-page-html.d.ts +5 -0
  42. package/dist/skills/build-label-page/scripts/label-page.d.ts +2 -0
  43. package/dist/study-modes.d.ts +3 -0
  44. package/dist/study-modes.d.ts.map +1 -1
  45. package/dist/study-modes.js +8 -1
  46. package/dist/study-modes.js.map +1 -1
  47. package/dist/study-registry-rule.d.ts.map +1 -1
  48. package/dist/study-registry-rule.js +1 -0
  49. package/dist/study-registry-rule.js.map +1 -1
  50. package/dist/study-result-rule.d.ts +12 -0
  51. package/dist/study-result-rule.d.ts.map +1 -0
  52. package/dist/study-result-rule.js +87 -0
  53. package/dist/study-result-rule.js.map +1 -0
  54. package/docs/design/study-gates.md +208 -0
  55. package/docs/rules/repro/outputs-before-decision.md +8 -0
  56. package/docs/rules/repro/study-registered.md +38 -15
  57. package/docs/rules/repro/study-result.md +100 -0
  58. package/docs/rules.md +1 -0
  59. package/eslint.config.mjs +1 -1
  60. package/hooks/hooks.harness.ts +51 -13
  61. package/hooks/paper-skills-nudge.hook.d.mts +22 -0
  62. package/hooks/paper-skills-nudge.hook.mjs +85 -23
  63. package/hooks/paper-skills-nudge.test.ts +82 -4
  64. package/package.json +2 -1
  65. package/plugin/hooks/hooks.json +7 -2
  66. package/schemas/plan.v2.schema.json +341 -0
  67. package/skills/argument-arc/SKILL.md.spec.ts +1 -1
  68. package/skills/build-label-page/SKILL.md +85 -54
  69. package/skills/build-label-page/SKILL.md.spec.ts +85 -53
  70. package/skills/build-label-page/build-label-page.test.ts +10 -941
  71. package/skills/build-label-page/references/checklist.md +45 -3
  72. package/skills/build-label-page/references/closed-choice-generator.md +88 -0
  73. package/skills/build-label-page/scripts/label-page-cli.js +72 -0
  74. package/skills/build-label-page/scripts/label-page-cli.ts +85 -0
  75. package/skills/build-label-page/scripts/label-page-commands.js +14 -0
  76. package/skills/build-label-page/scripts/label-page-commands.ts +14 -0
  77. package/skills/build-label-page/scripts/label-page-core.js +850 -0
  78. package/skills/build-label-page/scripts/label-page-core.test.ts +1164 -0
  79. package/skills/build-label-page/scripts/label-page-core.ts +1210 -0
  80. package/skills/build-label-page/scripts/label-page-html.js +33 -0
  81. package/skills/build-label-page/scripts/label-page-html.test.ts +413 -0
  82. package/skills/build-label-page/scripts/label-page-html.ts +43 -0
  83. package/skills/build-label-page/scripts/label-page.js +70 -0
  84. package/skills/build-label-page/scripts/label-page.test.ts +71 -0
  85. package/skills/build-label-page/scripts/label-page.ts +71 -0
  86. package/skills/build-label-page/scripts/page-template.html +513 -0
  87. package/skills/find-venue/SKILL.md.spec.ts +1 -1
  88. package/skills/paper-status/SKILL.md.spec.ts +1 -1
  89. package/skills/render-paper/SKILL.md.spec.ts +1 -1
  90. package/src/adapters/git/index.test.ts +33 -0
  91. package/src/adapters/git/index.ts +19 -0
  92. package/src/adapters/study-registry/gates.test.ts +181 -0
  93. package/src/adapters/study-registry/index.ts +219 -1
  94. package/src/adapters/study-registry/mode.test.ts +6 -0
  95. package/src/cli.ts +3 -0
  96. package/src/domain/decide-first.test.ts +131 -0
  97. package/src/domain/decide-first.ts +92 -2
  98. package/src/domain/study-gates.ts +125 -0
  99. package/src/domain/study-measurements.test.ts +71 -0
  100. package/src/domain/study-mode.ts +6 -0
  101. package/src/domain/study-registry.ts +64 -2
  102. package/src/domain/study-result.test.ts +300 -0
  103. package/src/domain/study-result.ts +358 -0
  104. package/src/outputs-before-decision-rule.test.ts +115 -2
  105. package/src/outputs-before-decision-rule.ts +50 -2
  106. package/src/ports/git-history.ts +12 -0
  107. package/src/ports/study-registry-codec.ts +3 -0
  108. package/src/rule-cases.test.ts +74 -0
  109. package/src/studies.test.ts +3 -0
  110. package/src/study-modes.ts +10 -1
  111. package/src/study-registry-rule.ts +1 -0
  112. package/src/study-result-rule.test.ts +281 -0
  113. package/src/study-result-rule.ts +123 -0
  114. package/skills/build-label-page/references/example-generator.py +0 -803
@@ -0,0 +1,1164 @@
1
+ /**
2
+ * The generator's CORE, called in this process: the items.json schema and its checks, the leak
3
+ * guard, ids and the key, the dump check and the export. No DOM here; the page is
4
+ * label-page-html.test.ts. Each check both ways: quiet on a good input, refusing a bad one by path.
5
+ */
6
+ import assert from "node:assert/strict";
7
+ import { readFileSync, writeFileSync } from "node:fs";
8
+ import { join } from "node:path";
9
+ import { test } from "vitest";
10
+ import { z } from "zod";
11
+ import {
12
+ DONE_PRACTICE,
13
+ Exported,
14
+ ITEMS,
15
+ PRACTICE,
16
+ QUESTIONS,
17
+ approval,
18
+ build,
19
+ config,
20
+ exportAt,
21
+ labelsFile,
22
+ run,
23
+ setup,
24
+ } from "../../../test/label-page-kit.ts";
25
+
26
+ /** A node of the declared config schema, as `example-generator.py schema` prints it. */
27
+ const Node: z.ZodType<SchemaNode> = z.lazy(() =>
28
+ z.object({
29
+ kind: z.enum(["string", "integer", "map", "array", "object"]),
30
+ required: z.boolean(),
31
+ pattern: z.string().optional(),
32
+ key_pattern: z.string().optional(),
33
+ min: z.number().optional(),
34
+ closed: z.boolean().optional(),
35
+ items: Node.optional(),
36
+ values: Node.optional(),
37
+ fields: z.record(z.string(), Node).optional(),
38
+ }),
39
+ );
40
+
41
+ interface SchemaNode {
42
+ readonly kind: "string" | "integer" | "map" | "array" | "object";
43
+ readonly required: boolean;
44
+ readonly pattern?: string | undefined;
45
+ readonly key_pattern?: string | undefined;
46
+ readonly min?: number | undefined;
47
+ readonly closed?: boolean | undefined;
48
+ readonly items?: SchemaNode | undefined;
49
+ readonly values?: SchemaNode | undefined;
50
+ readonly fields?: Readonly<Record<string, SchemaNode>> | undefined;
51
+ }
52
+
53
+ const Doc = z.json();
54
+
55
+ type Doc = z.infer<typeof Doc>;
56
+
57
+ interface DocObject {
58
+ readonly [key: string]: Doc;
59
+ }
60
+
61
+ const isObject = (v: Doc | undefined): v is DocObject =>
62
+ typeof v === "object" && v !== null && !Array.isArray(v);
63
+
64
+ /** A config with EVERY optional field present, so every schema path has a value to spoil. */
65
+ const FULL: DocObject = (() => {
66
+ const doc = Doc.parse(
67
+ config({
68
+ intro: "Label these.",
69
+ questions: [
70
+ {
71
+ ...QUESTIONS[0],
72
+ options: [
73
+ QUESTIONS[0]?.options[0],
74
+ { ...QUESTIONS[0]?.options[1], ask_why: "What is wrong with it?" },
75
+ ],
76
+ },
77
+ QUESTIONS[1],
78
+ ],
79
+ labeller_knows: ["python", "go", "rust"],
80
+ familiarity_question: "How well do you know {group}?",
81
+ items: ITEMS.map((i, n) =>
82
+ n === 0 ? { ...i, title: "T", code: "c = 1", context: "ctx" } : i,
83
+ ),
84
+ }),
85
+ );
86
+ assert.ok(isObject(doc));
87
+ return doc;
88
+ })();
89
+
90
+ interface Leaf {
91
+ readonly path: string;
92
+ readonly node: SchemaNode;
93
+ /** An array entry or a map value: it is not "a field", so it cannot be missing. */
94
+ readonly member: boolean;
95
+ }
96
+
97
+ /** The leaves one level down from `node`, whose value in the config is `value`. */
98
+ function children(
99
+ node: SchemaNode,
100
+ value: Doc | undefined,
101
+ path: string,
102
+ ): readonly Leaf[] {
103
+ if (isObject(value)) {
104
+ return node.kind === "object"
105
+ ? Object.entries(node.fields ?? {}).flatMap(([name, sub]) =>
106
+ leaves(
107
+ sub,
108
+ value[name],
109
+ path === "" ? name : `${path}.${name}`,
110
+ false,
111
+ ),
112
+ )
113
+ : mapChildren(node, value, path);
114
+ }
115
+ if (Array.isArray(value) && node.kind === "array" && node.items) {
116
+ return leaves(node.items, value[0], `${path}[0]`, true);
117
+ }
118
+ return [];
119
+ }
120
+
121
+ function mapChildren(
122
+ node: SchemaNode,
123
+ value: DocObject,
124
+ path: string,
125
+ ): readonly Leaf[] {
126
+ if (node.kind !== "map" || !node.values) return [];
127
+ const [k, v] = Object.entries(value)[0] ?? ["", undefined];
128
+ return leaves(node.values, v, `${path}.${k}`, true);
129
+ }
130
+
131
+ /** Every concrete path of `value` under `node`, with the node that governs it. */
132
+ function leaves(
133
+ node: SchemaNode,
134
+ value: Doc | undefined,
135
+ path: string,
136
+ member: boolean,
137
+ ): readonly Leaf[] {
138
+ return [{ path, node, member }, ...children(node, value, path)];
139
+ }
140
+
141
+ /** `doc` with the value at `steps` replaced, or removed when `bad` is `undefined`. */
142
+ function put(
143
+ doc: Doc,
144
+ steps: readonly (string | number)[],
145
+ bad: Doc | undefined,
146
+ ): Doc {
147
+ const [head, ...rest] = steps;
148
+ if (head === undefined) return bad ?? null;
149
+ if (Array.isArray(doc) && typeof head === "number") {
150
+ return doc.map((v, i) => (i === head ? put(v, rest, bad) : v));
151
+ }
152
+ if (isObject(doc) && typeof head === "string") {
153
+ if (rest.length === 0 && bad === undefined) {
154
+ return Object.fromEntries(
155
+ Object.entries(doc).filter(([k]) => k !== head),
156
+ );
157
+ }
158
+ return { ...doc, [head]: put(doc[head] ?? null, rest, bad) };
159
+ }
160
+ return doc;
161
+ }
162
+
163
+ const stepsOf = (path: string): readonly (string | number)[] =>
164
+ [...path.matchAll(/([^.[\]]+)|\[(\d+)\]/g)].map((m) =>
165
+ m[2] !== undefined ? Number(m[2]) : (m[1] ?? ""),
166
+ );
167
+
168
+ /** The config with the value at `path` replaced (or removed when `bad` is `undefined`). */
169
+ function spoil(path: string, bad: Doc | undefined): DocObject {
170
+ const doc = put(FULL, stepsOf(path), bad);
171
+ assert.ok(isObject(doc));
172
+ return doc;
173
+ }
174
+
175
+ const WRONG_TYPES: Readonly<Record<SchemaNode["kind"], readonly Doc[]>> = {
176
+ string: [null, 1, true, ["x"], {}, "", " "],
177
+ integer: [null, "7", true, 7.5, []],
178
+ map: [null, "x", ["x"], 1],
179
+ array: [null, "x", {}, 1],
180
+ object: [null, "x", ["x"], 1],
181
+ };
182
+
183
+ /** Build `doc`; the exit status and output. */
184
+ function tryBuild(name: string, doc: DocObject): ReturnType<typeof run> {
185
+ const dir = setup(name, doc);
186
+ return run(["build", join(dir, "items.json"), join(dir, "out")]);
187
+ }
188
+
189
+ type BadCase = readonly [label: string, value: Doc | undefined];
190
+
191
+ const bad = (label: string, value: Doc | undefined): BadCase => [label, value];
192
+
193
+ /** The values that must be refused at `leaf`: wrong type, null, empty, a malformed id, missing. */
194
+ function badCases({ node, member }: Leaf): readonly BadCase[] {
195
+ const wrongType = WRONG_TYPES[node.kind]
196
+ // an empty list is a legal value where nothing is required of it
197
+ .filter((v) => !(Array.isArray(v) && node.kind === "array"))
198
+ .map((v) => bad(JSON.stringify(v), v));
199
+ const extra: readonly BadCase[] = [
200
+ // `__proto__` matches the id pattern, but the page stores answers in plain JavaScript objects,
201
+ // where assigning it sets the prototype instead of an answer: every tap would save nothing.
202
+ ...(node.pattern === undefined
203
+ ? []
204
+ : [bad("bad id!", "bad id!"), bad("__proto__", "__proto__")]),
205
+ ...(node.kind === "array" && node.min ? [bad("[]", [])] : []),
206
+ ...(node.kind === "map" && node.min ? [bad("{}", {})] : []),
207
+ ...(node.required && !member ? [bad("(missing)", undefined)] : []),
208
+ ];
209
+ return [...wrongType, ...extra];
210
+ }
211
+
212
+ /** Spoil `leaf` each way in `badCases`; every one must stop the build and name the path. */
213
+ function expectRefused(leaf: Leaf): void {
214
+ badCases(leaf).forEach(([label, value], i) => {
215
+ const name = `schema-${leaf.path.replace(/\W/g, "_")}-${String(i)}`;
216
+ const r = tryBuild(name, spoil(leaf.path, value));
217
+ const what = `${leaf.path} = ${label}`;
218
+ assert.notEqual(r.status, 0, `${what} must stop the build`);
219
+ assert.ok(
220
+ r.out.includes(`REFUSED: ${leaf.path}`),
221
+ `${what}: the refusal must name the path\n${r.out}`,
222
+ );
223
+ assert.doesNotMatch(r.out, /Traceback/, `${what}\n${r.out}`);
224
+ });
225
+ if (!leaf.node.required && !leaf.member) {
226
+ const r = tryBuild(
227
+ `schema-${leaf.path.replace(/\W/g, "_")}-absent`,
228
+ spoil(leaf.path, undefined),
229
+ );
230
+ assert.equal(r.status, 0, `${leaf.path} is optional: ${r.out}`);
231
+ }
232
+ }
233
+
234
+ const DOCUMENTED_FIELDS = [
235
+ "study",
236
+ "title",
237
+ "intro",
238
+ "seed",
239
+ "groups",
240
+ "labeller_knows",
241
+ "familiarity_question",
242
+ "questions",
243
+ "items",
244
+ ];
245
+
246
+ /** Configs only the cross-field checks refuse, each with the refusal that names it. */
247
+ function moreRefusals(): readonly (readonly [
248
+ string,
249
+ Record<string, unknown>,
250
+ string,
251
+ ])[] {
252
+ const [q] = QUESTIONS;
253
+ assert.ok(q !== undefined);
254
+ const [yes, no] = q.options;
255
+ assert.ok(yes !== undefined && no !== undefined);
256
+ const [first] = ITEMS;
257
+ assert.ok(first !== undefined);
258
+ return [
259
+ [
260
+ "group-key",
261
+ { groups: { "bad id!": "Python" } },
262
+ "groups.bad id!: key must be letters, digits, - and _ only",
263
+ ],
264
+ [
265
+ "knows-unknown",
266
+ { labeller_knows: ["nope"] },
267
+ "labeller_knows[0]: 'nope' is not in `groups`",
268
+ ],
269
+ [
270
+ "knows-none",
271
+ { labeller_knows: [] },
272
+ "no item is left after `labeller_knows`",
273
+ ],
274
+ [
275
+ "question-twice",
276
+ { questions: [q, q] },
277
+ "questions[1].id: 'label' is repeated",
278
+ ],
279
+ [
280
+ "value-twice",
281
+ { questions: [{ ...q, options: [yes, { ...yes, label: "Y2" }] }] },
282
+ "questions[0].options: values must be distinct, got ['yes', 'yes']",
283
+ ],
284
+ [
285
+ "label-twice",
286
+ { questions: [{ ...q, options: [yes, { ...no, label: " yes " }] }] },
287
+ "questions[0].options[1].label: ' yes ' reads the same as options[0].label",
288
+ ],
289
+ ["round-0", { practice_round: 0 }, "practice_round: 0 — must be 1 or more"],
290
+ [
291
+ "source-twice",
292
+ { items: [first, first] },
293
+ "items[1].source_id: 'case-1000' is repeated",
294
+ ],
295
+ [
296
+ "group-unknown",
297
+ { items: [{ ...first, group: "cobol" }] },
298
+ "items[0].group: 'cobol' is not in `groups`",
299
+ ],
300
+ ["quote", { study: "it's" }, `study: "it's" must be letters`],
301
+ ["quotes", { study: `a'b"c` }, `study: 'a\\'b"c' must be letters`],
302
+ ["true", { seed: true }, "seed: must be an integer, got True"],
303
+ ["false", { seed: false }, "seed: must be an integer, got False"],
304
+ ];
305
+ }
306
+
307
+ test("🔴 the leak guard: a field outside the allow-list stops the build and is named", () => {
308
+ for (const field of ["model_answer", "verdict", "source", "difficulty"]) {
309
+ const items = [{ ...ITEMS[0], [field]: "x" }, ...ITEMS.slice(1)];
310
+ const dir = setup(`leak-${field}`, config({ items }));
311
+ const r = run(["build", join(dir, "items.json"), join(dir, "out")]);
312
+ assert.notEqual(r.status, 0, field);
313
+ assert.match(r.out, new RegExp(`REFUSED.*${field}`), r.out);
314
+ }
315
+ });
316
+
317
+ test("🔴 the leak guard: a source id typed into a visible text stops the build", () => {
318
+ const items = [
319
+ { ...ITEMS[0], body: `see case-1005 for the same` },
320
+ ...ITEMS.slice(1),
321
+ ];
322
+ const dir = setup("leak-text", config({ items }));
323
+ const r = run(["build", join(dir, "items.json"), join(dir, "out")]);
324
+ assert.notEqual(r.status, 0);
325
+ assert.match(r.out, /case-1005/);
326
+ });
327
+
328
+ test("only the groups the labeller knows reach the page, and only they get ids", () => {
329
+ const { page, key } = build(
330
+ setup("knows", config({ labeller_knows: ["python", "go"] })),
331
+ );
332
+ const onPage = Object.values(key.ids).filter((v) => v.on_page);
333
+ assert.ok(onPage.length > 0 && onPage.length < ITEMS.length);
334
+ assert.ok(onPage.every((v) => v.group !== "rust"));
335
+ assert.equal(Object.keys(key.ids).length, onPage.length);
336
+ assert.ok(!page.includes('"id":"Rust"') && !page.includes('"label":"Rust"'));
337
+ });
338
+
339
+ test("rebuilding keeps every id; a new item takes the next one", () => {
340
+ const dir = setup("stable", config());
341
+ const first = build(dir).key;
342
+ const more = [
343
+ ...ITEMS,
344
+ { source_id: "case-2000", group: "go", body: "late addition" },
345
+ ];
346
+ writeFileSync(
347
+ join(dir, "items.json"),
348
+ JSON.stringify(config({ items: more })),
349
+ );
350
+ const second = build(dir).key;
351
+ for (const [id, v] of Object.entries(first.ids)) {
352
+ assert.equal(second.ids[id]?.source_id, v.source_id, id);
353
+ }
354
+ assert.equal(second.ids["G013"]?.source_id, "case-2000");
355
+ const wrongSeed = setup("stable", config({ seed: 99, items: more }));
356
+ assert.notEqual(
357
+ run(["build", join(wrongSeed, "items.json"), join(wrongSeed, "out")])
358
+ .status,
359
+ 0,
360
+ );
361
+ });
362
+
363
+ test("🔴 build refuses an option without a usable value or label, and names the question and the option", () => {
364
+ const bad: readonly (readonly [string, unknown])[] = [
365
+ ["no-value", { label: "Maybe" }],
366
+ ["null-value", { value: null, label: "Maybe" }],
367
+ ["empty-value", { value: "", label: "Maybe" }],
368
+ ["no-label", { value: "maybe" }],
369
+ ["empty-label", { value: "maybe", label: " " }],
370
+ ["not-an-object", "maybe"],
371
+ // a different value behind a button that reads the same: the answer cannot be interpreted
372
+ [
373
+ "same-label",
374
+ { value: "maybe", label: ` ${QUESTIONS[1]?.options[0]?.label ?? ""} ` },
375
+ ],
376
+ ];
377
+ for (const [name, option] of bad) {
378
+ const questions = [
379
+ QUESTIONS[0],
380
+ {
381
+ ...QUESTIONS[1],
382
+ options: [...(QUESTIONS[1]?.options ?? []), option],
383
+ },
384
+ ];
385
+ const dir = setup(`option-${name}`, config({ questions }));
386
+ const r = run(["build", join(dir, "items.json"), join(dir, "out")]);
387
+ assert.notEqual(r.status, 0, `${name} must stop the build`);
388
+ assert.match(r.out, /REFUSED.*questions\[1\]\.options\[2\]/s, r.out);
389
+ }
390
+ });
391
+
392
+ test("🔴 export does not count an answer outside the question's allowed values; it marks the item incomplete and names it", () => {
393
+ const dir = setup("export-typo", config());
394
+ const { key } = build(dir);
395
+ const [a, b, c] = Object.keys(key.ids);
396
+ assert.ok(a !== undefined && b !== undefined && c !== undefined);
397
+ const dump = {
398
+ answers: {
399
+ [a]: { answers: { label: "yes", boundary: "clear" }, source: "page" },
400
+ // a typo from a chat answer: "yse" is not one of yes/no
401
+ [b]: { answers: { label: "yse", boundary: "clear" }, source: "chat" },
402
+ // options changed between builds: "maybe" is not offered any more
403
+ [c]: { answers: { label: "no", boundary: "maybe" }, source: "page" },
404
+ },
405
+ };
406
+ writeFileSync(join(dir, "dump.json"), JSON.stringify(dump));
407
+ const r = run([
408
+ "export",
409
+ join(dir, "dump.json"),
410
+ join(dir, "out", "key.json"),
411
+ join(dir, "data"),
412
+ "--date",
413
+ "2026-01-02",
414
+ ]);
415
+ assert.equal(r.status, 0, r.out);
416
+ assert.match(r.out, new RegExp(`invalid ${b}: label='yse'`), r.out);
417
+ assert.match(r.out, new RegExp(`invalid ${c}: boundary='maybe'`), r.out);
418
+ const file = Exported.parse(
419
+ JSON.parse(
420
+ readFileSync(
421
+ join(dir, "data", "demo-gold-labels-2026-01-02.json"),
422
+ "utf8",
423
+ ),
424
+ ),
425
+ );
426
+ assert.equal(file.complete_items, 1, "only the valid item is complete");
427
+ assert.equal(file.items.find((i) => i.id === b)?.complete, false);
428
+ assert.equal(file.items.find((i) => i.id === c)?.complete, false);
429
+ assert.deepEqual(file.items.find((i) => i.id === b)?.invalid_answers, {
430
+ label: "yse",
431
+ });
432
+ assert.equal(file.items.find((i) => i.id === b)?.answers["label"], null);
433
+ assert.equal(file.items.find((i) => i.id === b)?.source, "chat");
434
+ });
435
+
436
+ test("🔴 the config is checked against one declared schema: every field, with a wrong type, null or empty, is refused and named by its path", () => {
437
+ const printed = run(["schema"]);
438
+ assert.equal(printed.status, 0, printed.out);
439
+ const schema = Node.parse(JSON.parse(printed.out));
440
+ assert.equal(
441
+ tryBuild("schema-ok", FULL).status,
442
+ 0,
443
+ "the full config must build before it is spoiled",
444
+ );
445
+ const all = leaves(schema, FULL, "", false).filter((l) => l.path !== "");
446
+ assert.ok(
447
+ all.length > 25,
448
+ `the schema walk found ${String(all.length)} fields`,
449
+ );
450
+ all.forEach(expectRefused);
451
+ // A key the schema does not know, in each closed object, is refused by path.
452
+ const closed = [{ path: "", node: schema, member: false }, ...all].filter(
453
+ (l) => l.node.kind === "object" && l.node.closed !== false,
454
+ );
455
+ assert.ok(
456
+ closed.length >= 3,
457
+ "the root, a question and an option are closed",
458
+ );
459
+ closed.forEach(({ path }, i) => {
460
+ const where = path === "" ? "zzz" : `${path}.zzz`;
461
+ const r = tryBuild(`schema-unknown-${String(i)}`, spoil(where, 1));
462
+ assert.notEqual(r.status, 0, `${where} must stop the build`);
463
+ assert.ok(r.out.includes(`REFUSED: ${where}`), `${where}\n${r.out}`);
464
+ });
465
+ // Every field the docstring documents has a line in the schema.
466
+ const paths = new Set(all.map((l) => l.path));
467
+ for (const f of DOCUMENTED_FIELDS) {
468
+ assert.ok(paths.has(f), `${f} is in the schema`);
469
+ }
470
+ }, 120_000);
471
+
472
+ test("🔴 a numeric question id is refused at build: JSON keys are strings, so it would be stored under one name and keyed under another", () => {
473
+ const questions = [{ ...QUESTIONS[0], id: 1 }, QUESTIONS[1]];
474
+ const dir = setup("numeric-id", config({ questions }));
475
+ const r = run(["build", join(dir, "items.json"), join(dir, "out")]);
476
+ assert.notEqual(r.status, 0);
477
+ assert.match(r.out, /REFUSED: questions\[0\]\.id/, r.out);
478
+ });
479
+
480
+ test("🔴 a familiarity_question that is not a string is refused at build, not a page that throws when it renders", () => {
481
+ for (const bad of [null, 5, ["x"]]) {
482
+ const dir = setup("fam-q", config({ familiarity_question: bad }));
483
+ const r = run(["build", join(dir, "items.json"), join(dir, "out")]);
484
+ assert.notEqual(r.status, 0, JSON.stringify(bad));
485
+ assert.match(r.out, /REFUSED: familiarity_question/, r.out);
486
+ }
487
+ });
488
+
489
+ test("export: one file named by the date, chat answers kept, incomplete items named", () => {
490
+ const dir = setup("export", config());
491
+ const { key } = build(dir);
492
+ const ids = Object.keys(key.ids);
493
+ const [a, b] = ids;
494
+ assert.ok(a !== undefined && b !== undefined);
495
+ const dump = {
496
+ answers: {
497
+ [a]: {
498
+ answers: { label: "yes", boundary: "clear" },
499
+ savedAt: "2026-01-02T03:04:05Z",
500
+ source: "page",
501
+ },
502
+ [b]: {
503
+ answers: { label: "no" },
504
+ savedAt: "2026-01-02T03:05:00Z",
505
+ source: "chat",
506
+ },
507
+ },
508
+ groups: {
509
+ python: {
510
+ familiarity: "well",
511
+ savedAt: "2026-01-02T03:04:00Z",
512
+ source: "page",
513
+ },
514
+ },
515
+ };
516
+ writeFileSync(join(dir, "dump.json"), JSON.stringify(dump));
517
+ const r = run([
518
+ "export",
519
+ join(dir, "dump.json"),
520
+ join(dir, "out", "key.json"),
521
+ join(dir, "data"),
522
+ "--date",
523
+ "2026-01-02",
524
+ ]);
525
+ assert.equal(r.status, 0, r.out);
526
+ assert.match(r.out, new RegExp(`incomplete ${b}: missing boundary`));
527
+ const file = Exported.parse(
528
+ JSON.parse(
529
+ readFileSync(
530
+ join(dir, "data", "demo-gold-labels-2026-01-02.json"),
531
+ "utf8",
532
+ ),
533
+ ),
534
+ );
535
+ assert.equal(file.complete_items, 1);
536
+ assert.equal(file.total_items, ids.length);
537
+ assert.equal(file.items.find((i) => i.id === a)?.source, "page");
538
+ assert.equal(file.items.find((i) => i.id === b)?.source, "chat");
539
+ assert.equal(
540
+ file.items.find((i) => i.id === a)?.source_id,
541
+ key.ids[a]?.source_id,
542
+ );
543
+ });
544
+
545
+ test("export refuses a source it does not know, and an id the key does not know", () => {
546
+ const dir = setup("export-bad", config());
547
+ build(dir);
548
+ const keyPath = join(dir, "out", "key.json");
549
+ writeFileSync(
550
+ join(dir, "d1.json"),
551
+ JSON.stringify({ answers: { G001: { answers: {}, source: "guess" } } }),
552
+ );
553
+ assert.notEqual(
554
+ run(["export", join(dir, "d1.json"), keyPath, join(dir, "data")]).status,
555
+ 0,
556
+ );
557
+ writeFileSync(
558
+ join(dir, "d2.json"),
559
+ JSON.stringify({ answers: { G999: { answers: {} } } }),
560
+ );
561
+ assert.notEqual(
562
+ run(["export", join(dir, "d2.json"), keyPath, join(dir, "data")]).status,
563
+ 0,
564
+ );
565
+ // A familiarity merged under a misspelled group would be dropped, and its group exported unrated.
566
+ writeFileSync(
567
+ join(dir, "d3.json"),
568
+ JSON.stringify({
569
+ groups: { pyhton: { familiarity: "well", source: "chat" } },
570
+ }),
571
+ );
572
+ const r3 = run(["export", join(dir, "d3.json"), keyPath, join(dir, "data")]);
573
+ assert.notEqual(r3.status, 0, r3.out);
574
+ assert.match(
575
+ r3.out,
576
+ /groups\/pyhton: the key does not know this group/,
577
+ r3.out,
578
+ );
579
+ });
580
+
581
+ test("🔴 export checks every name in the store against the key: a stray one is refused by its path, never dropped", () => {
582
+ const dir = setup("export-stray", config());
583
+ build(dir);
584
+ const keyPath = join(dir, "out", "key.json");
585
+ const doc = { answers: { label: "yes" }, source: "chat" };
586
+ const cases: readonly (readonly [string, unknown])[] = [
587
+ [
588
+ "answers/G001/answers/boundry",
589
+ { answers: { G001: { ...doc, answers: { boundry: "clear" } } } },
590
+ ],
591
+ ["answers/G001/note", { answers: { G001: { ...doc, note: "x" } } }],
592
+ [
593
+ "groups/python/familarity",
594
+ { groups: { python: { familarity: "well", source: "chat" } } },
595
+ ],
596
+ ["answer: the store has no such collection", { answer: {} }],
597
+ ];
598
+ cases.forEach(([path, dump], i) => {
599
+ const file = join(dir, `stray-${String(i)}.json`);
600
+ writeFileSync(file, JSON.stringify(dump));
601
+ const r = run(["export", file, keyPath, join(dir, "data")]);
602
+ assert.notEqual(r.status, 0, `${path}: ${r.out}`);
603
+ assert.ok(r.out.includes(path), `${path} must be named\n${r.out}`);
604
+ });
605
+ });
606
+
607
+ test("🔴 export refuses a stored answer or familiarity without a source, and names it: a missing one is not relabelled as the page's", () => {
608
+ const dir = setup("export-no-source", config());
609
+ build(dir);
610
+ const keyPath = join(dir, "out", "key.json");
611
+ const dumps = {
612
+ "answers/G001": { answers: { G001: { answers: { label: "yes" } } } },
613
+ "groups/python": { groups: { python: { familiarity: "well" } } },
614
+ };
615
+ for (const [path, dump] of Object.entries(dumps)) {
616
+ const file = join(dir, `${path.replace("/", "-")}.json`);
617
+ writeFileSync(file, JSON.stringify(dump));
618
+ const r = run(["export", file, keyPath, join(dir, "data")]);
619
+ assert.notEqual(r.status, 0, r.out);
620
+ assert.match(r.out, new RegExp(`${path}: no source`), r.out);
621
+ }
622
+ });
623
+
624
+ test("🔴 build refuses two groups whose headings read the same: the labeller could not tell which one a familiarity is for", () => {
625
+ const dir = setup(
626
+ "group-labels",
627
+ config({ groups: { python: "Python", python3: " python ", go: "Go" } }),
628
+ );
629
+ const r = run(["build", join(dir, "items.json"), join(dir, "out")]);
630
+ assert.notEqual(r.status, 0, r.out);
631
+ assert.match(r.out, /groups\.python3/, r.out);
632
+ });
633
+
634
+ test("🔴 export refuses an answer kept for an item a rebuild took off the page, instead of dropping it", () => {
635
+ const dir = setup("off-page", config());
636
+ const before = build(dir).key;
637
+ writeFileSync(
638
+ join(dir, "items.json"),
639
+ JSON.stringify(config({ labeller_knows: ["python"] })),
640
+ );
641
+ const after = build(dir).key;
642
+ const gone = Object.keys(after.ids).find(
643
+ (id) =>
644
+ before.ids[id]?.on_page === true && after.ids[id]?.on_page === false,
645
+ );
646
+ assert.ok(gone !== undefined, "the rebuild must take an item off the page");
647
+ const group = after.ids[gone]?.group ?? "";
648
+ const cases = {
649
+ [`answers/${gone}`]: {
650
+ answers: { [gone]: { answers: { label: "yes" }, source: "page" } },
651
+ },
652
+ [`groups/${group}`]: {
653
+ groups: { [group]: { familiarity: "well", source: "page" } },
654
+ },
655
+ };
656
+ Object.entries(cases).forEach(([path, dump], i) => {
657
+ const file = join(dir, `off-${String(i)}.json`);
658
+ writeFileSync(file, JSON.stringify(dump));
659
+ const r = run([
660
+ "export",
661
+ file,
662
+ join(dir, "out", "key.json"),
663
+ join(dir, "data"),
664
+ ]);
665
+ assert.notEqual(r.status, 0, `${path}: ${r.out}`);
666
+ assert.ok(
667
+ r.out.includes(`${path}: `) && r.out.includes("no longer on the page"),
668
+ r.out,
669
+ );
670
+ });
671
+ });
672
+
673
+ test("🔴 export refuses a stored field of the wrong type by its path, never a Python traceback", () => {
674
+ const dir = setup("export-types", config());
675
+ build(dir);
676
+ const keyPath = join(dir, "out", "key.json");
677
+ const cases: readonly (readonly [string, unknown])[] = [
678
+ [
679
+ "answers/G001/answers",
680
+ { answers: { G001: { answers: [], source: "chat" } } },
681
+ ],
682
+ [
683
+ "answers/G001/answers/label",
684
+ { answers: { G001: { answers: { label: 5 }, source: "chat" } } },
685
+ ],
686
+ [
687
+ "answers/G001/savedAt",
688
+ { answers: { G001: { answers: {}, savedAt: 1, source: "chat" } } },
689
+ ],
690
+ [
691
+ "groups/python/familiarity",
692
+ { groups: { python: { familiarity: 3, source: "chat" } } },
693
+ ],
694
+ [
695
+ "answers/G001/source",
696
+ { answers: { G001: { answers: {}, source: ["chat"] } } },
697
+ ],
698
+ ["answers", { answers: [], groups: {} }],
699
+ ["groups", { groups: "python" }],
700
+ ["store", []],
701
+ ];
702
+ cases.forEach(([path, dump], i) => {
703
+ const file = join(dir, `type-${String(i)}.json`);
704
+ writeFileSync(file, JSON.stringify(dump));
705
+ const r = run(["export", file, keyPath, join(dir, "data")]);
706
+ assert.notEqual(r.status, 0, `${path}: ${r.out}`);
707
+ assert.doesNotMatch(r.out, /Traceback/, `${path}\n${r.out}`);
708
+ assert.ok(r.out.includes(`${path}: `), `${path} must be named\n${r.out}`);
709
+ });
710
+ });
711
+
712
+ test("🔴 build refuses an answer option without an example: every answer is shown on one real case", () => {
713
+ const [first, ...rest] = QUESTIONS;
714
+ assert.ok(first !== undefined);
715
+ const bare = {
716
+ ...first,
717
+ options: first.options.map(({ value, label }) => ({ value, label })),
718
+ };
719
+ const dir = setup("no-example", config({ questions: [bare, ...rest] }));
720
+ const r = run(["build", join(dir, "items.json"), join(dir, "out")]);
721
+ assert.notEqual(r.status, 0);
722
+ assert.match(r.out, /questions\[0\]\.options\[0\]\.example: is required/);
723
+ });
724
+
725
+ test("🔴 build refuses fewer than three practice items, an expected answer that is not offered, and one that leaves a question out", () => {
726
+ const cases: readonly [string, unknown, RegExp][] = [
727
+ ["two", PRACTICE.slice(0, 2), /practice: needs 3 or more entries, has 2/],
728
+ [
729
+ "unoffered",
730
+ [
731
+ { ...PRACTICE[0], expected: { label: "maybe", boundary: "clear" } },
732
+ ...PRACTICE.slice(1),
733
+ ],
734
+ /practice\[0\]\.expected\.label: 'maybe' is not one of yes, no/,
735
+ ],
736
+ [
737
+ "partial",
738
+ [{ ...PRACTICE[0], expected: { label: "yes" } }, ...PRACTICE.slice(1)],
739
+ /practice\[0\]\.expected\.boundary: every question needs its expected answer/,
740
+ ],
741
+ [
742
+ "stray",
743
+ [
744
+ { ...PRACTICE[0], expected: { ...PRACTICE[0]?.expected, extra: "x" } },
745
+ ...PRACTICE.slice(1),
746
+ ],
747
+ /practice\[0\]\.expected\.extra: not a question of this page/,
748
+ ],
749
+ ];
750
+ for (const [name, practice, message] of cases) {
751
+ const dir = setup(`practice-${name}`, config({ practice }));
752
+ const r = run(["build", join(dir, "items.json"), join(dir, "out")]);
753
+ assert.notEqual(r.status, 0, name);
754
+ assert.match(r.out, message, name);
755
+ }
756
+ });
757
+
758
+ test("🔴 export keeps the practice out of the labels and writes it to its own file, with what matched", () => {
759
+ const dir = setup("practice-export", config());
760
+ build(dir);
761
+ const practice = {
762
+ ...DONE_PRACTICE,
763
+ "r1-P2": {
764
+ answers: { label: "yes", boundary: "clear" },
765
+ source: "page",
766
+ savedAt: "2026-01-02T10:00:00Z",
767
+ },
768
+ };
769
+ writeFileSync(
770
+ join(dir, "dump.json"),
771
+ JSON.stringify({ answers: {}, groups: {}, practice }),
772
+ );
773
+ const r = exportAt(dir);
774
+ assert.equal(r.status, 0, r.out);
775
+ const labels = labelsFile(dir);
776
+ assert.doesNotMatch(labels, /practice|r1-P/);
777
+ const file = z
778
+ .object({
779
+ round: z.number(),
780
+ matched: z.number(),
781
+ total: z.number(),
782
+ items: z.array(
783
+ z.object({
784
+ id: z.string(),
785
+ matched: z.boolean(),
786
+ answers: z.unknown(),
787
+ expected: z.unknown(),
788
+ }),
789
+ ),
790
+ })
791
+ .parse(
792
+ JSON.parse(
793
+ readFileSync(
794
+ join(dir, "data", "demo-gold-practice-r1-2026-01-02.json"),
795
+ "utf8",
796
+ ),
797
+ ),
798
+ );
799
+ assert.equal(file.round, 1);
800
+ assert.equal(file.matched, 2);
801
+ assert.equal(file.total, 3);
802
+ assert.deepEqual(
803
+ file.items.map((i) => [i.id, i.matched]),
804
+ [
805
+ ["P1", true],
806
+ ["P2", false],
807
+ ["P3", true],
808
+ ],
809
+ );
810
+ assert.match(r.out, /practice round 1: 2 of 3 matched; missed P2/);
811
+ });
812
+
813
+ test("🔴 export refuses a stored practice answer the key does not know, by its path", () => {
814
+ const dir = setup("practice-stray", config());
815
+ build(dir);
816
+ writeFileSync(
817
+ join(dir, "dump.json"),
818
+ JSON.stringify({
819
+ answers: {},
820
+ groups: {},
821
+ practice: { "r1-P9": { answers: {}, source: "page" } },
822
+ }),
823
+ );
824
+ const r = exportAt(dir);
825
+ assert.notEqual(r.status, 0);
826
+ assert.match(
827
+ r.out,
828
+ /practice\/r1-P9: the key does not know this practice item/,
829
+ );
830
+ });
831
+
832
+ test("🔴 approval: export keeps each reason with its answer, and a drop without one is incomplete and named", () => {
833
+ const dir = setup("approval-export", approval());
834
+ const { key } = build(dir);
835
+ const [a, b] = Object.keys(key.ids);
836
+ assert.ok(a !== undefined && b !== undefined);
837
+ const answers = {
838
+ [a]: {
839
+ answers: { keep: "drop" },
840
+ reasons: { keep: "a process rule" },
841
+ source: "page",
842
+ },
843
+ [b]: { answers: { keep: "drop" }, source: "page" },
844
+ };
845
+ writeFileSync(
846
+ join(dir, "dump.json"),
847
+ JSON.stringify({ answers, groups: {} }),
848
+ );
849
+ const r = exportAt(dir);
850
+ assert.equal(r.status, 0, r.out);
851
+ const file = z
852
+ .object({
853
+ items: z.array(
854
+ z.object({
855
+ id: z.string(),
856
+ complete: z.boolean(),
857
+ reasons: z.record(z.string(), z.string()),
858
+ }),
859
+ ),
860
+ })
861
+ .parse(JSON.parse(labelsFile(dir)));
862
+ assert.deepEqual(file.items.find((i) => i.id === a)?.reasons, {
863
+ keep: "a process rule",
864
+ });
865
+ assert.equal(file.items.find((i) => i.id === a)?.complete, true);
866
+ assert.equal(file.items.find((i) => i.id === b)?.complete, false);
867
+ assert.match(r.out, new RegExp(`${b}: keep=drop needs a reason`));
868
+ });
869
+
870
+ test("🔴 approval: a stored reason that is not text, or for a question the key does not know, is refused by path", () => {
871
+ const dir = setup("approval-bad-reason", approval());
872
+ const { key } = build(dir);
873
+ const [a] = Object.keys(key.ids);
874
+ assert.ok(a !== undefined);
875
+ for (const [reasons, message] of [
876
+ [{ keep: 3 }, new RegExp(`answers/${a}/reasons/keep: 3 — must be text`)],
877
+ [
878
+ { kept: "x" },
879
+ new RegExp(`answers/${a}/reasons/kept: not a question of the key`),
880
+ ],
881
+ ] as const) {
882
+ writeFileSync(
883
+ join(dir, "dump.json"),
884
+ JSON.stringify({
885
+ answers: {
886
+ [a]: { answers: { keep: "drop" }, reasons, source: "page" },
887
+ },
888
+ groups: {},
889
+ }),
890
+ );
891
+ const r = exportAt(dir);
892
+ assert.notEqual(r.status, 0);
893
+ assert.match(r.out, message);
894
+ }
895
+ });
896
+
897
+ test("🔴 build refuses, by path, every config the schema alone cannot stop", () => {
898
+ for (const [name, over, message] of moreRefusals()) {
899
+ const dir = setup(`more-${name}`, config(over));
900
+ const r = run(["build", join(dir, "items.json"), join(dir, "out")]);
901
+ assert.equal(r.status, 1, `${name}\n${r.out}`);
902
+ assert.ok(r.out.includes(`REFUSED: ${message}`), `${name}\n${r.out}`);
903
+ }
904
+ const dir = setup("not-json", {});
905
+ writeFileSync(join(dir, "items.json"), "not json");
906
+ assert.match(
907
+ run(["build", join(dir, "items.json"), join(dir, "out")]).out,
908
+ /REFUSED: cannot read .*items\.json as JSON: /,
909
+ );
910
+ }, 60_000);
911
+
912
+ test("🔴 a rebuild with another seed is refused: the stored answers are keyed by the old ids", () => {
913
+ const dir = setup("reseed", config());
914
+ build(dir);
915
+ writeFileSync(join(dir, "items.json"), JSON.stringify(config({ seed: 8 })));
916
+ const r = run(["build", join(dir, "items.json"), join(dir, "out")]);
917
+ assert.equal(r.status, 1);
918
+ assert.match(r.out, /key\.json was made with seed 7; this config says 8\./);
919
+ });
920
+
921
+ test("🔴 export refuses a store, a collection or a document of the wrong kind, naming the type it got", () => {
922
+ const dir = setup("dump-types", config());
923
+ const { key } = build(dir);
924
+ const [a] = Object.keys(key.ids);
925
+ assert.ok(a !== undefined);
926
+ const cases: readonly [unknown, string][] = [
927
+ ["x", "store: str — must be an object"],
928
+ [1, "store: int — must be an object"],
929
+ [1.5, "store: float — must be an object"],
930
+ [true, "store: bool — must be an object"],
931
+ [null, "store: NoneType — must be an object"],
932
+ [
933
+ { answers: { [a]: 3 } },
934
+ `answers/${a}: must be an object with answers, reasons, savedAt, source`,
935
+ ],
936
+ ];
937
+ for (const [dump, message] of cases) {
938
+ writeFileSync(join(dir, "dump.json"), JSON.stringify(dump));
939
+ const r = exportAt(dir);
940
+ assert.equal(r.status, 1, r.out);
941
+ assert.ok(r.out.includes(`REFUSED: ${message}`), r.out);
942
+ }
943
+ }, 60_000);
944
+
945
+ test("🔴 a key from an older build: no `allowed` is refused; no practice or reason lists still exports", () => {
946
+ const dir = setup("old-key", config());
947
+ build(dir);
948
+ const keyPath = join(dir, "out", "key.json");
949
+ const raw = z
950
+ .record(z.string(), z.unknown())
951
+ .parse(JSON.parse(readFileSync(keyPath, "utf8")));
952
+ writeFileSync(
953
+ join(dir, "dump.json"),
954
+ JSON.stringify({ answers: {}, groups: {} }),
955
+ );
956
+ writeFileSync(keyPath, JSON.stringify({ ...raw, allowed: undefined }));
957
+ assert.match(
958
+ exportAt(dir).out,
959
+ /has no `allowed` values for every question and for familiarity/,
960
+ );
961
+ const allowed = Object.fromEntries(
962
+ Object.entries(
963
+ z.record(z.string(), z.unknown()).parse(raw["allowed"]),
964
+ ).filter(([k]) => k !== "needs_why"),
965
+ );
966
+ writeFileSync(
967
+ keyPath,
968
+ JSON.stringify({ ...raw, practice: undefined, allowed }),
969
+ );
970
+ const r = exportAt(dir);
971
+ assert.equal(r.status, 0, r.out);
972
+ assert.doesNotMatch(r.out, /practice round/);
973
+ }, 60_000);
974
+
975
+ test("export: a practice answered as expected throughout is recorded with nothing missed", () => {
976
+ const dir = setup("practice-all", config());
977
+ build(dir);
978
+ writeFileSync(
979
+ join(dir, "dump.json"),
980
+ JSON.stringify({ answers: {}, groups: {}, practice: DONE_PRACTICE }),
981
+ );
982
+ const r = exportAt(dir);
983
+ assert.equal(r.status, 0, r.out);
984
+ assert.match(r.out, /practice round 1: 3 of 3 matched -> /);
985
+ });
986
+
987
+ test("🔴 a later practice round with fewer items keeps the earlier round's answers known: export does not refuse them", () => {
988
+ const four = [
989
+ ...PRACTICE,
990
+ { ...PRACTICE[0], body: "a fourth practice item" },
991
+ ];
992
+ const dir = setup("practice-shrinks", config({ practice: four }));
993
+ build(dir);
994
+ setup("practice-shrinks", config({ practice: PRACTICE, practice_round: 2 }));
995
+ build(dir);
996
+ writeFileSync(
997
+ join(dir, "dump.json"),
998
+ JSON.stringify({
999
+ answers: {},
1000
+ groups: {},
1001
+ practice: { "r1-P4": { answers: {}, source: "page" } },
1002
+ }),
1003
+ );
1004
+ const r = exportAt(dir);
1005
+ assert.equal(r.status, 0, r.out);
1006
+ });
1007
+
1008
+ test("a key from an older build: without `rounds` its own round's ids are kept; without `practice` there is no earlier round", () => {
1009
+ const four = [
1010
+ ...PRACTICE,
1011
+ { ...PRACTICE[0], body: "a fourth practice item" },
1012
+ ];
1013
+ const dir = setup("practice-old-key", config({ practice: four }));
1014
+ build(dir);
1015
+ const keyPath = join(dir, "out", "key.json");
1016
+ const Rounds = z.object({
1017
+ practice: z.object({
1018
+ rounds: z.record(z.string(), z.record(z.string(), z.unknown())),
1019
+ }),
1020
+ });
1021
+ const read = (): Record<string, unknown> =>
1022
+ z
1023
+ .record(z.string(), z.unknown())
1024
+ .parse(JSON.parse(readFileSync(keyPath, "utf8")));
1025
+ const withoutRounds = read();
1026
+ const practice = z
1027
+ .record(z.string(), z.unknown())
1028
+ .parse(withoutRounds["practice"]);
1029
+ writeFileSync(
1030
+ keyPath,
1031
+ JSON.stringify({
1032
+ ...withoutRounds,
1033
+ practice: Object.fromEntries(
1034
+ Object.entries(practice).filter(([k]) => k !== "rounds"),
1035
+ ),
1036
+ }),
1037
+ );
1038
+ setup("practice-old-key", config({ practice: PRACTICE, practice_round: 2 }));
1039
+ build(dir);
1040
+ const ids = (r: Record<string, Record<string, unknown>>) =>
1041
+ Object.fromEntries(Object.entries(r).map(([k, v]) => [k, Object.keys(v)]));
1042
+ assert.deepEqual(ids(Rounds.parse(read()).practice.rounds), {
1043
+ "1": ["P1", "P2", "P3", "P4"],
1044
+ "2": ["P1", "P2", "P3"],
1045
+ });
1046
+ writeFileSync(
1047
+ keyPath,
1048
+ JSON.stringify(
1049
+ Object.fromEntries(
1050
+ Object.entries(read()).filter(([k]) => k !== "practice"),
1051
+ ),
1052
+ ),
1053
+ );
1054
+ build(dir);
1055
+ assert.deepEqual(ids(Rounds.parse(read()).practice.rounds), {
1056
+ "2": ["P1", "P2", "P3"],
1057
+ });
1058
+ });
1059
+
1060
+ test("🔴 a second practice round exported the same day does not overwrite the first: each round has its own file", () => {
1061
+ const dir = setup("practice-two-rounds", config());
1062
+ build(dir);
1063
+ const missed = {
1064
+ ...DONE_PRACTICE,
1065
+ "r1-P2": { answers: { label: "yes", boundary: "clear" }, source: "page" },
1066
+ };
1067
+ writeFileSync(
1068
+ join(dir, "dump.json"),
1069
+ JSON.stringify({ answers: {}, groups: {}, practice: missed }),
1070
+ );
1071
+ assert.equal(exportAt(dir).status, 0);
1072
+ setup("practice-two-rounds", config({ practice_round: 2 }));
1073
+ build(dir);
1074
+ const round2 = Object.fromEntries(
1075
+ PRACTICE.map((p, n) => [
1076
+ `r2-P${String(n + 1)}`,
1077
+ { answers: p.expected, source: "page" },
1078
+ ]),
1079
+ );
1080
+ writeFileSync(
1081
+ join(dir, "dump.json"),
1082
+ JSON.stringify({
1083
+ answers: {},
1084
+ groups: {},
1085
+ practice: { ...missed, ...round2 },
1086
+ }),
1087
+ );
1088
+ assert.equal(exportAt(dir).status, 0);
1089
+ const Round = z.object({ round: z.number(), matched: z.number() });
1090
+ const read = (r: number) =>
1091
+ Round.parse(
1092
+ JSON.parse(
1093
+ readFileSync(
1094
+ join(dir, "data", `demo-gold-practice-r${String(r)}-2026-01-02.json`),
1095
+ "utf8",
1096
+ ),
1097
+ ),
1098
+ );
1099
+ assert.deepEqual(read(1), { round: 1, matched: 2 });
1100
+ assert.deepEqual(read(2), { round: 2, matched: 3 });
1101
+ });
1102
+
1103
+ test("🔴 exported only after a retake, the earlier round's practice still gets its own file, scored against its own answers", () => {
1104
+ const dir = setup("practice-export-after-retake", config());
1105
+ build(dir);
1106
+ setup("practice-export-after-retake", config({ practice_round: 2 }));
1107
+ build(dir);
1108
+ const round1 = {
1109
+ ...DONE_PRACTICE,
1110
+ "r1-P2": { answers: { label: "yes", boundary: "clear" }, source: "page" },
1111
+ };
1112
+ const round2 = Object.fromEntries(
1113
+ PRACTICE.map((p, n) => [
1114
+ `r2-P${String(n + 1)}`,
1115
+ { answers: p.expected, source: "page" },
1116
+ ]),
1117
+ );
1118
+ writeFileSync(
1119
+ join(dir, "dump.json"),
1120
+ JSON.stringify({
1121
+ answers: {},
1122
+ groups: {},
1123
+ practice: { ...round1, ...round2 },
1124
+ }),
1125
+ );
1126
+ const r = exportAt(dir);
1127
+ assert.equal(r.status, 0, r.out);
1128
+ const Round = z.object({ round: z.number(), matched: z.number() });
1129
+ const read = (n: number) =>
1130
+ Round.parse(
1131
+ JSON.parse(
1132
+ readFileSync(
1133
+ join(dir, "data", `demo-gold-practice-r${String(n)}-2026-01-02.json`),
1134
+ "utf8",
1135
+ ),
1136
+ ),
1137
+ );
1138
+ assert.deepEqual(read(1), { round: 1, matched: 2 });
1139
+ assert.deepEqual(read(2), { round: 2, matched: 3 });
1140
+ assert.match(r.out, /practice round 1: 2 of 3 matched; missed P2/);
1141
+ });
1142
+
1143
+ test("🔴 a question dropped between practice rounds: the earlier round's answers are checked against that round's questions, not refused", () => {
1144
+ const dir = setup("practice-question-dropped", config());
1145
+ build(dir);
1146
+ const [first] = QUESTIONS;
1147
+ assert.ok(first !== undefined);
1148
+ const oneQuestion = PRACTICE.map((p) => ({
1149
+ ...p,
1150
+ expected: { label: p.expected.label },
1151
+ }));
1152
+ setup(
1153
+ "practice-question-dropped",
1154
+ config({ questions: [first], practice: oneQuestion, practice_round: 2 }),
1155
+ );
1156
+ build(dir);
1157
+ writeFileSync(
1158
+ join(dir, "dump.json"),
1159
+ JSON.stringify({ answers: {}, groups: {}, practice: DONE_PRACTICE }),
1160
+ );
1161
+ const r = exportAt(dir);
1162
+ assert.equal(r.status, 0, r.out);
1163
+ assert.match(r.out, /practice round 1: 3 of 3 matched/);
1164
+ });