paperlint 10.0.0 → 10.1.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 (54) hide show
  1. package/CONTRIBUTING.md +16 -4
  2. package/dist/eslint-rules/js-needs-reason.d.ts +55 -0
  3. package/dist/eslint-rules/js-needs-reason.d.ts.map +1 -0
  4. package/dist/eslint-rules/js-needs-reason.js +35 -0
  5. package/dist/eslint-rules/js-needs-reason.js.map +1 -0
  6. package/dist/package-dirs.d.ts +6 -0
  7. package/dist/package-dirs.d.ts.map +1 -1
  8. package/dist/package-dirs.js +6 -0
  9. package/dist/package-dirs.js.map +1 -1
  10. package/dist/skills/label-agent-runs/scripts/labels-commands.d.ts +13 -0
  11. package/dist/skills/label-agent-runs/scripts/labels.d.ts +2 -0
  12. package/dist/skills/run-agent-grid/scripts/grid-guard-commands.d.ts +20 -0
  13. package/dist/skills/run-agent-grid/scripts/grid-guard.d.ts +2 -0
  14. package/docs/skills.md +2 -0
  15. package/docs/testing.md +13 -0
  16. package/eslint-rules/js-needs-reason.test.ts +141 -0
  17. package/eslint-rules/js-needs-reason.ts +77 -0
  18. package/eslint.config.mjs +22 -2
  19. package/lib/skill-trigger-cases.test.ts +211 -0
  20. package/package.json +8 -4
  21. package/scripts/coverage-config.test.ts +5 -3
  22. package/skills/build-benchmark/SKILL.md +4 -1
  23. package/skills/build-benchmark/SKILL.md.spec.ts +3 -0
  24. package/skills/build-label-page/SKILL.md +3 -1
  25. package/skills/build-label-page/SKILL.md.spec.ts +2 -0
  26. package/skills/label-agent-runs/SKILL.md +103 -0
  27. package/skills/label-agent-runs/SKILL.md.spec.ts +106 -0
  28. package/skills/label-agent-runs/label-agent-runs.eval.ts +55 -0
  29. package/skills/label-agent-runs/label-agent-runs.harness.ts +123 -0
  30. package/skills/label-agent-runs/references/formats.md +64 -0
  31. package/skills/label-agent-runs/scripts/labels-commands.js +13 -0
  32. package/skills/label-agent-runs/scripts/labels-commands.ts +13 -0
  33. package/skills/label-agent-runs/scripts/labels.js +396 -0
  34. package/skills/label-agent-runs/scripts/labels.test.ts +538 -0
  35. package/skills/label-agent-runs/scripts/labels.ts +501 -0
  36. package/skills/paper-pipeline/SKILL.md +8 -1
  37. package/skills/paper-pipeline/SKILL.md.spec.ts +7 -0
  38. package/skills/run-agent-grid/SKILL.md +181 -0
  39. package/skills/run-agent-grid/SKILL.md.spec.ts +184 -0
  40. package/skills/run-agent-grid/references/failure-catalog.md +36 -0
  41. package/skills/run-agent-grid/references/registry-and-ledger.md +142 -0
  42. package/skills/run-agent-grid/run-agent-grid.eval.ts +59 -0
  43. package/skills/run-agent-grid/run-agent-grid.harness.ts +115 -0
  44. package/skills/run-agent-grid/scripts/grid-guard-commands.js +18 -0
  45. package/skills/run-agent-grid/scripts/grid-guard-commands.ts +21 -0
  46. package/skills/run-agent-grid/scripts/grid-guard.js +700 -0
  47. package/skills/run-agent-grid/scripts/grid-guard.test.ts +1090 -0
  48. package/skills/run-agent-grid/scripts/grid-guard.ts +869 -0
  49. package/src/package-dirs.ts +8 -0
  50. package/tsconfig.base.json +22 -0
  51. package/tsconfig.json +6 -24
  52. package/tsconfig.pkg.json +3 -1
  53. package/tsconfig.skills.json +14 -0
  54. package/tsconfig.src.json +12 -0
