paperlint 10.2.0 → 10.3.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 (46) hide show
  1. package/dist/adapters/study-registry/index.d.ts +352 -1
  2. package/dist/adapters/study-registry/index.d.ts.map +1 -1
  3. package/dist/adapters/study-registry/index.js +171 -8
  4. package/dist/adapters/study-registry/index.js.map +1 -1
  5. package/dist/cli.d.ts.map +1 -1
  6. package/dist/cli.js +6 -4
  7. package/dist/cli.js.map +1 -1
  8. package/dist/domain/study-registry.d.ts +201 -5
  9. package/dist/domain/study-registry.d.ts.map +1 -1
  10. package/dist/domain/study-registry.js +224 -2
  11. package/dist/domain/study-registry.js.map +1 -1
  12. package/dist/ports/study-registry-codec.d.ts +6 -2
  13. package/dist/ports/study-registry-codec.d.ts.map +1 -1
  14. package/dist/studies.d.ts.map +1 -1
  15. package/dist/studies.js +7 -4
  16. package/dist/studies.js.map +1 -1
  17. package/dist/study-registry-rule.d.ts +12 -5
  18. package/dist/study-registry-rule.d.ts.map +1 -1
  19. package/dist/study-registry-rule.js +67 -37
  20. package/dist/study-registry-rule.js.map +1 -1
  21. package/docs/design/study-registry.md +131 -15
  22. package/docs/layout.md +23 -21
  23. package/docs/rules/repro/study-plan-name.md +70 -0
  24. package/docs/rules/repro/study-registered.md +164 -6
  25. package/docs/rules.md +53 -52
  26. package/package.json +1 -1
  27. package/skills/build-benchmark/SKILL.md +24 -12
  28. package/skills/build-benchmark/SKILL.md.spec.ts +23 -11
  29. package/skills/build-label-page/SKILL.md +9 -7
  30. package/skills/build-label-page/SKILL.md.spec.ts +8 -6
  31. package/skills/run-agent-grid/SKILL.md +10 -5
  32. package/skills/run-agent-grid/SKILL.md.spec.ts +9 -4
  33. package/src/adapters/study-registry/index.test.ts +340 -0
  34. package/src/adapters/study-registry/index.ts +268 -10
  35. package/src/cli.ts +6 -4
  36. package/src/domain/study-measurements.test.ts +629 -0
  37. package/src/domain/study-registry.test.ts +10 -1
  38. package/src/domain/study-registry.ts +456 -10
  39. package/src/new-paper.test.ts +1 -0
  40. package/src/ports/study-registry-codec.ts +6 -2
  41. package/src/rule-cases.test.ts +27 -0
  42. package/src/studies-cli.test.ts +2 -2
  43. package/src/studies.test.ts +6 -0
  44. package/src/studies.ts +12 -3
  45. package/src/study-registry-rule.test.ts +140 -1
  46. package/src/study-registry-rule.ts +106 -39
