paperlint 13.1.0 → 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 (104) 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/package.json +2 -1
  60. package/schemas/plan.v2.schema.json +341 -0
  61. package/skills/build-label-page/SKILL.md +85 -54
  62. package/skills/build-label-page/SKILL.md.spec.ts +85 -53
  63. package/skills/build-label-page/build-label-page.test.ts +10 -941
  64. package/skills/build-label-page/references/checklist.md +45 -3
  65. package/skills/build-label-page/references/closed-choice-generator.md +88 -0
  66. package/skills/build-label-page/scripts/label-page-cli.js +72 -0
  67. package/skills/build-label-page/scripts/label-page-cli.ts +85 -0
  68. package/skills/build-label-page/scripts/label-page-commands.js +14 -0
  69. package/skills/build-label-page/scripts/label-page-commands.ts +14 -0
  70. package/skills/build-label-page/scripts/label-page-core.js +850 -0
  71. package/skills/build-label-page/scripts/label-page-core.test.ts +1164 -0
  72. package/skills/build-label-page/scripts/label-page-core.ts +1210 -0
  73. package/skills/build-label-page/scripts/label-page-html.js +33 -0
  74. package/skills/build-label-page/scripts/label-page-html.test.ts +413 -0
  75. package/skills/build-label-page/scripts/label-page-html.ts +43 -0
  76. package/skills/build-label-page/scripts/label-page.js +70 -0
  77. package/skills/build-label-page/scripts/label-page.test.ts +71 -0
  78. package/skills/build-label-page/scripts/label-page.ts +71 -0
  79. package/skills/build-label-page/scripts/page-template.html +513 -0
  80. package/src/adapters/git/index.test.ts +33 -0
  81. package/src/adapters/git/index.ts +19 -0
  82. package/src/adapters/study-registry/gates.test.ts +181 -0
  83. package/src/adapters/study-registry/index.ts +219 -1
  84. package/src/adapters/study-registry/mode.test.ts +6 -0
  85. package/src/cli.ts +3 -0
  86. package/src/domain/decide-first.test.ts +131 -0
  87. package/src/domain/decide-first.ts +92 -2
  88. package/src/domain/study-gates.ts +125 -0
  89. package/src/domain/study-measurements.test.ts +71 -0
  90. package/src/domain/study-mode.ts +6 -0
  91. package/src/domain/study-registry.ts +64 -2
  92. package/src/domain/study-result.test.ts +300 -0
  93. package/src/domain/study-result.ts +358 -0
  94. package/src/outputs-before-decision-rule.test.ts +115 -2
  95. package/src/outputs-before-decision-rule.ts +50 -2
  96. package/src/ports/git-history.ts +12 -0
  97. package/src/ports/study-registry-codec.ts +3 -0
  98. package/src/rule-cases.test.ts +74 -0
  99. package/src/studies.test.ts +3 -0
  100. package/src/study-modes.ts +10 -1
  101. package/src/study-registry-rule.ts +1 -0
  102. package/src/study-result-rule.test.ts +281 -0
  103. package/src/study-result-rule.ts +123 -0
  104. package/skills/build-label-page/references/example-generator.py +0 -803
