paperlint 13.3.2 → 13.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -12,6 +12,7 @@ type Json = null | boolean | number | string | readonly Json[] | {
12
12
  type JsonObject = {
13
13
  readonly [key: string]: Json;
14
14
  };
15
+ declare const PAGE_FIELDS: readonly ["group", "title", "body", "code", "context"];
15
16
  type SchemaNode = {
16
17
  kind: "string";
17
18
  required: boolean;
@@ -19,6 +20,9 @@ type SchemaNode = {
19
20
  } | {
20
21
  kind: "integer";
21
22
  required: boolean;
23
+ } | {
24
+ kind: "boolean";
25
+ required: boolean;
22
26
  } | {
23
27
  kind: "array";
24
28
  required: boolean;
@@ -50,9 +54,19 @@ interface Question {
50
54
  readonly short?: string;
51
55
  readonly options: readonly Option[];
52
56
  }
53
- type Item = Readonly<Record<string, string>> & {
57
+ interface Unit {
58
+ readonly id: string;
59
+ readonly text: string;
60
+ readonly part: number;
61
+ readonly of: number;
62
+ }
63
+ type Item = {
64
+ readonly [F in (typeof PAGE_FIELDS)[number]]?: string;
65
+ } & {
54
66
  readonly source_id: string;
55
67
  readonly group: string;
68
+ readonly body: string;
69
+ readonly unit?: Unit;
56
70
  };
57
71
  interface Practice {
58
72
  readonly title?: string;
@@ -69,6 +83,7 @@ interface Config {
69
83
  readonly seed: number;
70
84
  readonly groups: Readonly<Record<string, string>>;
71
85
  readonly labeller_knows?: readonly string[];
86
+ readonly familiarity?: boolean;
72
87
  readonly familiarity_question?: string;
73
88
  readonly questions: readonly Question[];
74
89
  readonly practice: readonly Practice[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "paperlint",
3
- "version": "13.3.2",
3
+ "version": "13.5.0",
4
4
  "description": "A linter for scientific papers written in LaTeX: catches mistakes before you submit to a conference or journal.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -26,42 +26,11 @@ import assert from "node:assert/strict";
26
26
  const here = dirname(fileURLToPath(import.meta.url));
27
27
  const TOOL = join(here, "candidate_page.ts");
28
28
  const FIXTURE = join(here, "fixtures", "novelty");
29
- // playwright-core is what package.json declares; the browser is whichever chromiumPath() finds (CHROMIUM_PATH, a
30
- // build in PLAYWRIGHT_BROWSERS_PATH, or the Chrome a GitHub runner ships), so nothing is downloaded.
31
29
  import { chromium } from "playwright-core";
32
- import { existsSync, readdirSync } from "node:fs";
30
+ import { chromiumPath } from "../../../test/chromium.ts";
33
31
  import { test } from "vitest";
34
32
 
35
33
  test("candidate_page", async () => {
36
- const BROWSERS_DIR =
37
- process.env.PLAYWRIGHT_BROWSERS_PATH || "/opt/pw-browsers";
38
- const SYSTEM_CHROME = [
39
- "/usr/bin/google-chrome",
40
- "/usr/bin/google-chrome-stable",
41
- "/usr/bin/chromium",
42
- "/usr/bin/chromium-browser",
43
- "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", // the macOS runner's Chrome
44
- ];
45
- const chromiumPath = (): string => {
46
- const builds = existsSync(BROWSERS_DIR)
47
- ? readdirSync(BROWSERS_DIR)
48
- .filter((d) => /^chromium-\d+$/.test(d))
49
- .sort((a, b) => Number(b.split("-")[1]) - Number(a.split("-")[1]))
50
- .flatMap((d) =>
51
- ["chrome-linux64", "chrome-linux"].map((sub) =>
52
- join(BROWSERS_DIR, d, sub, "chrome"),
53
- ),
54
- )
55
- : [];
56
- const found = [process.env.CHROMIUM_PATH, ...builds, ...SYSTEM_CHROME].find(
57
- (p) => p && existsSync(p),
58
- );
59
- if (!found)
60
- throw new Error(
61
- `no Chromium: set CHROMIUM_PATH, or install one in ${BROWSERS_DIR} or ${SYSTEM_CHROME[0]}`,
62
- );
63
- return found;
64
- };
65
34
  // A 1x1 PNG, enough for an <img> to load.
66
35
  const PNG = Buffer.from(
67
36
  "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
@@ -4,7 +4,7 @@ description: Build a phone labelling page published as a claude.ai Artifact with
4
4
  allowed-tools: [Read, Write, Edit, Grep, Glob, Bash, Artifact, ArtifactData]
5
5
  ---
6
6
 
7
- <!-- vigiles:sha256:6323fa135f385e8b compiled from skills/build-label-page/SKILL.md.spec.ts -->
7
+ <!-- vigiles:sha256:bd616b2694831fa1 compiled from skills/build-label-page/SKILL.md.spec.ts -->
8
8
 
9
9
  # build-label-page — a page the person labels on a phone, and the labels back in the repo
10
10
 
@@ -63,7 +63,8 @@ building. In one line each:
63
63
  source labels on the page. The generator refuses to build when an item carries a field outside an
64
64
  allow-list; a page written by hand checks its items against the same list.
65
65
  5. **Ask only what the labeller can answer.** Only the languages and tools they know; one "how well do
66
- you know it" question per group, used later as an analysis filter; "clear-cut or real boundary"
66
+ you know it" question per group, used later as an analysis filter (off on an approval page,
67
+ `"familiarity": false`: the author has not seen those repositories); "clear-cut or real boundary"
67
68
  is the labeller's judgement, not the designer's.
68
69
  6. **Export into the repository right away**, one JSON per page into the study's `data/` with the
69
70
  export date, commit it, and record answers given in chat with `source: "chat"`. If the page fails,
@@ -74,7 +74,8 @@ building. In one line each:
74
74
  source labels on the page. The generator refuses to build when an item carries a field outside an
75
75
  allow-list; a page written by hand checks its items against the same list.
76
76
  5. **Ask only what the labeller can answer.** Only the languages and tools they know; one "how well do
77
- you know it" question per group, used later as an analysis filter; "clear-cut or real boundary"
77
+ you know it" question per group, used later as an analysis filter (off on an approval page,
78
+ \`"familiarity": false\`: the author has not seen those repositories); "clear-cut or real boundary"
78
79
  is the labeller's judgement, not the designer's.
79
80
  6. **Export into the repository right away**, one JSON per page into the study's \`data/\` with the
80
81
  export date, commit it, and record answers given in chat with \`source: "chat"\`. If the page fails,
@@ -83,7 +83,9 @@ the text of an allowed field says.
83
83
  other groups are not put on the page and get no id.
84
84
  - Add one question per group, "how well do you know this language / tool" (well / somewhat / not
85
85
  really). It is an analysis filter: the study can report the labels with and without the groups
86
- the labeller does not know well.
86
+ the labeller does not know well. An approval page asks none (`"familiarity": false` in
87
+ `items.json`): its question is about a drawn unit, not about repositories the author has seen,
88
+ and an answer to "how well do you know the projects of this group" there means nothing.
87
89
  - The per-item judgement "clear-cut or a real boundary case" is the labeller's. The study design
88
90
  does not decide it in advance and does not pre-fill it.
89
91
 
@@ -92,7 +94,7 @@ answer is the only way to find it afterwards. And a boundary case is boundary be
92
94
  person hesitates; a designer who tags it in advance has measured the design, not the item.
93
95
 
94
96
  **Check.** The build prints how many items were left out; the export has a familiarity value for
95
- every group.
97
+ every group, or the page was built with `"familiarity": false` and the export has no groups.
96
98
 
97
99
  ## 6. Export into the repository right away
98
100
 
@@ -37,12 +37,22 @@ length.
37
37
  - **`practice_round`**: raise it after rewording a question; the practice reopens and the answers of
38
38
  the earlier round are kept but not counted.
39
39
  - **`items`**: `source_id` (the study's own, never shown), `group`, `body`, and optionally `title`,
40
- `code`, `context`. Nothing else: the allow-list is fixed in the generator, and a field outside it
40
+ `code`, `context`, `unit`. Nothing else: the allow-list is fixed in the generator, and a field outside it
41
41
  is taken out of the item, not added to the list to make a build pass.
42
+ - **`unit`**: `{id, text, part, of}` on an item that is one requirement of a line holding several
43
+ ("TypeScript only (no JavaScript); top-level await is allowed" is two). Each requirement is its
44
+ own item with its own question, and its card shows "From the line (requirement 1 of 2):" and the
45
+ whole line beneath, so the labeller judges one requirement at a time and sees where it came from.
46
+ The build refuses a line whose parts do not add up (one missing, repeated, a different `of`, or
47
+ split across groups), naming the item. `id` names one occurrence of the line (a file and line
48
+ number, say) and is never shown: two files holding the same words are two lines, each judged in
49
+ full. Splitting the line is the study's job, before the page.
42
50
  - **`seed`**: fixes the order. Rebuilding with the same seed keeps every id, and a new item gets the
43
51
  next one; a different seed is refused once a key exists.
44
52
  - **`ask_why`** on an option: a prompt. That answer is incomplete, on the page and in the export,
45
- until a reason is saved.
53
+ until a reason is saved. The reason saves as it is typed (after a pause, and when the page is
54
+ left), so a labeller who closes the page from inside the box does not leave a drop without its
55
+ reason; a store echo while typing keeps the box and its focus.
46
56
 
47
57
  ## The approval page
48
58
 
@@ -73,8 +83,15 @@ drop, the drop carrying `ask_why`.
73
83
  }
74
84
  ```
75
85
 
76
- The items are the drawn units verbatim. The practice is three units whose fate is obvious. Each
77
- dropped unit carries its reason in the labels file. The author's words, with the date, then go into
86
+ The page sets `"familiarity": false`: the author is judging drawn units, not repositories they
87
+ have seen, so the per-group "how well do you know it" question is left out (and a
88
+ `familiarity_question` beside it is refused).
89
+
90
+ The items are the drawn units verbatim, in a language the author reads. Units in other languages
91
+ are taken out before the draw, as a population rule the study's `plan.json` states ("English-language
92
+ units only") with the number excluded reported in the paper; a unit dropped at approval because the
93
+ author cannot read it is an exclusion made after the draw and goes unrecorded. The practice is three
94
+ units whose fate is obvious. Each dropped unit carries its reason in the labels file. The author's words, with the date, then go into
78
95
  `author_check` of the plan; a dropped unit is replaced by the next one in the draw.
79
96
 
80
97
  ## The export
@@ -0,0 +1,187 @@
1
+ /**
2
+ * The generator's page in a real Chromium, against a stand-in store whose writes come back as a
3
+ * snapshot a moment later (as the claude.ai store does). What the fake DOM of label-page-html.test.ts
4
+ * cannot show: focus, typing, and a page that is left without the box ever losing focus.
5
+ */
6
+ import assert from "node:assert/strict";
7
+ import { chromium, type Page } from "playwright-core";
8
+ import { test } from "vitest";
9
+ import { z } from "zod";
10
+ import { chromiumPath } from "../../../test/chromium.ts";
11
+ import {
12
+ APPROVAL_PRACTICE,
13
+ approval,
14
+ build,
15
+ setup,
16
+ } from "../../../test/label-page-kit.ts";
17
+
18
+ /** A store that keeps every write and echoes it back as a snapshot after `echoMs`. */
19
+ const STORE = (practice: Record<string, unknown>, echoMs: number): string => `
20
+ window.__writes = [];
21
+ (function () {
22
+ var docs = { answers: {}, groups: {}, practice: ${JSON.stringify(practice)} };
23
+ var subs = {};
24
+ function snap(n) {
25
+ (subs[n] || []).forEach(function (cb) {
26
+ cb({ docs: Object.keys(docs[n]).map(function (id) { return { id: id, data: function () { return docs[n][id]; } }; }) });
27
+ });
28
+ }
29
+ var db = { collection: function (n) { return {
30
+ onSnapshot: function (cb) { (subs[n] = subs[n] || []).push(cb); snap(n); },
31
+ doc: function (id) { return { set: function (body) {
32
+ window.__writes.push(body);
33
+ docs[n][id] = body;
34
+ return new Promise(function (r) { setTimeout(function () { r(); snap(n); }, ${String(echoMs)}); });
35
+ } }; }
36
+ }; } };
37
+ window.claude = { use: function () { return Promise.resolve(db); } };
38
+ })();`;
39
+
40
+ const PRACTICE_DONE = Object.fromEntries(
41
+ APPROVAL_PRACTICE.map((p, n) => [
42
+ `r1-P${String(n + 1)}`,
43
+ { answers: p.expected, source: "page" },
44
+ ]),
45
+ );
46
+
47
+ /** Every document the page wrote, in order, read back as JSON (the stand-in store keeps them). */
48
+ const Writes = z.array(
49
+ z.object({ reasons: z.record(z.string(), z.string()) }).loose(),
50
+ );
51
+ const writes = async (page: Page) =>
52
+ Writes.parse(
53
+ JSON.parse(await page.evaluate("JSON.stringify(window.__writes)")),
54
+ );
55
+ /** The reason the store was last given for the one question. */
56
+ const storedReason = async (page: Page): Promise<string | undefined> =>
57
+ (await writes(page)).at(-1)?.reasons["keep"];
58
+
59
+ /** Wait until the store's last write carries `text` as the reason (the page saves on its own). */
60
+ async function reasonStored(page: Page, text: string): Promise<void> {
61
+ await page.waitForFunction(
62
+ `(window.__writes || []).at(-1)?.reasons?.keep === ${JSON.stringify(text)}`,
63
+ undefined,
64
+ { timeout: 10_000 },
65
+ );
66
+ assert.equal(await storedReason(page), text);
67
+ }
68
+
69
+ /** A browser test launches Chromium and types: far above vitest's 5 s default on a slow runner. */
70
+ const BROWSER_TEST_MS = 60_000;
71
+
72
+ async function onApprovalPage(
73
+ name: string,
74
+ echoMs: number,
75
+ steps: (page: Page) => Promise<void>,
76
+ filter: "all" | "next" = "all",
77
+ ): Promise<void> {
78
+ const { page: html } = build(setup(name, approval()));
79
+ const browser = await chromium.launch({
80
+ executablePath: chromiumPath(),
81
+ args: ["--no-sandbox"],
82
+ });
83
+ try {
84
+ const page = await browser.newPage();
85
+ await page.addInitScript(STORE(PRACTICE_DONE, echoMs));
86
+ await page.goto("about:blank");
87
+ await page.setContent(html, { waitUntil: "domcontentloaded" });
88
+ if (filter === "next")
89
+ await page.getByRole("button", { name: "Next unanswered" }).click();
90
+ await page.getByRole("button", { name: /Drop/ }).first().click();
91
+ await steps(page);
92
+ } finally {
93
+ await browser.close();
94
+ }
95
+ }
96
+
97
+ test(
98
+ "🔴 a reason typed and never blurred is saved: after a pause in typing, and when the page is left",
99
+ async () => {
100
+ await onApprovalPage("browser-pause", 20, async (page) => {
101
+ await page.locator("textarea").first().click();
102
+ await page.keyboard.type("a process rule");
103
+ await reasonStored(page, "a process rule");
104
+ });
105
+ await onApprovalPage("browser-leave", 20, async (page) => {
106
+ await page.locator("textarea").first().click();
107
+ await page.keyboard.type("not a rule");
108
+ await page.evaluate(() => {
109
+ window.dispatchEvent(new Event("pagehide"));
110
+ });
111
+ await reasonStored(page, "not a rule");
112
+ });
113
+ },
114
+ BROWSER_TEST_MS,
115
+ );
116
+
117
+ test(
118
+ "🔴 the store's echo while typing keeps the box, its focus and every keystroke",
119
+ async () => {
120
+ await onApprovalPage("browser-echo", 300, async (page) => {
121
+ await page.locator("textarea").first().click();
122
+ await page.keyboard.type("a pro");
123
+ await page.waitForTimeout(450); // the Drop's own write comes back and the page renders again
124
+ await page.keyboard.type("cess rule");
125
+ assert.equal(
126
+ await page.evaluate(() => document.activeElement?.tagName),
127
+ "TEXTAREA",
128
+ );
129
+ assert.equal(
130
+ await page.locator("textarea").first().inputValue(),
131
+ "a process rule",
132
+ );
133
+ await reasonStored(page, "a process rule");
134
+ });
135
+ },
136
+ BROWSER_TEST_MS,
137
+ );
138
+
139
+ test(
140
+ "🔴 under 'Next unanswered', the save that completes an item keeps the box being typed in on the page",
141
+ async () => {
142
+ await onApprovalPage(
143
+ "browser-next",
144
+ 20,
145
+ async (page) => {
146
+ await page.locator("textarea").first().click();
147
+ await page.keyboard.type("a pro");
148
+ await reasonStored(page, "a pro"); // the item is complete now, and the filter hides complete items
149
+ await page.keyboard.type("cess rule");
150
+ assert.equal(
151
+ await page.evaluate(() => document.activeElement?.tagName),
152
+ "TEXTAREA",
153
+ );
154
+ await reasonStored(page, "a process rule");
155
+ },
156
+ "next",
157
+ );
158
+ },
159
+ BROWSER_TEST_MS,
160
+ );
161
+
162
+ test(
163
+ "a Keep needs no reason and writes none: the page stays quiet",
164
+ async () => {
165
+ const { page: html } = build(setup("browser-keep", approval()));
166
+ const browser = await chromium.launch({
167
+ executablePath: chromiumPath(),
168
+ args: ["--no-sandbox"],
169
+ });
170
+ try {
171
+ const page = await browser.newPage();
172
+ await page.addInitScript(STORE(PRACTICE_DONE, 20));
173
+ await page.goto("about:blank");
174
+ await page.setContent(html, { waitUntil: "domcontentloaded" });
175
+ await page.getByRole("button", { name: /Keep/ }).first().click();
176
+ await page.waitForTimeout(1200); // longer than the reason's pause: nothing more may arrive
177
+ assert.equal(await page.locator("textarea").count(), 0);
178
+ assert.deepEqual(
179
+ (await writes(page)).map((w) => w.reasons),
180
+ [{}],
181
+ );
182
+ } finally {
183
+ await browser.close();
184
+ }
185
+ },
186
+ BROWSER_TEST_MS,
187
+ );
@@ -89,7 +89,14 @@ const readJson = (path) => {
89
89
  // labeller what the answer is, or where the item came from?" — never to make a build pass.
90
90
  const PAGE_FIELDS = ["group", "title", "body", "code", "context"];
91
91
  const PRIVATE_FIELDS = ["source_id"];
92
- const ALLOWED_FIELDS = [...PAGE_FIELDS, ...PRIVATE_FIELDS];
92
+ // One requirement of a line that holds several: the whole line and which part this item is. Shown on
93
+ // the card beneath the item; the line is text the labeller may read, like `context`.
94
+ const UNIT_FIELD = "unit";
95
+ const ALLOWED_FIELDS = [
96
+ ...PAGE_FIELDS,
97
+ UNIT_FIELD,
98
+ ...PRIVATE_FIELDS,
99
+ ];
93
100
  const FAMILIARITY = [
94
101
  { value: "well", label: "Well" },
95
102
  { value: "somewhat", label: "Somewhat" },
@@ -123,6 +130,10 @@ const integer = (required = true) => ({
123
130
  kind: "integer",
124
131
  required,
125
132
  });
133
+ const boolean = (required = true) => ({
134
+ kind: "boolean",
135
+ required,
136
+ });
126
137
  const array = (items, required = true, min = 0) => ({
127
138
  kind: "array",
128
139
  required,
@@ -136,10 +147,13 @@ const mapping = (values, keyPattern, min = 0) => ({
136
147
  key_pattern: keyPattern,
137
148
  values,
138
149
  });
139
- const obj = (fields, closed = true) => ({ kind: "object", required: true, closed, fields });
150
+ const obj = (fields, closed = true, required = true) => ({ kind: "object", required, closed, fields });
140
151
  const ITEM_SCHEMA = obj({
141
152
  source_id: string(),
142
153
  ...Object.fromEntries(PAGE_FIELDS.map((f) => [f, string(f === "group" || f === "body")])),
154
+ [UNIT_FIELD]: obj(
155
+ // `id` names one occurrence of the line (two files may hold the same words): never shown.
156
+ { id: string(), text: string(), part: integer(), of: integer() }, true, false),
143
157
  }, false);
144
158
  // `icon` (an emoji) tells the buttons apart at a glance. No coloured side stripe on a button: it
145
159
  // reads as decoration, not meaning (the build-label-page look test refuses one).
@@ -173,6 +187,9 @@ export const CONFIG_SCHEMA = obj({
173
187
  seed: integer(),
174
188
  groups: mapping(string(), NAME_PATTERN, 1), // ids become store document ids
175
189
  labeller_knows: array(string(true, NAME_PATTERN), false),
190
+ // false: no "how well do you know {group}" question (an approval page: the author has never seen
191
+ // these repositories, so its answer means nothing). Left out, every group is asked (rule 5).
192
+ familiarity: boolean(false),
176
193
  familiarity_question: string(false),
177
194
  questions: array(QUESTION_SCHEMA, true, 1),
178
195
  practice: array(PRACTICE_SCHEMA, true, MIN_PRACTICE),
@@ -227,6 +244,10 @@ function checkObject(value, node, path, where) {
227
244
  fail(`${at(path, unknown)}: not a known field (known: ${Object.keys(node.fields).join(", ")})`);
228
245
  }
229
246
  }
247
+ function checkBoolean(value, where) {
248
+ if (typeof value !== "boolean")
249
+ fail(`${where}: must be true or false, got ${repr(value)}`);
250
+ }
230
251
  /** Refuse the first place `value` departs from `node`; the message starts with the field's path. */
231
252
  function checkValue(value, node, path) {
232
253
  const where = path || "items.json";
@@ -239,6 +260,9 @@ function checkValue(value, node, path) {
239
260
  fail(`${where}: must be an integer, got ${repr(value)}`);
240
261
  }
241
262
  break;
263
+ case "boolean":
264
+ checkBoolean(value, where);
265
+ break;
242
266
  case "array":
243
267
  checkArray(value, node, path, where);
244
268
  break;
@@ -258,15 +282,17 @@ function assertConfig(value) {
258
282
  * The page script (`DATA.allowed`) and the exporter (`key.json`) both read this one result, so a
259
283
  * stored value counts as an answer on the page exactly when it counts in the export.
260
284
  */
261
- const allowedValues = (questions) => ({
285
+ const allowedValues = (questions, asksFamiliarity) => ({
262
286
  questions: Object.fromEntries(questions.map((q) => [q.id, q.options.map((o) => o.value)])),
263
287
  // The answers that need a reason, per question: the page and the export read the same list.
264
288
  needs_why: Object.fromEntries(questions.map((q) => [
265
289
  q.id,
266
290
  q.options.filter((o) => o.ask_why !== undefined).map((o) => o.value),
267
291
  ])),
268
- familiarity: FAMILIARITY.map((o) => o.value),
292
+ // Empty when the page asks no familiarity: the export then expects no group document at all.
293
+ familiarity: asksFamiliarity ? FAMILIARITY.map((o) => o.value) : [],
269
294
  });
295
+ const asksFamiliarity = (cfg) => cfg.familiarity !== false;
270
296
  /** Two values that read the same once case and surrounding space are gone. */
271
297
  const readsAs = (s) => s.trim().toLowerCase();
272
298
  /** The first entry whose reading repeats an earlier one, with the index of that earlier one. */
@@ -330,6 +356,9 @@ function checkPractice(cfg) {
330
356
  }
331
357
  function checkConfig(cfg) {
332
358
  checkGroups(cfg);
359
+ if (!asksFamiliarity(cfg) && cfg.familiarity_question !== undefined) {
360
+ fail(`familiarity_question: ${repr(cfg.familiarity_question)} is never asked, because \`familiarity\` is false — remove one of the two`);
361
+ }
333
362
  cfg.questions.forEach((q, qn) => {
334
363
  checkQuestion(q, qn, cfg.questions.slice(0, qn).map((x) => x.id));
335
364
  });
@@ -337,7 +366,41 @@ function checkConfig(cfg) {
337
366
  }
338
367
  /** The practice as the page shows it and the key keeps it: neutral ids P1, P2 … in the given order. */
339
368
  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 ───────────────────────────────
369
+ /** The items of each line, in items.json order; a line's list is never empty. */
370
+ const linesOf = (cfg) => cfg.items.reduce((byLine, item, n) => {
371
+ if (item.unit === undefined)
372
+ return byLine;
373
+ const part = { item, unit: item.unit, n };
374
+ const seen = byLine.get(item.unit.id);
375
+ return byLine.set(item.unit.id, seen ? [seen[0], ...seen.slice(1), part] : [part]);
376
+ }, new Map());
377
+ /** One part against the first part of its line: same count, in range, not repeated, same group. */
378
+ function checkPart({ item, unit, n }, parts) {
379
+ const [first] = parts;
380
+ const at = `items[${String(n)}]`;
381
+ if (unit.text !== first.unit.text)
382
+ fail(`${at}.unit.text: ${repr(unit.text)}, but items[${String(first.n)}] of line ${repr(unit.id)} says ${repr(first.unit.text)}`);
383
+ if (unit.of !== first.unit.of)
384
+ fail(`${at}.unit.of: ${String(unit.of)}, but items[${String(first.n)}].unit.of says ${String(first.unit.of)} for the same line`);
385
+ if (unit.part < 1 || unit.part > unit.of)
386
+ fail(`${at}.unit.part: ${String(unit.part)} is not between 1 and ${String(unit.of)}`);
387
+ const earlier = parts.find((p) => p.n < n && p.unit.part === unit.part);
388
+ if (earlier !== undefined)
389
+ fail(`${at}.unit.part: ${String(unit.part)} repeats items[${String(earlier.n)}].unit.part`);
390
+ if (item.group !== first.item.group)
391
+ fail(`${at}.group: ${repr(item.group)}, but items[${String(first.n)}] of the same line is in ${repr(first.item.group)}`);
392
+ }
393
+ function checkUnits(cfg) {
394
+ for (const [line, parts] of linesOf(cfg)) {
395
+ parts.forEach((p) => {
396
+ checkPart(p, parts);
397
+ });
398
+ const [first] = parts;
399
+ const missing = Array.from({ length: first.unit.of }, (_, k) => k + 1).find((k) => !parts.some((p) => p.unit.part === k));
400
+ if (missing !== undefined)
401
+ fail(`items[${String(first.n)}].unit: part ${String(missing)} of ${String(first.unit.of)} of line ${repr(line)} is not among the items`);
402
+ }
403
+ }
341
404
  function checkItemFields(cfg) {
342
405
  const sids = [];
343
406
  cfg.items.forEach((item, n) => {
@@ -354,6 +417,11 @@ function checkItemFields(cfg) {
354
417
  fail(`items[${String(n)}].group: ${repr(item.group)} is not in \`groups\``);
355
418
  });
356
419
  }
420
+ /** Every text of an item the labeller sees, by field: the page fields and the line of a unit. */
421
+ const visibleTexts = (item) => [
422
+ ...PAGE_FIELDS.map((f) => [f, item[f] ?? ""]),
423
+ [UNIT_FIELD, item.unit?.text ?? ""],
424
+ ];
357
425
  const escapeRegExp = (s) => s.replace(/[\\^$.*+?()[\]{}|/]/g, "\\$&");
358
426
  /**
359
427
  * A source id typed into a visible field gives the source away as well as a field would. Short ids
@@ -366,7 +434,7 @@ function checkNoSourceShown(cfg) {
366
434
  for (const item of cfg.items) {
367
435
  for (const sid of long) {
368
436
  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] ?? ""));
437
+ const field = visibleTexts(item).find(([, text]) => word.test(text))?.[0];
370
438
  if (field !== undefined) {
371
439
  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
440
  }
@@ -407,11 +475,21 @@ function assignIds(items, seed, keyPath) {
407
475
  ]);
408
476
  return { ids, entries };
409
477
  }
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
- }));
478
+ const shownFields = (item) => ({
479
+ ...Object.fromEntries(PAGE_FIELDS.flatMap((f) => {
480
+ const v = item[f];
481
+ return v === undefined ? [] : [[f, v]];
482
+ })),
483
+ ...(item.unit === undefined
484
+ ? {}
485
+ : {
486
+ [UNIT_FIELD]: {
487
+ text: item.unit.text,
488
+ part: item.unit.part,
489
+ of: item.unit.of,
490
+ },
491
+ }),
492
+ });
415
493
  /** Only what the labeller can answer goes on the page, and only that gets an id, in id order. */
416
494
  function itemsOnPage(cfg, keyPath) {
417
495
  const knows = cfg.labeller_knows;
@@ -432,9 +510,11 @@ function payloadOf(cfg, page) {
432
510
  title: cfg.title,
433
511
  intro: cfg.intro ?? "",
434
512
  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),
513
+ familiarity_question: asksFamiliarity(cfg)
514
+ ? (cfg.familiarity_question ?? "How well do you know {group}?")
515
+ : "",
516
+ familiarity: asksFamiliarity(cfg) ? FAMILIARITY : [],
517
+ allowed: allowedValues(cfg.questions, asksFamiliarity(cfg)),
438
518
  questions: cfg.questions,
439
519
  practice: { round: practiceRound(cfg), items: practiceOf(cfg) },
440
520
  items: page.items.map((i) => ({ id: page.idOf(i), ...shownFields(i) })),
@@ -478,7 +558,7 @@ function keyOf(cfg, page, earlier) {
478
558
  questions: cfg.questions.map((q) => q.id),
479
559
  // What counts as an answer: the same lists the page was given (`allowedValues`). `export`
480
560
  // checks every stored answer and familiarity against them, so the build's options decide.
481
- allowed: allowedValues(cfg.questions),
561
+ allowed: allowedValues(cfg.questions, asksFamiliarity(cfg)),
482
562
  // The practice is scored against this at export; its answers never go into the labels.
483
563
  practice: {
484
564
  round: practiceRound(cfg),
@@ -503,6 +583,7 @@ export function prepareBuild(configPath, outDir) {
503
583
  assertConfig(cfg);
504
584
  checkConfig(cfg);
505
585
  checkItemFields(cfg);
586
+ checkUnits(cfg);
506
587
  checkNoSourceShown(cfg);
507
588
  mkdirSync(outDir, { recursive: true });
508
589
  const keyPath = join(outDir, "key.json");
@@ -658,6 +739,14 @@ function checkDump(dump, key) {
658
739
  }
659
740
  }
660
741
  }
742
+ /** A page built with `familiarity: false` asks no group anything: a group document came from elsewhere. */
743
+ function checkNoFamiliarity(dump, allowed) {
744
+ const groups = isObject(dump["groups"]) ? dump["groups"] : {};
745
+ const [first] = Object.keys(groups).sort(byText);
746
+ if (allowed.familiarity.length === 0 && first !== undefined) {
747
+ fail(`groups/${first}: familiarity is off for this page (items.json \`familiarity: false\`), so no group document is expected — take it out of the dump`);
748
+ }
749
+ }
661
750
  /** The key's `allowed` lists, or a refusal naming a key made by an older build. */
662
751
  function assertAllowed(a, key, keyPath) {
663
752
  const questions = isObject(a) ? a["questions"] : undefined;
@@ -818,7 +907,9 @@ function exportRound(study, round, items, at) {
818
907
  function labelsOf(dump, ctx, day) {
819
908
  const onPage = Object.entries(ctx.key.ids).filter(([, m]) => m.on_page);
820
909
  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);
910
+ const groups = ctx.allowed.familiarity.length
911
+ ? groupsOf([...new Set(onPage.map(([, m]) => m.group))], dump["groups"], ctx.allowed)
912
+ : {};
822
913
  return {
823
914
  study: ctx.key.study,
824
915
  exported: day,
@@ -839,6 +930,7 @@ export function exportLabels(dumpPath, keyPath, outDir, day) {
839
930
  const allowed = key.allowed;
840
931
  assertAllowed(allowed, key, keyPath);
841
932
  checkDump(dump, key);
933
+ checkNoFamiliarity(dump, allowed);
842
934
  const out = labelsOf(dump, { key, allowed }, day);
843
935
  mkdirSync(outDir, { recursive: true });
844
936
  const path = join(outDir, `${key.study}-labels-${day}.json`);