@@ -0,0 +1,538 @@
1
+ /**
2
+ * labels.ts, run as a process. Each check both ways: silent on a correct label file, firing on a
3
+ * planted defect — a wrong header, an unknown label, a duplicate cell, a cell without a run, a run
4
+ * without a label, a codebook committed after the labels, a relabel after the analysis without a
5
+ * dated, quoted note. Agreement is checked on a table whose kappa is known by hand: 50 cells,
6
+ * 20 yes/yes, 5 yes/no, 10 no/yes, 15 no/no → p_o 0.70, p_e 0.50, kappa 0.40.
7
+ *
8
+ * No model, no network. Needs `git`.
9
+ */
10
+ import assert from "node:assert/strict";
11
+ import { spawnSync } from "node:child_process";
12
+ import { mkdirSync } from "node:fs";
13
+ import { join } from "node:path";
14
+ import { test } from "vitest";
15
+ import {
16
+ runNode,
17
+ useTempDir,
18
+ writeTree,
19
+ type ScriptResult,
20
+ } from "../../../test/support.ts";
21
+
22
+ const SCRIPT = join(import.meta.dirname, "labels.ts");
23
+ const WORK = useTempDir("labels-");
24
+ const GIT_ENV = {
25
+ GIT_AUTHOR_NAME: "t",
26
+ GIT_AUTHOR_EMAIL: "t@example.invalid",
27
+ GIT_COMMITTER_NAME: "t",
28
+ GIT_COMMITTER_EMAIL: "t@example.invalid",
29
+ };
30
+ const labels = (args: readonly string[]): ScriptResult =>
31
+ runNode(SCRIPT, args, { cwd: WORK, env: GIT_ENV });
32
+ const ok = (stdout: string): ScriptResult => ({
33
+ status: 0,
34
+ stdout: `${stdout}\n`,
35
+ stderr: "",
36
+ });
37
+ const no = (status: number, stderr: string): ScriptResult => ({
38
+ status,
39
+ stdout: "",
40
+ stderr: `labels: ${stderr}\n`,
41
+ });
42
+ const git = (
43
+ cwd: string,
44
+ env: Record<string, string>,
45
+ ...args: string[]
46
+ ): void => {
47
+ const r = spawnSync("git", args, {
48
+ cwd,
49
+ encoding: "utf8",
50
+ env: { ...process.env, ...GIT_ENV, ...env },
51
+ });
52
+ assert.equal(r.status, 0, r.stderr);
53
+ };
54
+ /** Commit `files` into the repository at `dir`, dated `when` (unix seconds). */
55
+ const commit = (
56
+ dir: string,
57
+ files: Readonly<Record<string, string>>,
58
+ when: number,
59
+ ): void => {
60
+ writeTree(dir, files);
61
+ const date = `${String(when)} +0000`;
62
+ const env = { GIT_AUTHOR_DATE: date, GIT_COMMITTER_DATE: date };
63
+ git(dir, env, "add", ...Object.keys(files));
64
+ git(dir, env, "commit", "-q", "-m", `at ${String(when)}`);
65
+ };
66
+ const repo = (name: string): string => {
67
+ const dir = join(WORK, name);
68
+ mkdirSync(dir, { recursive: true });
69
+ git(dir, {}, "init", "-q");
70
+ return dir;
71
+ };
72
+
73
+ const HEAD = "agent\tmodel\tcell\tlabel";
74
+ const tsv = (...rows: string[][]): string =>
75
+ [HEAD, ...rows.map((r) => r.join("\t")), ""].join("\n");
76
+ const CODEBOOK = JSON.stringify({
77
+ labels: [
78
+ {
79
+ id: "solved",
80
+ definition: "the task's check passes",
81
+ borderline: "passes after an unrelated edit",
82
+ },
83
+ {
84
+ id: "deferred",
85
+ definition: "stops and hands the decision back",
86
+ borderline: "'I will leave this for you to decide.'",
87
+ },
88
+ ],
89
+ });
90
+ const LEDGER = [
91
+ {
92
+ attempt: "a1",
93
+ agent: "cli",
94
+ model: "m1",
95
+ unit: "u1",
96
+ arm: "control",
97
+ seed: 1,
98
+ start: "s",
99
+ },
100
+ { attempt: "a1", status: "complete" },
101
+ {
102
+ attempt: "a2",
103
+ agent: "cli",
104
+ model: "m1",
105
+ unit: "u1",
106
+ arm: "treatment",
107
+ seed: 1,
108
+ status: "complete",
109
+ },
110
+ {
111
+ attempt: "a3",
112
+ agent: "cli",
113
+ model: "m1",
114
+ unit: "u2",
115
+ arm: "control",
116
+ seed: 1,
117
+ status: "harness-error",
118
+ },
119
+ ]
120
+ .map((r) => JSON.stringify(r))
121
+ .join("\n");
122
+ const GOOD = tsv(
123
+ ["cli", "m1", "u1/control/1", "solved"],
124
+ ["cli", "m1", "u1/treatment/1", "deferred"],
125
+ );
126
+ const USAGE =
127
+ "usage: labels.js validate <labels.tsv> --codebook <f> [--ledger <f>] [--analysed-at <rev> --notes <f>] | agree <a.tsv> <b.tsv>";
128
+
129
+ test("a file that cannot be read is an error, exit 1", () => {
130
+ const f = join(WORK, "absent.tsv");
131
+ assert.deepEqual(
132
+ labels(["agree", f, f]),
133
+ no(1, `ENOENT: no such file or directory, open '${f}'`),
134
+ );
135
+ });
136
+
137
+ test("usage", () => {
138
+ assert.deepEqual(labels([]), no(2, USAGE));
139
+ assert.deepEqual(labels(["validate", "x.tsv"]), no(2, USAGE));
140
+ assert.deepEqual(
141
+ labels(["validate", "x.tsv", "--codebook", "c", "--analysed-at", "HEAD"]),
142
+ no(2, USAGE),
143
+ );
144
+ assert.deepEqual(labels(["agree", "a.tsv"]), no(2, USAGE));
145
+ assert.deepEqual(
146
+ labels(["agree", "--colour"]),
147
+ no(
148
+ 2,
149
+ `Unknown option '--colour'. To specify a positional argument starting with a '-', place it at the end of the command after '--', as in '-- "--colour"\n${USAGE}`,
150
+ ),
151
+ );
152
+ });
153
+
154
+ test("validate: a committed codebook, a label file covering exactly the completed runs — valid", () => {
155
+ const d = repo("good");
156
+ commit(d, { "codebook.json": CODEBOOK }, 1_700_000_000);
157
+ commit(d, { "labels.tsv": GOOD, "attempts.jsonl": LEDGER }, 1_700_001_000);
158
+ const args = [
159
+ "validate",
160
+ join(d, "labels.tsv"),
161
+ "--codebook",
162
+ join(d, "codebook.json"),
163
+ ];
164
+ assert.deepEqual(
165
+ labels([...args, "--ledger", join(d, "attempts.jsonl")]),
166
+ ok(`${join(d, "labels.tsv")}: 2 labels, valid`),
167
+ );
168
+ // Not yet committed labels are fine: the codebook only has to come first.
169
+ writeTree(d, { "draft.tsv": GOOD });
170
+ assert.deepEqual(
171
+ labels([
172
+ "validate",
173
+ join(d, "draft.tsv"),
174
+ "--codebook",
175
+ join(d, "codebook.json"),
176
+ ]),
177
+ ok(`${join(d, "draft.tsv")}: 2 labels, valid`),
178
+ );
179
+ });
180
+
181
+ test("validate: header, short row, unknown label, duplicate cell, run without label, label without run", () => {
182
+ const d = repo("bad-rows");
183
+ commit(
184
+ d,
185
+ { "codebook.json": CODEBOOK, "attempts.jsonl": LEDGER },
186
+ 1_700_000_000,
187
+ );
188
+ const f = join(d, "labels.tsv");
189
+ writeTree(d, {
190
+ "labels.tsv": [
191
+ "agent\tmodel\tunit\tlabel",
192
+ "cli\tm1\tu1/control/1\tsolved",
193
+ "cli\tm1\tu1/control/1\tsolved",
194
+ "cli\tm1\tu2/control/1\tasked",
195
+ "cli\tm1",
196
+ "",
197
+ ].join("\n"),
198
+ });
199
+ assert.deepEqual(
200
+ labels([
201
+ "validate",
202
+ f,
203
+ "--codebook",
204
+ join(d, "codebook.json"),
205
+ "--ledger",
206
+ join(d, "attempts.jsonl"),
207
+ ]),
208
+ no(
209
+ 1,
210
+ [
211
+ `${f}:1: the header must be "agent\\tmodel\\tcell\\tlabel"`,
212
+ `${f}:5: not four tab-separated fields`,
213
+ `${f}:4: "asked" is not a codebook label`,
214
+ `${f}: cli m1 u1/control/1 is labelled 2 times (lines 2, 3)`,
215
+ `${f}:4: cli m1 u2/control/1 has no completed run in the ledger`,
216
+ "cli m1 u1/treatment/1: a completed run with no label",
217
+ ].join("\n"),
218
+ ),
219
+ );
220
+ });
221
+
222
+ test("validate: the codebook must be committed, unchanged, complete, and first", () => {
223
+ const d = repo("codebook");
224
+ const cb = join(d, "codebook.json");
225
+ writeTree(d, { "codebook.json": CODEBOOK, "labels.tsv": GOOD });
226
+ const run = (): ScriptResult =>
227
+ labels(["validate", join(d, "labels.tsv"), "--codebook", cb]);
228
+ assert.deepEqual(
229
+ run(),
230
+ no(1, `${cb} is not committed: commit the codebook before the first label`),
231
+ );
232
+
233
+ commit(d, { "labels.tsv": GOOD }, 1_700_000_000);
234
+ commit(d, { "codebook.json": CODEBOOK }, 1_700_001_000);
235
+ assert.deepEqual(
236
+ run(),
237
+ no(
238
+ 1,
239
+ `${cb} was committed after ${join(d, "labels.tsv")} was first committed: the codebook is fixed before labelling; a changed codebook needs a new label file`,
240
+ ),
241
+ );
242
+
243
+ writeTree(d, {
244
+ "codebook.json": JSON.stringify({
245
+ labels: [{ id: "solved", definition: "x" }],
246
+ }),
247
+ });
248
+ assert.deepEqual(
249
+ run(),
250
+ no(
251
+ 1,
252
+ [
253
+ `${cb} differs from its committed version: the codebook is fixed before labelling`,
254
+ `${cb}: label "solved" needs an id, a definition and a borderline example`,
255
+ `${cb} was committed after ${join(d, "labels.tsv")} was first committed: the codebook is fixed before labelling; a changed codebook needs a new label file`,
256
+ `${join(d, "labels.tsv")}:3: "deferred" is not a codebook label`,
257
+ ].join("\n"),
258
+ ),
259
+ );
260
+ writeTree(d, { "codebook.json": "{}" });
261
+ assert.equal(run().stderr.includes(`${cb} defines no labels`), true);
262
+
263
+ const loose = writeTree(join(WORK, "loose"), {
264
+ "codebook.json": CODEBOOK,
265
+ "labels.tsv": GOOD,
266
+ });
267
+ assert.deepEqual(
268
+ labels([
269
+ "validate",
270
+ join(loose, "labels.tsv"),
271
+ "--codebook",
272
+ join(loose, "codebook.json"),
273
+ ]),
274
+ no(
275
+ 1,
276
+ `${join(loose, "codebook.json")} is not committed: commit the codebook before the first label`,
277
+ ),
278
+ );
279
+ });
280
+
281
+ test("validate --analysed-at: a relabel after the analysis needs a dated note with a quote", () => {
282
+ const d = repo("relabel");
283
+ commit(d, { "codebook.json": CODEBOOK }, 1_700_000_000);
284
+ commit(d, { "labels.tsv": GOOD }, 1_700_001_000);
285
+ git(d, {}, "tag", "analysis");
286
+ const relabelled = tsv(
287
+ ["cli", "m1", "u1/control/1", "deferred"],
288
+ ["cli", "m1", "u1/treatment/1", "deferred"],
289
+ ["cli", "m1", "u3/control/1", "solved"],
290
+ );
291
+ writeTree(d, {
292
+ "labels.tsv": relabelled,
293
+ "notes.md": "## Notes\nu1/control/1 changed\n",
294
+ });
295
+ const run = (): ScriptResult =>
296
+ labels([
297
+ "validate",
298
+ join(d, "labels.tsv"),
299
+ "--codebook",
300
+ join(d, "codebook.json"),
301
+ "--analysed-at",
302
+ "analysis",
303
+ "--notes",
304
+ join(d, "notes.md"),
305
+ ]);
306
+ const missing = no(
307
+ 1,
308
+ [
309
+ "cli m1 u1/control/1: relabelled solved → deferred after the analysis, with no dated note quoting the run",
310
+ "cli m1 u3/control/1: added after the analysis (solved), with no dated note quoting the run",
311
+ ].join("\n"),
312
+ );
313
+ assert.deepEqual(run(), missing);
314
+ // Dated, but no verbatim quote: still refused.
315
+ writeTree(d, {
316
+ "notes.md": "## 2026-01-05\nu1/control/1: re-read, it defers.\n",
317
+ });
318
+ assert.deepEqual(run(), missing);
319
+ writeTree(d, {
320
+ "notes.md":
321
+ "## 2026-01-05\nu1/control/1: re-read, it defers.\n> I'll leave the final call on this to you.\n## 2026-01-06\nu3/control/1 came in late:\n> Done — the check passes.\n",
322
+ });
323
+ assert.deepEqual(run(), ok(`${join(d, "labels.tsv")}: 3 labels, valid`));
324
+ });
325
+
326
+ test("validate --analysed-at: a revision without the label file is refused", () => {
327
+ const d = repo("relabel-rev");
328
+ commit(d, { "codebook.json": CODEBOOK }, 1_700_000_000);
329
+ commit(d, { "labels.tsv": GOOD }, 1_700_001_000);
330
+ git(d, {}, "tag", "analysis");
331
+ writeTree(d, { "notes.md": "" });
332
+ const r = labels([
333
+ "validate",
334
+ join(d, "labels.tsv"),
335
+ "--codebook",
336
+ join(d, "codebook.json"),
337
+ "--analysed-at",
338
+ "no-such-rev",
339
+ "--notes",
340
+ join(d, "notes.md"),
341
+ ]);
342
+ assert.deepEqual(
343
+ r,
344
+ no(
345
+ 1,
346
+ `${join(d, "labels.tsv")} does not exist at no-such-rev: name the revision the analysis read`,
347
+ ),
348
+ );
349
+ const loose = writeTree(join(WORK, "loose-relabel"), {
350
+ "labels.tsv": GOOD,
351
+ "notes.md": "",
352
+ });
353
+ const r2 = labels([
354
+ "validate",
355
+ join(loose, "labels.tsv"),
356
+ "--codebook",
357
+ join(d, "codebook.json"),
358
+ "--analysed-at",
359
+ "analysis",
360
+ "--notes",
361
+ join(loose, "notes.md"),
362
+ ]);
363
+ assert.deepEqual(
364
+ r2,
365
+ no(
366
+ 1,
367
+ `${join(loose, "labels.tsv")} does not exist at analysis: name the revision the analysis read`,
368
+ ),
369
+ );
370
+ });
371
+
372
+ /** The hand-checked table: rater A and rater B over 50 cells. */
373
+ const table = (): [string, string] => {
374
+ const cells = (from: number, to: number, label: string): string[][] =>
375
+ Array.from({ length: to - from + 1 }, (_, i) => [
376
+ "cli",
377
+ "m1",
378
+ `u${String(from + i)}/control/1`,
379
+ label,
380
+ ]);
381
+ const a = tsv(...cells(1, 25, "yes"), ...cells(26, 50, "no"));
382
+ const b = tsv(
383
+ ...cells(1, 20, "yes"),
384
+ ...cells(21, 25, "no"),
385
+ ...cells(26, 35, "yes"),
386
+ ...cells(36, 50, "no"),
387
+ );
388
+ return [a, b];
389
+ };
390
+
391
+ test("agree: kappa, percent with a Wilson interval, and every disagreement on a known table", () => {
392
+ const [a, b] = table();
393
+ const d = writeTree(join(WORK, "agree"), { "a.tsv": a, "b.tsv": b });
394
+ const r = labels(["agree", join(d, "a.tsv"), join(d, "b.tsv")]);
395
+ const disagree = [
396
+ ...Array.from(
397
+ { length: 5 },
398
+ (_, i) => `disagree cli m1 u${String(21 + i)}/control/1: yes | no`,
399
+ ),
400
+ ...Array.from(
401
+ { length: 10 },
402
+ (_, i) => `disagree cli m1 u${String(26 + i)}/control/1: no | yes`,
403
+ ),
404
+ ];
405
+ assert.deepEqual(
406
+ r,
407
+ ok(
408
+ [
409
+ "n 50",
410
+ "agreement 35/50 = 0.700 (95% Wilson 0.562–0.809)",
411
+ "cohen kappa 0.400",
412
+ ...disagree,
413
+ ].join("\n"),
414
+ ),
415
+ );
416
+ });
417
+
418
+ test("agree: one label only leaves kappa undefined, and says so", () => {
419
+ const one = tsv(
420
+ ["cli", "m1", "u1/control/1", "yes"],
421
+ ["cli", "m1", "u2/control/1", "yes"],
422
+ );
423
+ const d = writeTree(join(WORK, "agree-one"), { "a.tsv": one, "b.tsv": one });
424
+ assert.deepEqual(
425
+ labels(["agree", join(d, "a.tsv"), join(d, "b.tsv")]),
426
+ ok(
427
+ "n 2\nagreement 2/2 = 1.000 (95% Wilson 0.342–1.000)\ncohen kappa undefined (one label only)",
428
+ ),
429
+ );
430
+ });
431
+
432
+ test("agree: different cells, a malformed file, or nothing to compare are refused", () => {
433
+ const d = writeTree(join(WORK, "agree-bad"), {
434
+ "a.tsv": tsv(["cli", "m1", "u1/control/1", "yes"]),
435
+ "b.tsv": tsv(["cli", "m1", "u2/control/1", "yes"]),
436
+ "bad.tsv": "cell\tlabel\n",
437
+ "empty.tsv": tsv(),
438
+ });
439
+ assert.deepEqual(
440
+ labels(["agree", join(d, "a.tsv"), join(d, "b.tsv")]),
441
+ no(
442
+ 1,
443
+ `the two files label different cells — rate the same runs:\ncli m1 u1/control/1: only in ${join(d, "a.tsv")}\ncli m1 u2/control/1: only in ${join(d, "b.tsv")}`,
444
+ ),
445
+ );
446
+ assert.deepEqual(
447
+ labels(["agree", join(d, "bad.tsv"), join(d, "a.tsv")]),
448
+ no(
449
+ 1,
450
+ `${join(d, "bad.tsv")}:1: the header must be "agent\\tmodel\\tcell\\tlabel"`,
451
+ ),
452
+ );
453
+ assert.deepEqual(
454
+ labels(["agree", join(d, "empty.tsv"), join(d, "empty.tsv")]),
455
+ no(1, "no labels to compare"),
456
+ );
457
+ });
458
+
459
+ test("validate --analysed-at: deleting a labelled cell after the analysis needs a note too", () => {
460
+ const d = repo("relabel-delete");
461
+ commit(d, { "codebook.json": CODEBOOK }, 1_700_000_000);
462
+ commit(d, { "labels.tsv": GOOD }, 1_700_001_000);
463
+ git(d, {}, "tag", "analysis");
464
+ // Emptied to the header: every cell the analysis read is gone.
465
+ writeTree(d, { "labels.tsv": tsv(), "notes.md": "" });
466
+ const run = (): ScriptResult =>
467
+ labels([
468
+ "validate",
469
+ join(d, "labels.tsv"),
470
+ "--codebook",
471
+ join(d, "codebook.json"),
472
+ "--analysed-at",
473
+ "analysis",
474
+ "--notes",
475
+ join(d, "notes.md"),
476
+ ]);
477
+ assert.deepEqual(
478
+ run(),
479
+ no(
480
+ 1,
481
+ [
482
+ "cli m1 u1/control/1: removed after the analysis (was solved), with no dated note quoting the run",
483
+ "cli m1 u1/treatment/1: removed after the analysis (was deferred), with no dated note quoting the run",
484
+ ].join("\n"),
485
+ ),
486
+ );
487
+ writeTree(d, {
488
+ "notes.md":
489
+ "## 2026-01-07\nu1/control/1 and u1/treatment/1 were a harness error:\n> setup failed before the agent started\n",
490
+ });
491
+ assert.deepEqual(run(), ok(`${join(d, "labels.tsv")}: 0 labels, valid`));
492
+ });
493
+
494
+ test("validate: a codebook changed after the first label commit is refused", () => {
495
+ const d = repo("codebook-edited");
496
+ commit(d, { "codebook.json": CODEBOOK }, 1_700_000_000);
497
+ commit(d, { "labels.tsv": GOOD }, 1_700_001_000);
498
+ const edited = CODEBOOK.replace(
499
+ "]}",
500
+ ',{"id":"failed","definition":"neither","borderline":"a timeout"}]}',
501
+ );
502
+ commit(d, { "codebook.json": edited }, 1_700_002_000);
503
+ const cb = join(d, "codebook.json");
504
+ assert.deepEqual(
505
+ labels(["validate", join(d, "labels.tsv"), "--codebook", cb]),
506
+ no(
507
+ 1,
508
+ `${cb} was committed after ${join(d, "labels.tsv")} was first committed: the codebook is fixed before labelling; a changed codebook needs a new label file`,
509
+ ),
510
+ );
511
+ // A new label file, begun after the new codebook, is fine.
512
+ commit(d, { "labels-v2.tsv": GOOD }, 1_700_003_000);
513
+ assert.deepEqual(
514
+ labels(["validate", join(d, "labels-v2.tsv"), "--codebook", cb]),
515
+ ok(`${join(d, "labels-v2.tsv")}: 2 labels, valid`),
516
+ );
517
+ });
518
+
519
+ test("agree: a cell labelled twice in either file is refused before any statistic", () => {
520
+ const twice = tsv(
521
+ ["cli", "m1", "u1/control/1", "yes"],
522
+ ["cli", "m1", "u1/control/1", "no"],
523
+ ["cli", "m1", "u2/control/1", "yes"],
524
+ );
525
+ const once = tsv(
526
+ ["cli", "m1", "u1/control/1", "yes"],
527
+ ["cli", "m1", "u2/control/1", "yes"],
528
+ );
529
+ const d = writeTree(join(WORK, "agree-dup"), {
530
+ "twice.tsv": twice,
531
+ "once.tsv": once,
532
+ });
533
+ const a = join(d, "twice.tsv");
534
+ const b = join(d, "once.tsv");
535
+ const dup = `${a}: cli m1 u1/control/1 is labelled 2 times (lines 2, 3)`;
536
+ assert.deepEqual(labels(["agree", a, b]), no(1, dup));
537
+ assert.deepEqual(labels(["agree", b, a]), no(1, dup));
538
+ });