@@ -0,0 +1,629 @@
1
+ /**
2
+ * The study registry above its studies (`src/domain/study-registry.ts`): the author's requirements,
3
+ * the measurements a study belongs to, and each study's plan. Both halves: a registry whose
4
+ * measurements, approvals and plans hold together is silent; each defect is reported once, with a
5
+ * text that stands alone. A registry without the new keys is judged as before.
6
+ */
7
+ import { describe, expect, it } from "vitest";
8
+ import {
9
+ PLAN_FILE,
10
+ judgeStudies,
11
+ planKeys,
12
+ planTemplate,
13
+ planText,
14
+ studyMessage,
15
+ withIndex,
16
+ type Measurement,
17
+ type DraftPlan,
18
+ type PlanState,
19
+ type PlanTextField,
20
+ type Study,
21
+ type StudyInput,
22
+ type StudyPlan,
23
+ type StudyRegistry,
24
+ } from "./study-registry.ts";
25
+
26
+ const study = (over: Partial<Study> = {}): Study => ({
27
+ id: "P1-S01",
28
+ folder: "P1-S01-census",
29
+ title: "Census of rules",
30
+ question: "How many rules can a linter check?",
31
+ kind: "measurement",
32
+ status: "running",
33
+ date: "2026-10-15",
34
+ in_paper: "no",
35
+ ...over,
36
+ });
37
+
38
+ const AUTHOR_CHECK = {
39
+ by: "A. Author",
40
+ date: "2026-10-14",
41
+ quote: "These are the rules I meet. Go.",
42
+ } as const;
43
+
44
+ const APPROVAL = {
45
+ by: "A. Author",
46
+ date: "2026-10-12",
47
+ quote: "This is the one.",
48
+ } as const;
49
+
50
+ const PLAN: StudyPlan = {
51
+ question: "How many rules can a linter check?",
52
+ population: {
53
+ source: "every rule found in the crawl of 2026-10-01",
54
+ draw: { kind: "all", method: "census" },
55
+ },
56
+ arms: [],
57
+ primary_outcome: "the share of rules a linter can check",
58
+ author_check: AUTHOR_CHECK,
59
+ };
60
+
61
+ const HEADLINE: Measurement = {
62
+ id: "M1",
63
+ title: "Rule census",
64
+ role: "headline",
65
+ approval: APPROVAL,
66
+ requirements: ["R1"],
67
+ };
68
+
69
+ /** A registry with an author, one requirement, the headline measurement and its one study. */
70
+ const reg = (over: Partial<StudyRegistry> = {}): StudyRegistry => ({
71
+ prefix: "P1",
72
+ author: "A. Author",
73
+ requirements: [
74
+ {
75
+ id: "R1",
76
+ text: "Every sample is a random draw from all rules found, no filter by difficulty.",
77
+ date: "2026-10-10",
78
+ source: "chat",
79
+ },
80
+ ],
81
+ measurements: [HEADLINE],
82
+ studies: [study({ measurement: "M1", plan: PLAN_FILE })],
83
+ ...over,
84
+ });
85
+
86
+ const read = (plan: StudyPlan): PlanState => ({ kind: "ready", plan });
87
+
88
+ /** Every required text of the template, empty. */
89
+ const ALL_EMPTY = [
90
+ "question",
91
+ "population.source",
92
+ "population.draw.method",
93
+ "primary_outcome",
94
+ ] as const;
95
+
96
+ const draft = (
97
+ plan: DraftPlan,
98
+ empty: PlanTextField[] = [...ALL_EMPTY],
99
+ ): PlanState => ({ kind: "draft", plan, empty });
100
+
101
+ /** A `repro/` that holds every row's folder and plan, its index in sync, then `over`. */
102
+ const input = (
103
+ r: StudyRegistry,
104
+ over: Partial<StudyInput> = {},
105
+ ): StudyInput => ({
106
+ folders: r.studies.map((s) => s.folder),
107
+ registry: { kind: "read", registry: r },
108
+ readme: withIndex("# Reproduction index\n", r),
109
+ existingResults: new Set(),
110
+ plans: new Map(
111
+ r.studies.flatMap((s) =>
112
+ s.plan === undefined ? [] : [[`${s.folder}/${s.plan}`, read(PLAN)]],
113
+ ),
114
+ ),
115
+ ...over,
116
+ });
117
+
118
+ const findings = (r: StudyRegistry, over: Partial<StudyInput> = {}) =>
119
+ judgeStudies(input(r, over));
120
+
121
+ describe("measurements and plans — silent", () => {
122
+ it("on a headline measurement approved by the author, its study's plan checked by the author", () => {
123
+ expect(findings(reg())).toEqual([]);
124
+ });
125
+
126
+ it("on a registry without the new keys: a row's `measurement` is a field of its own, as before", () => {
127
+ const r: StudyRegistry = {
128
+ prefix: "P1",
129
+ studies: [study({ measurement: "census", measurement_title: "Census" })],
130
+ };
131
+ expect(findings(r)).toEqual([]);
132
+ });
133
+
134
+ it("on a registry with no author: any approval stands", () => {
135
+ const { author, ...r } = reg();
136
+ expect(author).toBeDefined();
137
+ expect(findings(r)).toEqual([]);
138
+ });
139
+
140
+ it("on a baseline measurement with no approval, and a study of it with no plan", () => {
141
+ const r = reg({
142
+ measurements: [
143
+ HEADLINE,
144
+ { id: "M2", title: "Baseline linter", role: "baseline" },
145
+ ],
146
+ studies: [
147
+ study({ measurement: "M1", plan: PLAN_FILE }),
148
+ study({ id: "P1-S02", folder: "P1-S02-base", measurement: "M2" }),
149
+ ],
150
+ });
151
+ expect(findings(r)).toEqual([]);
152
+ });
153
+
154
+ it("on a designing study whose plan is the template, in a headline measurement", () => {
155
+ const r = reg({
156
+ studies: [
157
+ study({ status: "designing", measurement: "M1", plan: PLAN_FILE }),
158
+ ],
159
+ });
160
+ const plans = new Map([
161
+ [`P1-S01-census/${PLAN_FILE}`, draft(planTemplate())],
162
+ ]);
163
+ expect(findings(r, { plans })).toEqual([]);
164
+ });
165
+
166
+ it("on a dropped study of a headline measurement with no plan", () => {
167
+ const r = reg({
168
+ studies: [
169
+ study({ measurement: "M1", plan: PLAN_FILE }),
170
+ study({
171
+ id: "P1-S02",
172
+ folder: "P1-S02-pilot",
173
+ status: "dropped",
174
+ measurement: "M1",
175
+ }),
176
+ ],
177
+ });
178
+ expect(findings(r)).toEqual([]);
179
+ });
180
+ });
181
+
182
+ describe("measurements — a row's measurement, and a measurement's studies", () => {
183
+ it("a row naming a measurement that is not in `measurements`", () => {
184
+ const r = reg({
185
+ studies: [
186
+ study({ measurement: "M1", plan: PLAN_FILE }),
187
+ study({ id: "P1-S02", folder: "P1-S02-pilot", measurement: "M9" }),
188
+ ],
189
+ });
190
+ expect(findings(r)).toEqual([
191
+ { problem: "unknown-measurement", id: "P1-S02", measurement: "M9" },
192
+ ]);
193
+ });
194
+
195
+ it("a measurement no row names", () => {
196
+ const r = reg({
197
+ measurements: [
198
+ HEADLINE,
199
+ { id: "M2", title: "Replication", role: "baseline" },
200
+ ],
201
+ });
202
+ expect(findings(r)).toEqual([
203
+ { problem: "empty-measurement", measurement: "M2" },
204
+ ]);
205
+ });
206
+
207
+ it("a measurement that must meet a requirement the registry does not hold", () => {
208
+ const r = reg({
209
+ measurements: [{ ...HEADLINE, requirements: ["R1", "R7"] }],
210
+ });
211
+ expect(findings(r)).toEqual([
212
+ { problem: "unknown-requirement", measurement: "M1", requirement: "R7" },
213
+ ]);
214
+ });
215
+
216
+ it("requirements named when the registry holds none", () => {
217
+ const { requirements, ...r } = reg();
218
+ expect(requirements).toBeDefined();
219
+ expect(findings(r)).toEqual([
220
+ { problem: "unknown-requirement", measurement: "M1", requirement: "R1" },
221
+ ]);
222
+ });
223
+ });
224
+
225
+ describe("measurements — a headline's approval", () => {
226
+ it("a headline measurement with no approval", () => {
227
+ const { approval, ...m } = HEADLINE;
228
+ expect(approval).toBeDefined();
229
+ expect(findings(reg({ measurements: [m] }))).toEqual([
230
+ {
231
+ problem: "headline-approval",
232
+ measurement: "M1",
233
+ why: "missing",
234
+ author: "A. Author",
235
+ },
236
+ ]);
237
+ });
238
+
239
+ it("a headline measurement with no approval, in a registry with no author", () => {
240
+ const { approval, ...m } = HEADLINE;
241
+ const { author, ...r } = reg({ measurements: [m] });
242
+ expect([approval, author]).toHaveLength(2);
243
+ expect(findings(r)).toEqual([
244
+ {
245
+ problem: "headline-approval",
246
+ measurement: "M1",
247
+ why: "missing",
248
+ author: null,
249
+ },
250
+ ]);
251
+ });
252
+
253
+ it("a headline measurement approved by someone other than the author", () => {
254
+ const m = { ...HEADLINE, approval: { ...APPROVAL, by: "an agent" } };
255
+ expect(findings(reg({ measurements: [m] }))).toEqual([
256
+ {
257
+ problem: "headline-approval",
258
+ measurement: "M1",
259
+ why: "not-author",
260
+ by: "an agent",
261
+ author: "A. Author",
262
+ },
263
+ ]);
264
+ });
265
+ });
266
+
267
+ describe("plans — the file a row names", () => {
268
+ it("a plan that is not there", () => {
269
+ expect(findings(reg(), { plans: new Map() })).toEqual([
270
+ { problem: "bad-plan", id: "P1-S01", path: PLAN_FILE, why: "missing" },
271
+ ]);
272
+ });
273
+
274
+ it("a running study of a headline measurement whose row names no plan", () => {
275
+ const r = reg({ studies: [study({ measurement: "M1" })] });
276
+ expect(findings(r)).toEqual([
277
+ {
278
+ problem: "author-check",
279
+ id: "P1-S01",
280
+ measurement: "M1",
281
+ status: "running",
282
+ why: "no-plan",
283
+ },
284
+ ]);
285
+ });
286
+
287
+ it("a plan outside the study's folder", () => {
288
+ const r = reg({
289
+ studies: [study({ measurement: "M1", plan: "../plan.json" })],
290
+ });
291
+ expect(findings(r)).toEqual([
292
+ {
293
+ problem: "bad-plan",
294
+ id: "P1-S01",
295
+ path: "../plan.json",
296
+ why: "outside",
297
+ },
298
+ ]);
299
+ });
300
+
301
+ it("a plan that is not of the schema", () => {
302
+ const r = reg({ studies: [study({ plan: PLAN_FILE })], measurements: [] });
303
+ const plans = new Map<string, PlanState>([
304
+ [
305
+ `P1-S01-census/${PLAN_FILE}`,
306
+ { kind: "unreadable", why: "question: required" },
307
+ ],
308
+ ]);
309
+ expect(findings(r, { plans })).toEqual([
310
+ {
311
+ problem: "bad-plan",
312
+ id: "P1-S01",
313
+ path: PLAN_FILE,
314
+ why: "unreadable",
315
+ reasons: "question: required",
316
+ },
317
+ ]);
318
+ });
319
+ });
320
+
321
+ describe("plans — filled once the study has run, and named plan.json", () => {
322
+ it("a study that has run on a plan with empty fields", () => {
323
+ const r = reg({
324
+ studies: [study({ status: "done", result: "x", plan: PLAN_FILE })],
325
+ measurements: [],
326
+ });
327
+ const plans = new Map([
328
+ [`P1-S01-census/${PLAN_FILE}`, draft(planTemplate())],
329
+ ]);
330
+ expect(findings(r, { plans })).toEqual([
331
+ { problem: "no-result", id: "P1-S01", missing: ["result_file"] },
332
+ {
333
+ problem: "bad-plan",
334
+ id: "P1-S01",
335
+ path: PLAN_FILE,
336
+ why: "unfilled",
337
+ fields: [
338
+ "question",
339
+ "population.source",
340
+ "population.draw.method",
341
+ "primary_outcome",
342
+ ],
343
+ status: "done",
344
+ },
345
+ ]);
346
+ });
347
+ });
348
+
349
+ describe("plans — the parser decides ready or draft", () => {
350
+ it("the empty fields are the parser's: a draft is reported with exactly its list", () => {
351
+ const r = reg({ studies: [study({ plan: PLAN_FILE })], measurements: [] });
352
+ const plans = new Map([
353
+ [
354
+ `P1-S01-census/${PLAN_FILE}`,
355
+ draft({ ...PLAN, author_check: null }, ["primary_outcome"]),
356
+ ],
357
+ ]);
358
+ expect(findings(r, { plans })).toEqual([
359
+ {
360
+ problem: "bad-plan",
361
+ id: "P1-S01",
362
+ path: PLAN_FILE,
363
+ why: "unfilled",
364
+ fields: ["primary_outcome"],
365
+ status: "running",
366
+ },
367
+ ]);
368
+ });
369
+
370
+ it("a ready plan is never reported as unfilled", () => {
371
+ const r = reg({
372
+ studies: [
373
+ study({
374
+ status: "done",
375
+ result: "x",
376
+ result_file: "r.tsv",
377
+ plan: PLAN_FILE,
378
+ }),
379
+ ],
380
+ measurements: [],
381
+ });
382
+ const plans = new Map([[`P1-S01-census/${PLAN_FILE}`, read(PLAN)]]);
383
+ expect(
384
+ findings(r, { plans, existingResults: new Set(["P1-S01-census/r.tsv"]) }),
385
+ ).toEqual([]);
386
+ });
387
+
388
+ it("an old prereg.json as the plan: read as the plan, and reported for its name", () => {
389
+ const r = reg({
390
+ studies: [study({ measurement: "M1", plan: "prereg.json" })],
391
+ });
392
+ expect(findings(r)).toEqual([
393
+ { problem: "legacy-plan", id: "P1-S01", path: "prereg.json" },
394
+ ]);
395
+ });
396
+ });
397
+
398
+ describe("plans — the author's check of a headline measurement's study", () => {
399
+ it("a running study of a headline measurement whose plan's author_check is null", () => {
400
+ const plans = new Map([
401
+ [`P1-S01-census/${PLAN_FILE}`, read({ ...PLAN, author_check: null })],
402
+ ]);
403
+ expect(findings(reg(), { plans })).toEqual([
404
+ {
405
+ problem: "author-check",
406
+ id: "P1-S01",
407
+ measurement: "M1",
408
+ status: "running",
409
+ why: "missing",
410
+ },
411
+ ]);
412
+ });
413
+
414
+ it("an author_check by someone other than the author", () => {
415
+ const plans = new Map([
416
+ [
417
+ `P1-S01-census/${PLAN_FILE}`,
418
+ read({ ...PLAN, author_check: { ...AUTHOR_CHECK, by: "an agent" } }),
419
+ ],
420
+ ]);
421
+ expect(findings(reg(), { plans })).toEqual([
422
+ {
423
+ problem: "author-check",
424
+ id: "P1-S01",
425
+ measurement: "M1",
426
+ status: "running",
427
+ why: "not-author",
428
+ by: "an agent",
429
+ author: "A. Author",
430
+ },
431
+ ]);
432
+ });
433
+
434
+ it("a plan that cannot be read is reported once, as a plan", () => {
435
+ const plans = new Map<string, PlanState>([
436
+ [`P1-S01-census/${PLAN_FILE}`, { kind: "unreadable", why: "not JSON" }],
437
+ ]);
438
+ expect(judgeStudies(input(reg(), { plans })).map((f) => f.problem)).toEqual(
439
+ ["bad-plan"],
440
+ );
441
+ });
442
+ });
443
+
444
+ describe("planKeys", () => {
445
+ it("names each plan inside its folder, and leaves out a row with none or one outside", () => {
446
+ const r = reg({
447
+ studies: [
448
+ study({ plan: PLAN_FILE }),
449
+ study({ id: "P1-S02", folder: "P1-S02-b" }),
450
+ study({ id: "P1-S03", folder: "P1-S03-c", plan: "../x.json" }),
451
+ study({ id: "P1-S04", folder: "P1-S04-d", plan: "design/plan.json" }),
452
+ ],
453
+ });
454
+ expect(planKeys(r)).toEqual([
455
+ "P1-S01-census/plan.json",
456
+ "P1-S04-d/design/plan.json",
457
+ ]);
458
+ });
459
+ });
460
+
461
+ describe("planTemplate and planText", () => {
462
+ it("every field present, its value to fill, and author_check null", () => {
463
+ expect(planTemplate()).toEqual({
464
+ question: "",
465
+ population: { source: "", draw: { kind: "all", method: "" } },
466
+ arms: [],
467
+ primary_outcome: "",
468
+ author_check: null,
469
+ });
470
+ });
471
+
472
+ it("is JSON, two-space indented, with a final newline", () => {
473
+ expect(planText(PLAN)).toBe(`${JSON.stringify(PLAN, null, 2)}\n`);
474
+ });
475
+ });
476
+
477
+ /** Each new finding, and the head of the text it is reported with. */
478
+ const MESSAGES = [
479
+ [
480
+ { problem: "unknown-measurement", id: "P1-S02", measurement: "M9" },
481
+ "`repro/studies.json`: the row `P1-S02` names the measurement `M9`, which is not in `measurements`. What to do: add the measurement to `measurements`, or correct the row's `measurement`.",
482
+ ],
483
+ [
484
+ { problem: "empty-measurement", measurement: "M2" },
485
+ '`repro/studies.json`: no row names the measurement `M2`, and a measurement is made of studies. What to do: set `"measurement": "M2"` on the rows of its studies, or remove the measurement.',
486
+ ],
487
+ [
488
+ { problem: "unknown-requirement", measurement: "M1", requirement: "R7" },
489
+ "`repro/studies.json`: the measurement `M1` must meet the requirement `R7`, which is not in `requirements`. What to do: add the requirement — the author's words verbatim in `text`, with its `date` and `source` — or correct the measurement's `requirements`.",
490
+ ],
491
+ [
492
+ {
493
+ problem: "headline-approval",
494
+ measurement: "M1",
495
+ why: "missing",
496
+ author: "A. Author",
497
+ },
498
+ "`repro/studies.json`: the measurement `M1` is a `headline` one and has no `approval`. What to do: record the approval of A. Author, the registry's `author` — `by`, `date`, and their words in `quote` — or make it a `baseline`.",
499
+ ],
500
+ [
501
+ {
502
+ problem: "headline-approval",
503
+ measurement: "M1",
504
+ why: "missing",
505
+ author: null,
506
+ },
507
+ "`repro/studies.json`: the measurement `M1` is a `headline` one and has no `approval`. What to do: record the author's approval — `by`, `date`, and their words in `quote` — or make it a `baseline`.",
508
+ ],
509
+ [
510
+ {
511
+ problem: "headline-approval",
512
+ measurement: "M1",
513
+ why: "not-author",
514
+ by: "an agent",
515
+ author: "A. Author",
516
+ },
517
+ "`repro/studies.json`: the measurement `M1` is a `headline` one, and its `approval` is by an agent, not by the registry's `author`, A. Author. What to do: only the author approves a headline measurement; record their own approval.",
518
+ ],
519
+ [
520
+ { problem: "bad-plan", id: "P1-S01", path: "plan.json", why: "missing" },
521
+ "`repro/studies.json`: the `plan` of `P1-S01`, `plan.json`, is not a file in its folder. What to do: write the plan there (its fields are on the rule's page), or correct the row's `plan`.",
522
+ ],
523
+ [
524
+ { problem: "bad-plan", id: "P1-S01", path: "../plan.json", why: "outside" },
525
+ "`repro/studies.json`: the `plan` of `P1-S01`, `../plan.json`, is outside its folder. What to do: keep the plan in the study's folder, as `plan.json`, and name it relative to the folder.",
526
+ ],
527
+ [
528
+ {
529
+ problem: "bad-plan",
530
+ id: "P1-S01",
531
+ path: "plan.json",
532
+ why: "unreadable",
533
+ reasons: "question: required",
534
+ },
535
+ "The plan of `P1-S01`, `plan.json` in its folder, is not a study plan: question: required. What to do: give it the fields on the rule's page — `question`, `population`, `arms`, `primary_outcome`.",
536
+ ],
537
+ [
538
+ {
539
+ problem: "bad-plan",
540
+ id: "P1-S01",
541
+ path: "plan.json",
542
+ why: "unfilled",
543
+ fields: ["question", "primary_outcome"],
544
+ status: "running",
545
+ },
546
+ "The plan of `P1-S01`, `plan.json` in its folder, leaves `question` and `primary_outcome` empty, and the study is `running`: a plan is written before the runs. What to do: fill them in, or set the study back to `designing`.",
547
+ ],
548
+ [
549
+ {
550
+ problem: "author-check",
551
+ id: "P1-S01",
552
+ measurement: "M1",
553
+ status: "running",
554
+ why: "no-plan",
555
+ },
556
+ "`P1-S01` is `running` in the headline measurement `M1`, and its row names no plan to hold the author's check of the drawn units. What to do: write its plan (`plan.json` in its folder, named in the row's `plan`) with `author_check` — `by`, `date`, and the author's words in `quote`.",
557
+ ],
558
+ [
559
+ {
560
+ problem: "author-check",
561
+ id: "P1-S01",
562
+ measurement: "M1",
563
+ status: "running",
564
+ why: "missing",
565
+ },
566
+ "`P1-S01` is `running` in the headline measurement `M1`, and its plan has no `author_check`. What to do: show the author the drawn units, then record their answer in the plan's `author_check` — `by`, `date`, and their words in `quote`.",
567
+ ],
568
+ [
569
+ {
570
+ problem: "author-check",
571
+ id: "P1-S01",
572
+ measurement: "M1",
573
+ status: "running",
574
+ why: "not-author",
575
+ by: "an agent",
576
+ author: "A. Author",
577
+ },
578
+ "`P1-S01` is `running` in the headline measurement `M1`, and the `author_check` in its plan is by an agent, not by the registry's `author`, A. Author. What to do: only the author approves the drawn units of a headline measurement; record their own answer.",
579
+ ],
580
+ ] as const;
581
+
582
+ describe("studyMessage — the new findings stand alone", () => {
583
+ it("lists more than two empty fields with commas, the last with «and»", () => {
584
+ expect(
585
+ studyMessage({
586
+ problem: "bad-plan",
587
+ id: "P1-S01",
588
+ path: "plan.json",
589
+ why: "unfilled",
590
+ fields: ["question", "population.source", "primary_outcome"],
591
+ status: "done",
592
+ }),
593
+ ).toMatch(
594
+ /leaves `question`, `population.source` and `primary_outcome` empty/u,
595
+ );
596
+ });
597
+
598
+ it("names a single empty field alone", () => {
599
+ expect(
600
+ studyMessage({
601
+ problem: "bad-plan",
602
+ id: "P1-S01",
603
+ path: "plan.json",
604
+ why: "unfilled",
605
+ fields: ["question"],
606
+ status: "done",
607
+ }),
608
+ ).toMatch(/leaves `question` empty, and/u);
609
+ });
610
+
611
+ const RULE =
612
+ "Rule repro/study-registered: https://github.com/zernie/paperlint/blob/main/docs/rules/repro/study-registered.md";
613
+
614
+ it.each(MESSAGES)("%j", (finding, head) => {
615
+ expect(studyMessage(finding)).toBe(`${head}\n${RULE}`);
616
+ });
617
+
618
+ it("a plan under its old name names plan.json, and its own rule", () => {
619
+ expect(
620
+ studyMessage({
621
+ problem: "legacy-plan",
622
+ id: "P1-S01",
623
+ path: "prereg.json",
624
+ }),
625
+ ).toBe(
626
+ "The plan of `P1-S01` is `prereg.json`, its old name; it is read as the plan. What to do: rename it to `plan.json` in the study's folder (`git mv`) and set the row's `plan` to `plan.json`.\nRule repro/study-plan-name: https://github.com/zernie/paperlint/blob/main/docs/rules/repro/study-plan-name.md",
627
+ );
628
+ });
629
+ });
@@ -58,6 +58,7 @@ const input = (
58
58
  registry: read(r),
59
59
  readme: withIndex("# Reproduction index\n", r),
60
60
  existingResults: new Set(),
61
+ plans: new Map(),
61
62
  ...over,
62
63
  });