@@ -0,0 +1,850 @@
1
+ /**
2
+ * The CORE of build-label-page's generator, with no page layout: the items.json schema and its
3
+ * checks, the leak guard, neutral ids, the key with its `allowed` and `needs_why` lists, the check of
4
+ * the store's dump against the key, and the export (labels and practice). The page is built from its
5
+ * payload by `label-page-html.ts`; the command line is `label-page.ts`. No side effect on import.
6
+ */
7
+ import { createHash } from "node:crypto";
8
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
9
+ import { join } from "node:path";
10
+ import { z } from "zod";
11
+ // ── refusals and output ───────────────────────────────────────────────────────────────────────
12
+ export class Refusal extends Error {
13
+ exitCode;
14
+ constructor(exitCode, message) {
15
+ super(message);
16
+ this.exitCode = exitCode;
17
+ }
18
+ }
19
+ export const fail = (message) => {
20
+ throw new Refusal(1, `REFUSED: ${message}`);
21
+ };
22
+ /** What a run printed, line by line; `cli` takes it out after each run. */
23
+ const printed = [];
24
+ /** The lines printed since the last call, each ending in a newline. */
25
+ export const takePrinted = () => printed
26
+ .splice(0)
27
+ .map((l) => `${l}\n`)
28
+ .join("");
29
+ export const say = (line) => {
30
+ printed.push(line);
31
+ };
32
+ const isList = (v) => Array.isArray(v);
33
+ const isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
34
+ const has = (o, key) => Object.hasOwn(o, key);
35
+ // ── how a value is quoted in a message: as Python wrote it, so messages read as before ───────
36
+ const quoted = (s) => {
37
+ const q = s.includes("'") && !s.includes('"') ? '"' : "'";
38
+ const body = s
39
+ .replaceAll("\\", "\\\\")
40
+ .replaceAll("\n", "\\n")
41
+ .replaceAll("\t", "\\t")
42
+ .replaceAll("\r", "\\r");
43
+ return `${q}${q === "'" ? body.replaceAll("'", "\\'") : body}${q}`;
44
+ };
45
+ const repr = (v) => {
46
+ if (v === null || v === undefined)
47
+ return "None";
48
+ if (typeof v === "boolean")
49
+ return v ? "True" : "False";
50
+ if (typeof v === "string")
51
+ return quoted(v);
52
+ if (typeof v === "number")
53
+ return String(v);
54
+ if (Array.isArray(v))
55
+ return `[${v.map(repr).join(", ")}]`;
56
+ return `{${Object.entries(v)
57
+ .map(([k, x]) => `${quoted(k)}: ${repr(x)}`)
58
+ .join(", ")}}`;
59
+ };
60
+ /** The name Python gave the type of a value that is not an object, as the dump refusals print it. */
61
+ const typeName = (v) => {
62
+ if (v === null)
63
+ return "NoneType";
64
+ if (Array.isArray(v))
65
+ return "list";
66
+ if (typeof v === "number")
67
+ return Number.isInteger(v) ? "int" : "float";
68
+ return typeof v === "boolean" ? "bool" : "str";
69
+ };
70
+ /** Code-point order of two strings, as Python's `sorted` has it. */
71
+ const byText = (a, b) => Number(a > b) - Number(a < b);
72
+ const writeJson = (path, value) => {
73
+ writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`, "utf8");
74
+ };
75
+ const JSON_VALUE = z.json();
76
+ const readJson = (path) => {
77
+ try {
78
+ return JSON_VALUE.parse(JSON.parse(readFileSync(path, "utf8")));
79
+ }
80
+ catch (e) {
81
+ return fail(`cannot read ${path} as JSON: ${String(e)}`);
82
+ }
83
+ };
84
+ // ── THE LEAK GUARD ───────────────────────────────────────────────────────────────────────────
85
+ // What an item may carry. `PAGE_FIELDS` reach the labeller; `PRIVATE_FIELDS` go to key.json only.
86
+ // Anything else on an item — a model answer, a checker verdict, a source label, a difficulty tag —
87
+ // stops the build: a blind page fails by what leaked in, and a field nobody listed is exactly the
88
+ // one nobody thought of. A NEW FIELD IS ADDED HERE ON PURPOSE, after asking "does this tell the
89
+ // labeller what the answer is, or where the item came from?" — never to make a build pass.
90
+ const PAGE_FIELDS = ["group", "title", "body", "code", "context"];
91
+ const PRIVATE_FIELDS = ["source_id"];
92
+ const ALLOWED_FIELDS = [...PAGE_FIELDS, ...PRIVATE_FIELDS];
93
+ const FAMILIARITY = [
94
+ { value: "well", label: "Well" },
95
+ { value: "somewhat", label: "Somewhat" },
96
+ { value: "not_really", label: "Not really" },
97
+ ];
98
+ // An id is a key of a plain JavaScript object on the page, so a name Object.prototype already has
99
+ // is refused: `answers["__proto__"] = v` sets the prototype and saves nothing, and an unanswered
100
+ // `answers["constructor"]` reads back a function instead of nothing.
101
+ const RESERVED_NAMES = [
102
+ "__proto__",
103
+ "constructor",
104
+ "prototype",
105
+ "hasOwnProperty",
106
+ "isPrototypeOf",
107
+ "propertyIsEnumerable",
108
+ "toLocaleString",
109
+ "toString",
110
+ "valueOf",
111
+ "__defineGetter__",
112
+ "__defineSetter__",
113
+ "__lookupGetter__",
114
+ "__lookupSetter__",
115
+ ];
116
+ const NAME_PATTERN = `(?!(?:${RESERVED_NAMES.join("|")})$)[A-Za-z0-9_-]+`;
117
+ const SOURCES = ["page", "chat"];
118
+ const SOURCES_SHOWN = `(${SOURCES.map(quoted).join(", ")})`;
119
+ const string = (required = true, pattern) => pattern === undefined
120
+ ? { kind: "string", required }
121
+ : { kind: "string", required, pattern };
122
+ const integer = (required = true) => ({
123
+ kind: "integer",
124
+ required,
125
+ });
126
+ const array = (items, required = true, min = 0) => ({
127
+ kind: "array",
128
+ required,
129
+ min,
130
+ items,
131
+ });
132
+ const mapping = (values, keyPattern, min = 0) => ({
133
+ kind: "map",
134
+ required: true,
135
+ min,
136
+ key_pattern: keyPattern,
137
+ values,
138
+ });
139
+ const obj = (fields, closed = true) => ({ kind: "object", required: true, closed, fields });
140
+ const ITEM_SCHEMA = obj({
141
+ source_id: string(),
142
+ ...Object.fromEntries(PAGE_FIELDS.map((f) => [f, string(f === "group" || f === "body")])),
143
+ }, false);
144
+ // `icon` (an emoji) tells the buttons apart at a glance. No coloured side stripe on a button: it
145
+ // reads as decoration, not meaning (the build-label-page look test refuses one).
146
+ // `ask_why` (a prompt) makes the answer incomplete until the labeller says why: an approval page's "drop".
147
+ const OPTION_SCHEMA = obj({
148
+ value: string(),
149
+ label: string(),
150
+ example: string(),
151
+ icon: string(false),
152
+ ask_why: string(false),
153
+ });
154
+ const PRACTICE_SCHEMA = obj({
155
+ ...Object.fromEntries(PAGE_FIELDS.filter((f) => f !== "group").map((f) => [
156
+ f,
157
+ string(f === "body"),
158
+ ])),
159
+ expected: mapping(string(), NAME_PATTERN, 1),
160
+ why: string(),
161
+ });
162
+ const MIN_PRACTICE = 3;
163
+ const QUESTION_SCHEMA = obj({
164
+ id: string(true, NAME_PATTERN),
165
+ text: string(),
166
+ short: string(false),
167
+ options: array(OPTION_SCHEMA, true, 2),
168
+ });
169
+ export const CONFIG_SCHEMA = obj({
170
+ study: string(true, NAME_PATTERN), // becomes a file name
171
+ title: string(),
172
+ intro: string(false),
173
+ seed: integer(),
174
+ groups: mapping(string(), NAME_PATTERN, 1), // ids become store document ids
175
+ labeller_knows: array(string(true, NAME_PATTERN), false),
176
+ familiarity_question: string(false),
177
+ questions: array(QUESTION_SCHEMA, true, 1),
178
+ practice: array(PRACTICE_SCHEMA, true, MIN_PRACTICE),
179
+ practice_round: integer(false),
180
+ items: array(ITEM_SCHEMA, true, 1),
181
+ });
182
+ // ── checking a value against the schema ──────────────────────────────────────────────────────
183
+ const at = (path, name) => path ? `${path}.${name}` : name;
184
+ const fullMatch = (pattern, s) => new RegExp(`^(?:${pattern})$`, "u").test(s);
185
+ const ID_RULE = "letters, digits, - and _ only";
186
+ function checkString(value, node, where) {
187
+ if (typeof value !== "string" || !value.trim()) {
188
+ fail(`${where}: must be a non-empty string, got ${repr(value)}`);
189
+ }
190
+ else if (node.pattern !== undefined && !fullMatch(node.pattern, value)) {
191
+ fail(`${where}: ${repr(value)} must be ${ID_RULE}`);
192
+ }
193
+ }
194
+ const checkCount = (n, min, where) => {
195
+ if (n < min)
196
+ fail(`${where}: needs ${String(min)} or more entries, has ${String(n)}`);
197
+ };
198
+ function checkArray(value, node, path, where) {
199
+ if (!isList(value))
200
+ return fail(`${where}: must be a list, got ${repr(value)}`);
201
+ checkCount(value.length, node.min, where);
202
+ value.forEach((entry, n) => {
203
+ checkValue(entry, node.items, `${path}[${String(n)}]`);
204
+ });
205
+ }
206
+ function checkMap(value, node, path, where) {
207
+ if (!isObject(value))
208
+ return fail(`${where}: must be an object, got ${repr(value)}`);
209
+ checkCount(Object.keys(value).length, node.min, where);
210
+ for (const [key, entry] of Object.entries(value)) {
211
+ if (!fullMatch(node.key_pattern, key))
212
+ fail(`${at(path, key)}: key must be ${ID_RULE}`);
213
+ checkValue(entry, node.values, at(path, key));
214
+ }
215
+ }
216
+ function checkObject(value, node, path, where) {
217
+ if (!isObject(value))
218
+ return fail(`${where}: must be an object, got ${repr(value)}`);
219
+ for (const [name, sub] of Object.entries(node.fields)) {
220
+ if (has(value, name))
221
+ checkValue(value[name] ?? null, sub, at(path, name));
222
+ else if (sub.required)
223
+ fail(`${at(path, name)}: is required and missing`);
224
+ }
225
+ const unknown = Object.keys(value).find((name) => !has(node.fields, name));
226
+ if (node.closed && unknown !== undefined) {
227
+ fail(`${at(path, unknown)}: not a known field (known: ${Object.keys(node.fields).join(", ")})`);
228
+ }
229
+ }
230
+ /** Refuse the first place `value` departs from `node`; the message starts with the field's path. */
231
+ function checkValue(value, node, path) {
232
+ const where = path || "items.json";
233
+ switch (node.kind) {
234
+ case "string":
235
+ checkString(value, node, where);
236
+ break;
237
+ case "integer":
238
+ if (typeof value !== "number" || !Number.isInteger(value)) {
239
+ fail(`${where}: must be an integer, got ${repr(value)}`);
240
+ }
241
+ break;
242
+ case "array":
243
+ checkArray(value, node, path, where);
244
+ break;
245
+ case "map":
246
+ checkMap(value, node, path, where);
247
+ break;
248
+ case "object":
249
+ checkObject(value, node, path, where);
250
+ }
251
+ }
252
+ /** The config is typed by passing CONFIG_SCHEMA: `checkValue` refuses (throws) on any departure. */
253
+ function assertConfig(value) {
254
+ checkValue(value, CONFIG_SCHEMA, "");
255
+ }
256
+ /**
257
+ * THE definition of a valid answer: the values the build offers, per question and for familiarity.
258
+ * The page script (`DATA.allowed`) and the exporter (`key.json`) both read this one result, so a
259
+ * stored value counts as an answer on the page exactly when it counts in the export.
260
+ */
261
+ const allowedValues = (questions) => ({
262
+ questions: Object.fromEntries(questions.map((q) => [q.id, q.options.map((o) => o.value)])),
263
+ // The answers that need a reason, per question: the page and the export read the same list.
264
+ needs_why: Object.fromEntries(questions.map((q) => [
265
+ q.id,
266
+ q.options.filter((o) => o.ask_why !== undefined).map((o) => o.value),
267
+ ])),
268
+ familiarity: FAMILIARITY.map((o) => o.value),
269
+ });
270
+ /** Two values that read the same once case and surrounding space are gone. */
271
+ const readsAs = (s) => s.trim().toLowerCase();
272
+ /** The first entry whose reading repeats an earlier one, with the index of that earlier one. */
273
+ const firstRepeat = (labels) => {
274
+ const seen = labels.map(readsAs);
275
+ const at = seen.findIndex((x, i) => seen.indexOf(x) !== i);
276
+ return at === -1
277
+ ? undefined
278
+ : { at, earlier: seen.findIndex((x) => x === seen[at]) };
279
+ };
280
+ // What a schema cannot say: references between fields, and values that must differ.
281
+ function checkGroups(cfg) {
282
+ // The page heads each group with its label: two that read the same are two familiarity questions
283
+ // the labeller cannot tell apart, exported under different group ids.
284
+ const ids = Object.keys(cfg.groups);
285
+ const labels = Object.values(cfg.groups);
286
+ const twice = firstRepeat(labels);
287
+ if (twice !== undefined) {
288
+ fail(`groups.${String(ids[twice.at])}: ${repr(labels[twice.at])} reads the same as groups.${String(ids[twice.earlier])} — each group needs its own label`);
289
+ }
290
+ (cfg.labeller_knows ?? []).forEach((gid, n) => {
291
+ if (!has(cfg.groups, gid))
292
+ fail(`labeller_knows[${String(n)}]: ${repr(gid)} is not in \`groups\``);
293
+ });
294
+ }
295
+ function checkQuestion(q, qn, earlierIds) {
296
+ if (earlierIds.includes(q.id))
297
+ fail(`questions[${String(qn)}].id: ${repr(q.id)} is repeated`);
298
+ const values = q.options.map((o) => o.value);
299
+ if (new Set(values).size !== values.length) {
300
+ fail(`questions[${String(qn)}].options: values must be distinct, got ${repr(values)}`);
301
+ }
302
+ // Two buttons that read the same store different answers no one can tell apart afterwards.
303
+ const twice = firstRepeat(q.options.map((o) => o.label));
304
+ if (twice !== undefined) {
305
+ fail(`questions[${String(qn)}].options[${String(twice.at)}].label: ${repr(q.options[twice.at]?.label)} reads the same as options[${String(twice.earlier)}].label — each button needs its own label`);
306
+ }
307
+ }
308
+ /**
309
+ * Each practice item knows the answer to every question, and only offered answers: a practice the
310
+ * page cannot score would let a misread question through unseen.
311
+ */
312
+ function checkPractice(cfg) {
313
+ const offered = Object.fromEntries(cfg.questions.map((q) => [q.id, q.options.map((o) => o.value)]));
314
+ if ((cfg.practice_round ?? 1) < 1) {
315
+ fail(`practice_round: ${repr(cfg.practice_round)} — must be 1 or more`);
316
+ }
317
+ cfg.practice.forEach((item, n) => {
318
+ const where = `practice[${String(n)}].expected`;
319
+ for (const [q, v] of Object.entries(item.expected)) {
320
+ const values = offered[q];
321
+ if (values === undefined)
322
+ fail(`${where}.${q}: not a question of this page (${Object.keys(offered).join(", ")})`);
323
+ else if (!values.includes(v))
324
+ fail(`${where}.${q}: ${repr(v)} is not one of ${values.join(", ")}`);
325
+ }
326
+ const missing = Object.keys(offered).find((q) => !has(item.expected, q));
327
+ if (missing !== undefined)
328
+ fail(`${where}.${missing}: every question needs its expected answer`);
329
+ });
330
+ }
331
+ function checkConfig(cfg) {
332
+ checkGroups(cfg);
333
+ cfg.questions.forEach((q, qn) => {
334
+ checkQuestion(q, qn, cfg.questions.slice(0, qn).map((x) => x.id));
335
+ });
336
+ checkPractice(cfg);
337
+ }
338
+ /** The practice as the page shows it and the key keeps it: neutral ids P1, P2 … in the given order. */
339
+ const practiceOf = (cfg) => cfg.practice.map((item, n) => ({ id: `P${String(n + 1)}`, ...item }));
340
+ // ── the items: the leak guard, and the references to the config ───────────────────────────────
341
+ function checkItemFields(cfg) {
342
+ const sids = [];
343
+ cfg.items.forEach((item, n) => {
344
+ const extra = Object.keys(item)
345
+ .filter((f) => !ALLOWED_FIELDS.includes(f))
346
+ .sort();
347
+ if (extra.length) {
348
+ fail(`item #${String(n)} (${item.source_id}) carries ${extra.join(", ")}, which is not in the allow-list ${repr([...ALLOWED_FIELDS].sort())}. A blind page shows the labeller the item and nothing about the answer or the source. Remove the field from items.json; add it to PAGE_FIELDS only if it is safe for the labeller to see.`);
349
+ }
350
+ if (sids.includes(item.source_id))
351
+ fail(`items[${String(n)}].source_id: ${repr(item.source_id)} is repeated`);
352
+ sids.push(item.source_id);
353
+ if (!has(cfg.groups, item.group))
354
+ fail(`items[${String(n)}].group: ${repr(item.group)} is not in \`groups\``);
355
+ });
356
+ }
357
+ const escapeRegExp = (s) => s.replace(/[\\^$.*+?()[\]{}|/]/g, "\\$&");
358
+ /**
359
+ * A source id typed into a visible field gives the source away as well as a field would. Short ids
360
+ * ("7") would match ordinary text, so only ids of four characters or more are looked for.
361
+ */
362
+ function checkNoSourceShown(cfg) {
363
+ const long = cfg.items
364
+ .map((i) => i.source_id)
365
+ .filter((sid) => sid.length >= 4);
366
+ for (const item of cfg.items) {
367
+ for (const sid of long) {
368
+ const word = new RegExp(`(?<![\\p{L}\\p{N}_-])${escapeRegExp(sid)}(?![\\p{L}\\p{N}_-])`, "u");
369
+ const field = PAGE_FIELDS.find((f) => word.test(item[f] ?? ""));
370
+ if (field !== undefined) {
371
+ fail(`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).`);
372
+ }
373
+ }
374
+ }
375
+ }
376
+ /** key.json is this script's own output, written by `build`; its shape is trusted, not re-checked. */
377
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars -- an assertion signature, no body to run
378
+ function assertOwnKey(_value) { }
379
+ const readOldKey = (keyPath, seed) => {
380
+ if (!existsSync(keyPath))
381
+ return undefined;
382
+ const old = readJson(keyPath);
383
+ assertOwnKey(old);
384
+ if (old.seed !== seed) {
385
+ fail(`${keyPath} was made with seed ${String(old.seed)}; this config says ${String(seed)}. Answers are keyed by the old ids: restore the seed, or start a new study folder.`);
386
+ }
387
+ return old;
388
+ };
389
+ /** Neutral ids G001… in an order fixed by the seed, and kept when a key file already exists. */
390
+ function assignIds(items, seed, keyPath) {
391
+ const old = readOldKey(keyPath, seed);
392
+ const entries = old?.ids ?? {};
393
+ const known = new Map(Object.entries(entries).map(([nid, v]) => [v.source_id, nid]));
394
+ const rank = (s) => createHash("sha256")
395
+ .update(`${String(seed)}:${s}`)
396
+ .digest("hex");
397
+ const fresh = items
398
+ .map((i) => i.source_id)
399
+ .filter((sid) => !known.has(sid))
400
+ .sort((a, b) => byText(rank(a), rank(b)));
401
+ const ids = new Map([
402
+ ...known,
403
+ ...fresh.map((sid, k) => [
404
+ sid,
405
+ `G${String(known.size + k + 1).padStart(3, "0")}`,
406
+ ]),
407
+ ]);
408
+ return { ids, entries };
409
+ }
410
+ // ── a build, short of the page ──────────────────────────────────────────────────────────────────
411
+ const shownFields = (item) => Object.fromEntries(PAGE_FIELDS.flatMap((f) => {
412
+ const v = item[f];
413
+ return v === undefined ? [] : [[f, v]];
414
+ }));
415
+ /** Only what the labeller can answer goes on the page, and only that gets an id, in id order. */
416
+ function itemsOnPage(cfg, keyPath) {
417
+ const knows = cfg.labeller_knows;
418
+ const items = cfg.items.filter((i) => knows === undefined || knows.includes(i.group));
419
+ if (!items.length)
420
+ fail("no item is left after `labeller_knows`");
421
+ const { ids, entries } = assignIds(items, cfg.seed, keyPath);
422
+ // Every item on the page was given an id by `assignIds` above.
423
+ const idOf = (i) => String(ids.get(i.source_id));
424
+ const sorted = [...items].sort((a, b) => byText(idOf(a), idOf(b)));
425
+ return { items: sorted, ids, entries, idOf };
426
+ }
427
+ const practiceRound = (cfg) => cfg.practice_round ?? 1;
428
+ /** What a page shows: the questions, the practice and the items, blind, with their neutral ids. */
429
+ function payloadOf(cfg, page) {
430
+ const used = Object.keys(cfg.groups).filter((g) => page.items.some((i) => i.group === g));
431
+ return {
432
+ title: cfg.title,
433
+ intro: cfg.intro ?? "",
434
+ groups: used.map((g) => ({ id: g, label: String(cfg.groups[g]) })),
435
+ familiarity_question: cfg.familiarity_question ?? "How well do you know {group}?",
436
+ familiarity: FAMILIARITY,
437
+ allowed: allowedValues(cfg.questions),
438
+ questions: cfg.questions,
439
+ practice: { round: practiceRound(cfg), items: practiceOf(cfg) },
440
+ items: page.items.map((i) => ({ id: page.idOf(i), ...shownFields(i) })),
441
+ };
442
+ }
443
+ /** Every id the key keeps (on the page now, or kept from an earlier build), with its source and group. */
444
+ function idsOf(cfg, page) {
445
+ const shown = new Set(page.items.map(page.idOf));
446
+ const groupOf = new Map([
447
+ ...Object.values(page.entries).map((v) => [
448
+ v.source_id,
449
+ v.group,
450
+ ]),
451
+ ...cfg.items.map((i) => [i.source_id, i.group]),
452
+ ]);
453
+ const byId = [...page.ids].sort(([, a], [, b]) => byText(a, b));
454
+ return Object.fromEntries(byId.map(([sid, nid]) => [
455
+ nid,
456
+ {
457
+ source_id: sid,
458
+ group: String(groupOf.get(sid)),
459
+ on_page: shown.has(nid),
460
+ },
461
+ ]));
462
+ }
463
+ /** Every round of a key's practice; a key from before `rounds` has only its own round. */
464
+ const roundsIn = (p) => p.rounds ?? { [String(p.round)]: p.expected };
465
+ /** The rounds an existing key holds, kept by the next build. */
466
+ const roundsOf = (keyPath) => {
467
+ if (!existsSync(keyPath))
468
+ return {};
469
+ const old = readJson(keyPath);
470
+ assertKey(old);
471
+ return old.practice === undefined ? {} : roundsIn(old.practice);
472
+ };
473
+ function keyOf(cfg, page, earlier) {
474
+ const practice = practiceOf(cfg);
475
+ return {
476
+ study: cfg.study,
477
+ seed: cfg.seed,
478
+ questions: cfg.questions.map((q) => q.id),
479
+ // What counts as an answer: the same lists the page was given (`allowedValues`). `export`
480
+ // checks every stored answer and familiarity against them, so the build's options decide.
481
+ allowed: allowedValues(cfg.questions),
482
+ // The practice is scored against this at export; its answers never go into the labels.
483
+ practice: {
484
+ round: practiceRound(cfg),
485
+ expected: Object.fromEntries(practice.map((p) => [p.id, p.expected])),
486
+ // Every round's items and expected answers, kept across rebuilds: an earlier round's stored
487
+ // answers stay known to the export (a later round may have fewer items) and are scored too.
488
+ rounds: {
489
+ ...earlier,
490
+ [String(practiceRound(cfg))]: Object.fromEntries(practice.map((p) => [p.id, p.expected])),
491
+ },
492
+ },
493
+ ids: idsOf(cfg, page),
494
+ };
495
+ }
496
+ /**
497
+ * Everything a build does but lay out the page: reads and checks items.json (the schema, the
498
+ * cross-field checks, the leak guard), gives the items their ids, writes key.json beside the page and
499
+ * returns what the page shows. A page written by hand can take its blindness and its key from here.
500
+ */
501
+ export function prepareBuild(configPath, outDir) {
502
+ const cfg = readJson(configPath);
503
+ assertConfig(cfg);
504
+ checkConfig(cfg);
505
+ checkItemFields(cfg);
506
+ checkNoSourceShown(cfg);
507
+ mkdirSync(outDir, { recursive: true });
508
+ const keyPath = join(outDir, "key.json");
509
+ const earlier = roundsOf(keyPath);
510
+ const page = itemsOnPage(cfg, keyPath);
511
+ writeJson(keyPath, keyOf(cfg, page, earlier));
512
+ return {
513
+ payload: payloadOf(cfg, page),
514
+ dropped: cfg.items.length - page.items.length,
515
+ };
516
+ }
517
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars -- an assertion signature, no body to run
518
+ function assertKey(_value) {
519
+ // key.json is this script's own output; what an older build left out is checked where it is read.
520
+ }
521
+ const ANSWER_DOC_FIELDS = ["answers", "reasons", "savedAt", "source"];
522
+ const GROUP_DOC_FIELDS = ["familiarity", "savedAt", "source"];
523
+ const DOC_FIELD_TYPES = [
524
+ ["answers", isObject, "an object {question: value}"],
525
+ ["reasons", isObject, "an object {question: text}"],
526
+ [
527
+ "familiarity",
528
+ (v) => v === null || typeof v === "string",
529
+ "a familiarity value (a string) or null",
530
+ ],
531
+ [
532
+ "savedAt",
533
+ (v) => v === null || typeof v === "string",
534
+ "a timestamp string or null",
535
+ ],
536
+ ];
537
+ const COLLECTIONS = ["answers", "groups", "practice"];
538
+ const COLLECTION_NAMES = COLLECTIONS;
539
+ const NOUN = {
540
+ answers: "answer",
541
+ groups: "group",
542
+ practice: "practice item",
543
+ };
544
+ /**
545
+ * The store documents a practice of this key may have: every round up to the key's, so the answers
546
+ * of an earlier round are known (and kept), not refused.
547
+ */
548
+ const practiceDocs = (key) => {
549
+ const p = key.practice;
550
+ if (p === undefined)
551
+ return [];
552
+ // Each round's own questions: a later round may have dropped or renamed one.
553
+ return Object.entries(roundsIn(p)).flatMap(([r, expected]) => Object.entries(expected).map(([pid, answers]) => [`r${r}-${pid}`, Object.keys(answers)]));
554
+ };
555
+ function knownNames(key) {
556
+ const entries = Object.entries(key.ids);
557
+ const shown = entries.filter(([, m]) => m.on_page);
558
+ const practice = practiceDocs(key);
559
+ return {
560
+ answers: {
561
+ known: entries.map(([nid]) => [nid, key.questions]),
562
+ onPage: shown.map(([nid]) => nid),
563
+ fields: ANSWER_DOC_FIELDS,
564
+ },
565
+ groups: {
566
+ known: entries.map(([, m]) => [m.group, key.questions]),
567
+ onPage: shown.map(([, m]) => m.group),
568
+ fields: GROUP_DOC_FIELDS,
569
+ },
570
+ practice: {
571
+ known: practice,
572
+ onPage: practice.map(([n]) => n),
573
+ fields: ANSWER_DOC_FIELDS,
574
+ },
575
+ };
576
+ }
577
+ const sortedEntries = (o) => Object.entries(o).sort(([a], [b]) => byText(a, b));
578
+ function checkDumpShape(dump) {
579
+ if (!isObject(dump))
580
+ return fail(`store: ${typeName(dump)} — must be an object {answers, groups, practice}`);
581
+ for (const kind of COLLECTIONS) {
582
+ const c = dump[kind];
583
+ if (c !== undefined && !isObject(c))
584
+ fail(`${kind}: ${typeName(c)} — must be an object {id: stored document}`);
585
+ }
586
+ const stray = Object.keys(dump)
587
+ .sort()
588
+ .find((top) => !COLLECTION_NAMES.includes(top));
589
+ if (stray !== undefined)
590
+ fail(`${stray}: the store has no such collection (expected ${COLLECTIONS.join(", ")})`);
591
+ }
592
+ function checkDocName(where, name, kind, names) {
593
+ const hit = names.known.find(([n]) => n === name);
594
+ if (hit === undefined) {
595
+ return fail(`${where}: the key does not know this ${NOUN[kind]} (${[...new Set(names.known.map(([n]) => n))].sort().join(", ")})`);
596
+ }
597
+ if (!names.onPage.includes(name)) {
598
+ fail(`${where}: stored, but no longer on the page (a rebuild left it out), so the export would drop it; rebuild with it on the page, or take it out of the dump on purpose`);
599
+ }
600
+ return hit[1];
601
+ }
602
+ function checkDocFields(where, doc, kind, fields) {
603
+ if (!isObject(doc))
604
+ return fail(`${where}: must be an object with ${fields.join(", ")}`);
605
+ const stray = Object.keys(doc)
606
+ .sort()
607
+ .find((f) => !fields.includes(f));
608
+ if (stray !== undefined)
609
+ fail(`${where}/${stray}: not a field of a stored ${NOUN[kind]} (${fields.join(", ")})`);
610
+ if (!has(doc, "source"))
611
+ fail(`${where}: no source; add "source": "chat" or "page" (one of ${SOURCES_SHOWN})`);
612
+ const source = doc["source"];
613
+ if (typeof source !== "string" || !SOURCES.includes(source)) {
614
+ fail(`${where}/source: ${repr(source)}; expected one of ${SOURCES_SHOWN}`);
615
+ }
616
+ // The types the export reads, so a hand merge is refused by path rather than crashing it.
617
+ for (const [field, ok, what] of DOC_FIELD_TYPES) {
618
+ const v = doc[field];
619
+ if (v !== undefined && !ok(v))
620
+ fail(`${where}/${field}: ${repr(v)} — must be ${what}`);
621
+ }
622
+ }
623
+ function checkDocAnswers(where, doc, questions) {
624
+ const known = (part, q) => {
625
+ if (!questions.includes(q))
626
+ fail(`${where}/${part}/${q}: not a question of the key (${questions.join(", ")})`);
627
+ };
628
+ for (const [q, v] of sortedEntries(isObject(doc["answers"]) ? doc["answers"] : {})) {
629
+ known("answers", q);
630
+ if (v !== null && typeof v !== "string")
631
+ fail(`${where}/answers/${q}: ${repr(v)} — must be an option value (a string) or null`);
632
+ }
633
+ for (const [q, v] of sortedEntries(isObject(doc["reasons"]) ? doc["reasons"] : {})) {
634
+ known("reasons", q);
635
+ if (typeof v !== "string")
636
+ fail(`${where}/reasons/${q}: ${repr(v)} — must be text`);
637
+ }
638
+ }
639
+ /**
640
+ * The whole store against the key, ONCE, before anything is read from it. Every name in it must be
641
+ * one the key knows: an item id, a group, a question id, a field of the document; an item or group
642
+ * must also still be on the page, or a rebuild that left it out would drop its stored answer. A
643
+ * name the export does not read would otherwise be dropped without a word — a misspelled question
644
+ * key in a chat merge (`boundry`), a misspelled group (`pyhton`) — and its answer lost from the
645
+ * labels. Refuses, naming the path; the fix is one edit in the dump. Every document also says where
646
+ * it came from: the page always writes `source: "page"`, so a document without one was merged by hand.
647
+ */
648
+ function checkDump(dump, key) {
649
+ checkDumpShape(dump);
650
+ const names = knownNames(key);
651
+ for (const kind of COLLECTIONS) {
652
+ const collection = dump[kind];
653
+ for (const [name, doc] of sortedEntries(isObject(collection) ? collection : {})) {
654
+ const where = `${kind}/${name}`;
655
+ const questions = checkDocName(where, name, kind, names[kind]);
656
+ checkDocFields(where, doc, kind, names[kind].fields);
657
+ checkDocAnswers(where, doc, questions);
658
+ }
659
+ }
660
+ }
661
+ /** The key's `allowed` lists, or a refusal naming a key made by an older build. */
662
+ function assertAllowed(a, key, keyPath) {
663
+ const questions = isObject(a) ? a["questions"] : undefined;
664
+ const complete = isObject(a) &&
665
+ has(a, "familiarity") &&
666
+ isObject(questions) &&
667
+ key.questions.every((q) => has(questions, q));
668
+ if (!complete) {
669
+ fail(`${keyPath} has no \`allowed\` values for every question and for familiarity (made by an older build). Run \`build\` again with the same items.json and seed: ids are kept, the key gains them.`);
670
+ }
671
+ }
672
+ const docOf = (collection, name) => {
673
+ const doc = isObject(collection) ? collection[name] : undefined;
674
+ return isObject(doc) ? doc : {};
675
+ };
676
+ /** The values a question offers (or needs a reason for); the key has a list for every question. */
677
+ const valuesOf = (lists, q) => Object.entries(lists)
678
+ .filter(([k]) => k === q)
679
+ .flatMap(([, vs]) => vs);
680
+ const listed = (lists, q, v) => valuesOf(lists, q).includes(v);
681
+ const textOf = (v) => typeof v === "string" ? v : null;
682
+ /** The text values of a stored object (answers, reasons): a null answer is no text. */
683
+ const textsOf = (o) => Object.fromEntries(Object.entries(isObject(o) ? o : {}).filter((e) => typeof e[1] === "string"));
684
+ /**
685
+ * An answer outside the question's allowed values (a typo in a chat answer, an option that was
686
+ * removed or renamed between builds) is MARKED, not refused and not dropped: the item is
687
+ * incomplete, the raw value is kept under `invalid_answers`, and the report names the item.
688
+ * Refusing would hold every good label hostage to one typo at the moment the export must happen
689
+ * (checklist rule 6); dropping it would lose what the labeller said.
690
+ */
691
+ function answersOf(doc, ctx) {
692
+ const stored = textsOf(doc["answers"]);
693
+ const raw = ctx.key.questions.map((q) => [
694
+ q,
695
+ stored[q] ?? null,
696
+ ]);
697
+ const invalid = Object.fromEntries(raw.filter(([q, v]) => v !== null && !listed(ctx.allowed.questions, q, v)));
698
+ const given = Object.fromEntries(raw.map(([q, v]) => [q, has(invalid, q) ? null : v]));
699
+ return { given, invalid };
700
+ }
701
+ /** One item of the labels file: its answers, what is invalid, the reasons, and whether it is done. */
702
+ function labelOf(nid, meta, doc, ctx) {
703
+ const { given, invalid } = answersOf(doc, ctx);
704
+ const reasons = Object.fromEntries(Object.entries(textsOf(doc["reasons"])).filter(([, r]) => r.trim()));
705
+ const needsWhy = ctx.allowed.needs_why ?? {};
706
+ const unexplained = Object.entries(given)
707
+ .filter(([q, v]) => v !== null && listed(needsWhy, q, v) && !has(reasons, q))
708
+ .map(([q]) => q);
709
+ return {
710
+ id: nid,
711
+ source_id: meta.source_id,
712
+ group: meta.group,
713
+ answers: given,
714
+ invalid_answers: invalid,
715
+ reasons,
716
+ unexplained,
717
+ complete: Object.values(given).every((v) => v !== null) && !unexplained.length,
718
+ source: textOf(doc["source"]),
719
+ saved_at: doc["savedAt"] ?? null,
720
+ };
721
+ }
722
+ /** Same rule as for item answers: a familiarity outside the options is marked, not counted as rated. */
723
+ function groupsOf(groupIds, groups, allowed) {
724
+ return Object.fromEntries(groupIds.map((g) => {
725
+ const doc = docOf(groups, g);
726
+ const fam = textOf(doc["familiarity"]);
727
+ const bad = fam !== null && !allowed.familiarity.includes(fam);
728
+ return [
729
+ g,
730
+ {
731
+ familiarity: bad ? null : fam,
732
+ invalid_familiarity: bad ? fam : null,
733
+ source: textOf(doc["source"]),
734
+ saved_at: doc["savedAt"] ?? null,
735
+ },
736
+ ];
737
+ }));
738
+ }
739
+ function reportLabels(items, allowed) {
740
+ for (const i of items) {
741
+ for (const [q, v] of Object.entries(i.invalid_answers)) {
742
+ say(` invalid ${i.id}: ${q}=${repr(v)} is not one of ${valuesOf(allowed.questions, q).join(", ")} (not counted)`);
743
+ }
744
+ const missing = Object.entries(i.answers)
745
+ .filter(([q, v]) => v === null && !has(i.invalid_answers, q))
746
+ .map(([q]) => q);
747
+ if (missing.length)
748
+ say(` incomplete ${i.id}: missing ${missing.join(", ")}`);
749
+ for (const q of i.unexplained)
750
+ say(` incomplete ${i.id}: ${q}=${String(i.answers[q])} needs a reason`);
751
+ }
752
+ }
753
+ function reportGroups(groups, allowed) {
754
+ for (const [g, v] of Object.entries(groups)) {
755
+ if (v.invalid_familiarity !== null) {
756
+ say(` invalid group ${g}: familiarity=${repr(v.invalid_familiarity)} is not one of ${allowed.familiarity.join(", ")} (not counted as rated)`);
757
+ }
758
+ else if (v.familiarity === null)
759
+ say(` group ${g}: no familiarity answer`);
760
+ }
761
+ }
762
+ /** Each practice item of the key's round: what was answered first, what was expected, whether they match. */
763
+ function scorePractice(dump, round, all) {
764
+ return Object.entries(all).map(([pid, expected]) => {
765
+ const doc = docOf(dump["practice"], `r${round}-${pid}`);
766
+ const answers = isObject(doc["answers"]) ? doc["answers"] : {};
767
+ const matched = Object.entries(expected).every(([q, v]) => answers[q] === v);
768
+ return {
769
+ id: pid,
770
+ answers,
771
+ expected,
772
+ matched,
773
+ savedAt: doc["savedAt"] ?? null,
774
+ };
775
+ });
776
+ }
777
+ /** One file per round: a second round exported the same day keeps the first. */
778
+ const practiceFile = (study, round, day) => `${study}-practice-r${String(round)}-${day}.json`;
779
+ /**
780
+ * Every practice round the store holds answers for, each scored against its own expected answers
781
+ * into its own file; never into the labels. The key's own round is reported even unanswered.
782
+ */
783
+ function exportPractice(dump, key, target, day) {
784
+ const p = key.practice;
785
+ if (p === undefined)
786
+ return;
787
+ Object.entries(roundsIn(p))
788
+ .sort(([a], [b]) => Number(a) - Number(b))
789
+ .forEach(([round, expected]) => {
790
+ exportRound(key.study, Number(round), scorePractice(dump, round, expected), {
791
+ target,
792
+ day,
793
+ current: Number(round) === p.round,
794
+ });
795
+ });
796
+ }
797
+ /** One round's scored practice to `<study>-practice-r<round>-<date>.json`, and one line about it. */
798
+ function exportRound(study, round, items, at) {
799
+ if (!items.some((i) => Object.keys(i.answers).length)) {
800
+ if (at.current)
801
+ say(`practice round ${String(round)}: not answered`);
802
+ return;
803
+ }
804
+ const missed = items.filter((i) => !i.matched).map((i) => i.id);
805
+ const matched = items.length - missed.length;
806
+ const path = join(at.target, practiceFile(study, round, at.day));
807
+ writeJson(path, {
808
+ study,
809
+ round,
810
+ exported: at.day,
811
+ matched,
812
+ total: items.length,
813
+ items,
814
+ });
815
+ say(`practice round ${String(round)}: ${String(matched)} of ${String(items.length)} matched${missed.length ? `; missed ${missed.join(", ")}` : ""} -> ${path}`);
816
+ }
817
+ /** The labels file: every item on the page with its answers, the groups, and the counts. */
818
+ function labelsOf(dump, ctx, day) {
819
+ const onPage = Object.entries(ctx.key.ids).filter(([, m]) => m.on_page);
820
+ const items = onPage.map(([nid, meta]) => labelOf(nid, meta, docOf(dump["answers"], nid), ctx));
821
+ const groups = groupsOf([...new Set(onPage.map(([, m]) => m.group))], dump["groups"], ctx.allowed);
822
+ return {
823
+ study: ctx.key.study,
824
+ exported: day,
825
+ questions: ctx.key.questions,
826
+ items,
827
+ groups,
828
+ complete_items: items.filter((i) => i.complete).length,
829
+ invalid_items: items.filter((i) => Object.keys(i.invalid_answers).length)
830
+ .length,
831
+ invalid_groups: Object.values(groups).filter((g) => g.invalid_familiarity !== null).length,
832
+ total_items: items.length,
833
+ };
834
+ }
835
+ export function exportLabels(dumpPath, keyPath, outDir, day) {
836
+ const dump = readJson(dumpPath);
837
+ const key = readJson(keyPath);
838
+ assertKey(key);
839
+ const allowed = key.allowed;
840
+ assertAllowed(allowed, key, keyPath);
841
+ checkDump(dump, key);
842
+ const out = labelsOf(dump, { key, allowed }, day);
843
+ mkdirSync(outDir, { recursive: true });
844
+ const path = join(outDir, `${key.study}-labels-${day}.json`);
845
+ writeJson(path, out);
846
+ exportPractice(dump, key, outDir, day);
847
+ say(`wrote ${path}: ${String(out.complete_items)} of ${String(out.total_items)} items complete`);
848
+ reportLabels(out.items, allowed);
849
+ reportGroups(out.groups, allowed);
850
+ }