63
64
 
@@ -96,6 +97,7 @@ describe("judgeStudies — silent", () => {
96
97
  registry: { kind: "absent" },
97
98
  readme: null,
98
99
  existingResults: new Set(),
100
+ plans: new Map(),
99
101
  }),
100
102
  ).toEqual([]);
101
103
  });
@@ -124,6 +126,7 @@ describe("judgeStudies — the registry itself", () => {
124
126
  registry: { kind: "absent" },
125
127
  readme: null,
126
128
  existingResults: new Set(),
129
+ plans: new Map(),
127
130
  }),
128
131
  ).toEqual([
129
132
  { problem: "no-registry", folders: ["a-2026-10-01", "b-2026-10-02"] },
@@ -137,6 +140,7 @@ describe("judgeStudies — the registry itself", () => {
137
140
  registry: { kind: "unreadable", why: "prefix: required" },
138
141
  readme: null,
139
142
  existingResults: new Set(),
143
+ plans: new Map(),
140
144
  }),
141
145
  ).toEqual([{ problem: "unreadable", why: "prefix: required" }]);
142
146
  });
@@ -583,7 +587,7 @@ describe("addStudy", () => {
583
587
  date: "2026-10-15",
584
588
  } as const;
585
589
 
586
- it("appends the next id, its folder, designing, not in the paper, an empty question", () => {
590
+ it("appends the next id, its folder, designing, not in the paper, an empty question, its plan", () => {
587
591
  const r = reg(study({ id: "P1-S04", folder: "P1-S04-x", foo: 1 }));
588
592
  const out = addStudy(r, { ...NEW, title: " Baseline\n pass rate " });
589
593
  const added: Study = {
@@ -595,6 +599,7 @@ describe("addStudy", () => {
595
599
  status: "designing",
596
600
  date: "2026-10-15",
597
601
  in_paper: "no",
602
+ plan: "plan.json",
598
603
  };
599
604
  expect(out).toEqual({
600
605
  ok: true,
@@ -645,6 +650,10 @@ describe("studyReadme", () => {
645
650
  "Its row in `../studies.json` holds its status. When the study ends, set `status`, `result`,",
646
651
  "`result_file` and `in_paper` there, then run `npx paperlint studies index <paper-dir>`.",
647
652
  "",
653
+ "Its plan is `plan.json`: what it measures, the population and the draw, the arms and the primary",
654
+ "outcome, filled in before the first run; the author's check of the drawn units goes in its",
655
+ "`author_check`.",
656
+ "",
648
657
  "## Question",
649
658
  "",
650
659
  "## How to run",