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
@@ -5,7 +5,7 @@ context: fork
5
5
  allowed-tools: [Read, Write, Edit, Grep, Glob, Bash, Agent]
6
6
  ---
7
7
 
8
- <!-- vigiles:sha256:93d83b56c6c1baea compiled from skills/build-benchmark/SKILL.md.spec.ts -->
8
+ <!-- vigiles:sha256:ed855c5f3aa34b10 compiled from skills/build-benchmark/SKILL.md.spec.ts -->
9
9
 
10
10
  # build-benchmark — the study + the artifact reviewers can run
11
11
 
@@ -44,10 +44,19 @@ Every study gets its folder, its id and its row from one command, never from a f
44
44
  npx paperlint studies new <paper-dir> "<plain title, at most 8 words>" --kind measurement
45
45
  ```
46
46
 
47
- It takes the next id in `repro/studies.json`, creates `repro/<id>-<slug>/README.md`, appends the row
48
- (`status: designing`) and regenerates the index in `repro/README.md`. The kinds: `measurement`,
49
- `spike`, `tooling`, `data`, `artifact`, `salvage`. Write the study's one-sentence `question` into its
50
- row at once.
47
+ It takes the next id in `repro/studies.json`, creates `repro/<id>-<slug>/README.md` and the study's
48
+ plan, `plan.json`, appends the row (`status: designing`, `plan: "plan.json"`) and regenerates the index
49
+ in `repro/README.md`. The kinds: `measurement`, `spike`, `tooling`, `data`, `artifact`, `salvage`.
50
+ Write the study's one-sentence `question` into its row at once. Never write a plan by hand under
51
+ another name: the template's fields are the ones `repro/study-registered` reads.
52
+
53
+ **A measurement that spans studies** (a pilot, the main run, a replication) is declared once, in
54
+ `measurements` at the top of `repro/studies.json` — `id`, `title`, `rq`, `role` (`headline` or
55
+ `baseline`) — and each of its studies' rows names it in `measurement`. The author's requirements on
56
+ the measurements go, verbatim, into `requirements` (`id`, `text`, `date`, `source`), and each
57
+ measurement lists the ones it must meet; never in a separate note. A `headline` measurement carries
58
+ the author's `approval` (`by` the registry's `author`, `date`, their words in `quote`), written
59
+ from the author's own message, never by the agent that designed the study.
51
60
 
52
61
  As the study moves, edit its row in `repro/studies.json`: `running` when the first counted run starts;
53
62
  when it ends, `done` with `result` (one line) and `result_file` (the file inside the folder the result
@@ -73,13 +82,16 @@ Write the design as the answer to "what would make the critiqued pitfall impossi
73
82
  **Declare the population, the screens and the coverage before freezing the sample.** Each screen
74
83
  between the population and the sample (fits one excerpt, one classifier lane, a model-picked item)
75
84
  changes what the sample can say, not only its size; screens that are reasonable one by one can leave a
76
- sample nobody meets in practice. So, before the draw is frozen, the study's `prereg.json` says what
77
- the claim is about, what each screen removed per stratum and what share the sample can represent; then
78
- show the author the drawn **units** (the rules or cases, not the items made from them) and ask: *is
79
- this what you meet in practice?* The fields are the experimental rule
80
- `study/declare-sample` (`docs/rules/study/declare-sample.md`), off by default: do this step either way,
81
- and turn the check on with `"rules": { "study/declare-sample": "error" }` in the paper's `paperlint.json`
82
- for `paperlint lint` to fail until they are there.
85
+ sample nobody meets in practice. So, before the draw is frozen, fill the study's `plan.json`: the
86
+ `question`, the `population` (its `source`, and the `draw`: `{ "kind": "all", "method" }` for a census,
87
+ or `{ "kind": "seeded", "method", "seed" }` for a sample),
88
+ the `arms` (`[]` for a census) and the `primary_outcome`; what each screen removed per stratum and
89
+ what share the sample can represent go into the same file, under `extra` (the plan is closed: any
90
+ other unknown key is an error). Then show the
91
+ author the drawn **units** (the rules or cases, not the items made from them), ask *is this what you
92
+ meet in practice?*, and record the answer in the plan's `author_check` (`by`, `date`, their words in
93
+ `quote`). Once the study is `running`, `repro/study-registered` fails the lint on an empty plan field,
94
+ and, for a study of a `headline` measurement, on a plan without the author's check.
83
95
 
84
96
  ## 2. Honest statistics — the part reviewers attack first
85
97
 
@@ -50,10 +50,19 @@ Every study gets its folder, its id and its row from one command, never from a f
50
50
  npx paperlint studies new <paper-dir> "<plain title, at most 8 words>" --kind measurement
51
51
  \`\`\`
52
52
 
53
- It takes the next id in \`repro/studies.json\`, creates \`repro/<id>-<slug>/README.md\`, appends the row
54
- (\`status: designing\`) and regenerates the index in \`repro/README.md\`. The kinds: \`measurement\`,
55
- \`spike\`, \`tooling\`, \`data\`, \`artifact\`, \`salvage\`. Write the study's one-sentence \`question\` into its
56
- row at once.
53
+ It takes the next id in \`repro/studies.json\`, creates \`repro/<id>-<slug>/README.md\` and the study's
54
+ plan, \`plan.json\`, appends the row (\`status: designing\`, \`plan: "plan.json"\`) and regenerates the index
55
+ in \`repro/README.md\`. The kinds: \`measurement\`, \`spike\`, \`tooling\`, \`data\`, \`artifact\`, \`salvage\`.
56
+ Write the study's one-sentence \`question\` into its row at once. Never write a plan by hand under
57
+ another name: the template's fields are the ones \`repro/study-registered\` reads.
58
+
59
+ **A measurement that spans studies** (a pilot, the main run, a replication) is declared once, in
60
+ \`measurements\` at the top of \`repro/studies.json\` — \`id\`, \`title\`, \`rq\`, \`role\` (\`headline\` or
61
+ \`baseline\`) — and each of its studies' rows names it in \`measurement\`. The author's requirements on
62
+ the measurements go, verbatim, into \`requirements\` (\`id\`, \`text\`, \`date\`, \`source\`), and each
63
+ measurement lists the ones it must meet; never in a separate note. A \`headline\` measurement carries
64
+ the author's \`approval\` (\`by\` the registry's \`author\`, \`date\`, their words in \`quote\`), written
65
+ from the author's own message, never by the agent that designed the study.
57
66
 
58
67
  As the study moves, edit its row in \`repro/studies.json\`: \`running\` when the first counted run starts;
59
68
  when it ends, \`done\` with \`result\` (one line) and \`result_file\` (the file inside the folder the result
@@ -79,13 +88,16 @@ Write the design as the answer to "what would make the critiqued pitfall impossi
79
88
  **Declare the population, the screens and the coverage before freezing the sample.** Each screen
80
89
  between the population and the sample (fits one excerpt, one classifier lane, a model-picked item)
81
90
  changes what the sample can say, not only its size; screens that are reasonable one by one can leave a
82
- sample nobody meets in practice. So, before the draw is frozen, the study's \`prereg.json\` says what
83
- the claim is about, what each screen removed per stratum and what share the sample can represent; then
84
- show the author the drawn **units** (the rules or cases, not the items made from them) and ask: *is
85
- this what you meet in practice?* The fields are the experimental rule
86
- \`study/declare-sample\` (\`docs/rules/study/declare-sample.md\`), off by default: do this step either way,
87
- and turn the check on with \`"rules": { "study/declare-sample": "error" }\` in the paper's \`paperlint.json\`
88
- for \`paperlint lint\` to fail until they are there.
91
+ sample nobody meets in practice. So, before the draw is frozen, fill the study's \`plan.json\`: the
92
+ \`question\`, the \`population\` (its \`source\`, and the \`draw\`: \`{ "kind": "all", "method" }\` for a census,
93
+ or \`{ "kind": "seeded", "method", "seed" }\` for a sample),
94
+ the \`arms\` (\`[]\` for a census) and the \`primary_outcome\`; what each screen removed per stratum and
95
+ what share the sample can represent go into the same file, under \`extra\` (the plan is closed: any
96
+ other unknown key is an error). Then show the
97
+ author the drawn **units** (the rules or cases, not the items made from them), ask *is this what you
98
+ meet in practice?*, and record the answer in the plan's \`author_check\` (\`by\`, \`date\`, their words in
99
+ \`quote\`). Once the study is \`running\`, \`repro/study-registered\` fails the lint on an empty plan field,
100
+ and, for a study of a \`headline\` measurement, on a plan without the author's check.
89
101
 
90
102
  ## 2. Honest statistics — the part reviewers attack first
91
103
 
@@ -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:7a537e71585bd321 compiled from skills/build-label-page/SKILL.md.spec.ts -->
7
+ <!-- vigiles:sha256:dd742c1fcb34cfd6 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
 
@@ -49,15 +49,17 @@ The full text, each with its reason and its check, is
49
49
  fit the time they have on a phone? Who is the labeller: a page with a store opens only for people
50
50
  with access to the artifact, so the labeller is the owner or has been shared as a Contributor.
51
51
  If the study design is not written yet, `build-benchmark` comes first.
52
- If the items come from a drawn sample, the study's `prereg.json` declares the population, the
53
- screens and the coverage before the sample is frozen (the experimental rule `study/declare-sample` checks it, off by default), and the
54
- author has been shown the drawn **units** (the rules or cases, not the items) with "is this what
55
- you meet in practice?". The labelling page is not that check: items made from easy units each look
56
- fine on their own.
52
+ If the items come from a drawn sample, the study's `plan.json` (written by
53
+ `paperlint studies new`) declares the population and its draw before the sample is frozen, and its
54
+ `author_check` records that the author has been shown the drawn **units** (the rules or cases, not
55
+ the items) with "is this what you meet in practice?". The labelling page is not that check: items
56
+ made from easy units each look fine on their own.
57
57
  2. **Copy the generator into the study and write the items.** The study folder is
58
58
  `<paper-dir>/repro/<id>-<slug>/`, made by `npx paperlint studies new <paper-dir> "Gold labels"
59
59
  --kind data` when the labelling is a study of its own (`P1-S04-gold-labels`): the command writes
60
- its `README.md` and its row in `repro/studies.json`, and `repro/study-registered` checks both. When
60
+ its `README.md`, its `plan.json` and its row in `repro/studies.json`, and `repro/study-registered`
61
+ checks them; fill the plan (what is labelled, the items' source, `arms: []`, the primary outcome)
62
+ before the first label. When
61
63
  the labels are in, update the row (`status`, `result`, `result_file`) and run
62
64
  `npx paperlint studies index <paper-dir>`. Where this sits in stage 3, and what checks each place:
63
65
  `docs/layout.md` in the paperlint package.
@@ -59,15 +59,17 @@ The full text, each with its reason and its check, is
59
59
  fit the time they have on a phone? Who is the labeller: a page with a store opens only for people
60
60
  with access to the artifact, so the labeller is the owner or has been shared as a Contributor.
61
61
  If the study design is not written yet, \`build-benchmark\` comes first.
62
- If the items come from a drawn sample, the study's \`prereg.json\` declares the population, the
63
- screens and the coverage before the sample is frozen (the experimental rule \`study/declare-sample\` checks it, off by default), and the
64
- author has been shown the drawn **units** (the rules or cases, not the items) with "is this what
65
- you meet in practice?". The labelling page is not that check: items made from easy units each look
66
- fine on their own.
62
+ If the items come from a drawn sample, the study's \`plan.json\` (written by
63
+ \`paperlint studies new\`) declares the population and its draw before the sample is frozen, and its
64
+ \`author_check\` records that the author has been shown the drawn **units** (the rules or cases, not
65
+ the items) with "is this what you meet in practice?". The labelling page is not that check: items
66
+ made from easy units each look fine on their own.
67
67
  2. **Copy the generator into the study and write the items.** The study folder is
68
68
  \`<paper-dir>/repro/<id>-<slug>/\`, made by \`npx paperlint studies new <paper-dir> "Gold labels"
69
69
  --kind data\` when the labelling is a study of its own (\`P1-S04-gold-labels\`): the command writes
70
- its \`README.md\` and its row in \`repro/studies.json\`, and \`repro/study-registered\` checks both. When
70
+ its \`README.md\`, its \`plan.json\` and its row in \`repro/studies.json\`, and \`repro/study-registered\`
71
+ checks them; fill the plan (what is labelled, the items' source, \`arms: []\`, the primary outcome)
72
+ before the first label. When
71
73
  the labels are in, update the row (\`status\`, \`result\`, \`result_file\`) and run
72
74
  \`npx paperlint studies index <paper-dir>\`. Where this sits in stage 3, and what checks each place:
73
75
  \`docs/layout.md\` in the paperlint package.
@@ -4,7 +4,7 @@ description: "Run a paid or counted agent experiment grid end to end — units
4
4
  allowed-tools: [Read, Write, Edit, Grep, Glob, Bash, Agent]
5
5
  ---
6
6
 
7
- <!-- vigiles:sha256:522a747e8eb6ead4 compiled from skills/run-agent-grid/SKILL.md.spec.ts -->
7
+ <!-- vigiles:sha256:451354b8f781c409 compiled from skills/run-agent-grid/SKILL.md.spec.ts -->
8
8
 
9
9
  # run-agent-grid — running a counted agent grid without spoiling it
10
10
 
@@ -25,10 +25,15 @@ and a driver loop that calls the guard: [`references/registry-and-ledger.md`](re
25
25
 
26
26
  **The grid's study folder.** A grid is one study of the paper. Its folder is made by
27
27
  `npx paperlint studies new <paper-dir> "<title>" --kind measurement` — the next id in
28
- `repro/studies.json`, the folder `repro/<id>-<slug>/` — and `grid.json`, the runs and the ledger live
29
- in it. When the grid ends, its row is updated (`status`, `result`, `result_file`, `in_paper`) and
30
- `npx paperlint studies index <paper-dir>` regenerates the index; `repro/study-registered` fails the
31
- lint until both are done.
28
+ `repro/studies.json`, the folder `repro/<id>-<slug>/` with the study's `plan.json` — and `grid.json`,
29
+ the runs and the ledger live in it. The plan says what the grid measures, the population and its
30
+ draw (`{ "kind": "seeded", "method", "seed" }`), the `arms` (each name once) and the `primary_outcome`, and is filled before the first counted
31
+ run; the author's approval of the drawn units goes into both `grid.json`'s `approval` (the guard reads
32
+ it) and the plan's `author_check` (the lint reads it). A grid that is one study of a larger
33
+ measurement names it in its row's `measurement`. When the grid ends, its row is updated (`status`,
34
+ `result`, `result_file`, `in_paper`) and `npx paperlint studies index <paper-dir>` regenerates the
35
+ index; `repro/study-registered` fails the lint until both are done, and on a running grid whose plan
36
+ is empty.
32
37
 
33
38
  ## The order — each gate is passed before the next phase starts
34
39
 
@@ -26,10 +26,15 @@ and a driver loop that calls the guard: [\`references/registry-and-ledger.md\`](
26
26
 
27
27
  **The grid's study folder.** A grid is one study of the paper. Its folder is made by
28
28
  \`npx paperlint studies new <paper-dir> "<title>" --kind measurement\` — the next id in
29
- \`repro/studies.json\`, the folder \`repro/<id>-<slug>/\` — and \`grid.json\`, the runs and the ledger live
30
- in it. When the grid ends, its row is updated (\`status\`, \`result\`, \`result_file\`, \`in_paper\`) and
31
- \`npx paperlint studies index <paper-dir>\` regenerates the index; \`repro/study-registered\` fails the
32
- lint until both are done.
29
+ \`repro/studies.json\`, the folder \`repro/<id>-<slug>/\` with the study's \`plan.json\` — and \`grid.json\`,
30
+ the runs and the ledger live in it. The plan says what the grid measures, the population and its
31
+ draw (\`{ "kind": "seeded", "method", "seed" }\`), the \`arms\` (each name once) and the \`primary_outcome\`, and is filled before the first counted
32
+ run; the author's approval of the drawn units goes into both \`grid.json\`'s \`approval\` (the guard reads
33
+ it) and the plan's \`author_check\` (the lint reads it). A grid that is one study of a larger
34
+ measurement names it in its row's \`measurement\`. When the grid ends, its row is updated (\`status\`,
35
+ \`result\`, \`result_file\`, \`in_paper\`) and \`npx paperlint studies index <paper-dir>\` regenerates the
36
+ index; \`repro/study-registered\` fails the lint until both are done, and on a running grid whose plan
37
+ is empty.
33
38
 
34
39
  ## The order — each gate is passed before the next phase starts
35
40
 
@@ -4,6 +4,8 @@
4
4
  */
5
5
  import { describe, expect, it } from "vitest";
6
6
  import {
7
+ planTemplate,
8
+ planText,
7
9
  registryText,
8
10
  type StudyRegistry,
9
11
  } from "../../domain/study-registry.ts";
@@ -142,3 +144,341 @@ describe("refusing a row that is not a study", () => {
142
144
  });
143
145
  });
144
146
  });
147
+
148
+ /** A registry with every key above the studies. */
149
+ const FULL = {
150
+ prefix: "P1",
151
+ author: "A. Author",
152
+ requirements: [
153
+ {
154
+ id: "R1",
155
+ text: "Every sample is a random draw from all rules found.",
156
+ date: "2026-10-10",
157
+ source: "chat",
158
+ },
159
+ ],
160
+ measurements: [
161
+ {
162
+ id: "M1",
163
+ title: "Rule census",
164
+ rq: "RQ1",
165
+ role: "headline",
166
+ approval: {
167
+ by: "A. Author",
168
+ date: "2026-10-12",
169
+ quote: "This is the one.",
170
+ source: "review comment",
171
+ },
172
+ requirements: ["R1"],
173
+ frame: "all rules found",
174
+ },
175
+ { id: "M2", title: "Baseline", role: "baseline" },
176
+ ],
177
+ studies: [{ ...ROW, measurement: "M1", plan: "plan.json" }],
178
+ };
179
+
180
+ describe("the keys above the studies: author, requirements, measurements", () => {
181
+ it("a registry with all of them parses to itself, a measurement's extra fields kept", () => {
182
+ expect(parse(text(FULL))).toEqual({ ok: true, registry: FULL });
183
+ });
184
+ });
185
+
186
+ describe("refusing what is not a measurement, a requirement or a plan path", () => {
187
+ it.each([
188
+ [
189
+ "a role that is neither",
190
+ { ...FULL, measurements: [{ id: "M1", title: "x", role: "main" }] },
191
+ 'measurements.0.role: Invalid option: expected one of "headline"|"baseline"',
192
+ ],
193
+ [
194
+ "an approval without the author's words",
195
+ {
196
+ ...FULL,
197
+ measurements: [
198
+ {
199
+ id: "M1",
200
+ title: "x",
201
+ role: "headline",
202
+ approval: { by: "A", date: "2026-10-12" },
203
+ },
204
+ ],
205
+ },
206
+ "measurements.0.approval.quote: Invalid input: expected string, received undefined",
207
+ ],
208
+ [
209
+ "a requirement with a key of its own",
210
+ { ...FULL, requirements: [{ ...FULL.requirements[0], note: "x" }] },
211
+ 'requirements.0: Unrecognized key: "note"',
212
+ ],
213
+ [
214
+ "a row's plan that is not a path",
215
+ { ...FULL, studies: [{ ...ROW, plan: true }] },
216
+ "studies.0.plan: Invalid input: expected string, received boolean",
217
+ ],
218
+ ])("refuses %s", (_why, v, why) => {
219
+ expect(parse(text(v))).toEqual({ ok: false, why });
220
+ });
221
+ });
222
+
223
+ /** A filled plan of a seeded draw, with keys of the paper's own in `extra`. */
224
+ const parsePlan = studyRegistryCodec.parsePlan;
225
+ const PLAN = {
226
+ question: "How many rules can a linter check?",
227
+ population: {
228
+ source: "the crawl of 2026-10-01",
229
+ draw: { kind: "seeded", method: "random", seed: 7 },
230
+ },
231
+ arms: [{ name: "with the linter" }, { name: "without" }],
232
+ primary_outcome: "the share of rules checked",
233
+ author_check: { by: "A. Author", date: "2026-10-14", quote: "Go." },
234
+ extra: { hypotheses: ["H1"], draw_script: "tools/draw.py" },
235
+ };
236
+
237
+ /** `PLAN` with `over` merged in, as the text of a file. */
238
+ const planWith = (over: Readonly<Record<string, unknown>>): string =>
239
+ JSON.stringify({ ...PLAN, ...over });
240
+
241
+ describe("parsing a study's plan: ready or draft", () => {
242
+ it("a filled plan is ready, its `extra` kept as it is", () => {
243
+ expect(parsePlan(JSON.stringify(PLAN))).toEqual({
244
+ kind: "ready",
245
+ plan: PLAN,
246
+ });
247
+ });
248
+
249
+ it("a census draws all: no seed", () => {
250
+ const census = {
251
+ ...PLAN,
252
+ population: {
253
+ source: "every rule found",
254
+ draw: { kind: "all", method: "census" },
255
+ },
256
+ arms: [],
257
+ };
258
+ expect(parsePlan(JSON.stringify(census))).toEqual({
259
+ kind: "ready",
260
+ plan: census,
261
+ });
262
+ });
263
+
264
+ it("the template studies new writes is a draft of a census, naming every empty text", () => {
265
+ expect(planTemplate().population.draw).toEqual({ kind: "all", method: "" });
266
+ expect(parsePlan(planText(planTemplate()))).toEqual({
267
+ kind: "draft",
268
+ plan: planTemplate(),
269
+ empty: [
270
+ "question",
271
+ "population.source",
272
+ "population.draw.method",
273
+ "primary_outcome",
274
+ ],
275
+ });
276
+ });
277
+
278
+ it("a text of white space only is empty", () => {
279
+ const plan = { ...PLAN, primary_outcome: " ", author_check: null };
280
+ expect(parsePlan(JSON.stringify(plan))).toEqual({
281
+ kind: "draft",
282
+ plan,
283
+ empty: ["primary_outcome"],
284
+ });
285
+ });
286
+ });
287
+
288
+ describe("parsing a study's plan: no contradictory state", () => {
289
+ it.each([
290
+ [
291
+ "an author_check on a draft: the author approved a plan with empty texts",
292
+ planWith({ question: "", primary_outcome: " " }),
293
+ "author_check: is set, and the plan leaves `question`, `primary_outcome` empty — the author approves a complete plan: fill them, or set author_check to null",
294
+ ],
295
+ [
296
+ "a seeded draw without its seed",
297
+ planWith({
298
+ population: {
299
+ source: "x",
300
+ draw: { kind: "seeded", method: "random" },
301
+ },
302
+ }),
303
+ "population.draw.seed: Invalid input",
304
+ ],
305
+ [
306
+ "a seeded draw whose seed is null",
307
+ planWith({
308
+ population: {
309
+ source: "x",
310
+ draw: { kind: "seeded", method: "random", seed: null },
311
+ },
312
+ }),
313
+ "population.draw.seed: Invalid input",
314
+ ],
315
+ [
316
+ "a draw of all that carries a seed",
317
+ planWith({
318
+ population: {
319
+ source: "x",
320
+ draw: { kind: "all", method: "census", seed: 7 },
321
+ },
322
+ }),
323
+ 'population.draw: Unrecognized key: "seed"',
324
+ ],
325
+ [
326
+ "a draw of no known kind",
327
+ planWith({
328
+ population: { source: "x", draw: { method: "census" } },
329
+ }),
330
+ "population.draw.kind: Invalid discriminator value. Expected 'seeded' | 'all'",
331
+ ],
332
+ ])("refuses %s", (_why, t, why) => {
333
+ expect(parsePlan(t)).toEqual({ kind: "unreadable", why });
334
+ });
335
+ });
336
+
337
+ describe("parsing a study's plan: no name or fact said twice", () => {
338
+ it.each([
339
+ [
340
+ "two arms of one name",
341
+ planWith({ arms: [{ name: "a" }, { name: "b" }, { name: "a" }] }),
342
+ "arms: more than one arm is named «a» — an arm's name is unique",
343
+ ],
344
+ [
345
+ "a key in `extra` that is a field of the plan",
346
+ planWith({ extra: { hypotheses: ["H1"], question: "x" } }),
347
+ "extra.question: is a field of the plan itself — set it there, not in extra",
348
+ ],
349
+ [
350
+ "a population size: it is derived, never stored",
351
+ planWith({ population: { ...PLAN.population, size: 4210 } }),
352
+ 'population: Unrecognized key: "size"',
353
+ ],
354
+ ])("refuses %s", (_why, t, why) => {
355
+ expect(parsePlan(t)).toEqual({ kind: "unreadable", why });
356
+ });
357
+ });
358
+
359
+ describe("parsing a study's plan: strict at every level", () => {
360
+ it.each([
361
+ ["text that is not JSON", "{", "not JSON"],
362
+ [
363
+ "a misspelt key at the top",
364
+ planWith({ primary_outcom: "x" }),
365
+ '(top): Unrecognized key: "primary_outcom"',
366
+ ],
367
+ [
368
+ "a misspelt key in the draw",
369
+ planWith({
370
+ population: {
371
+ ...PLAN.population,
372
+ draw: { kind: "seeded", method: "random", seed: 7, sed: 7 },
373
+ },
374
+ }),
375
+ 'population.draw: Unrecognized key: "sed"',
376
+ ],
377
+ [
378
+ "a key of the paper's own outside `extra`",
379
+ planWith({ hypotheses: ["H1"] }),
380
+ '(top): Unrecognized key: "hypotheses"',
381
+ ],
382
+ [
383
+ "no author_check: null is the one way to say none yet",
384
+ planWith({ author_check: undefined }),
385
+ "author_check: Invalid input: expected object, received undefined",
386
+ ],
387
+ [
388
+ "an author_check with an empty quote",
389
+ planWith({ author_check: { ...PLAN.author_check, quote: "" } }),
390
+ "author_check.quote: Too small: expected string to have >=1 characters",
391
+ ],
392
+ [
393
+ "an arm with a key besides its name",
394
+ planWith({ arms: [{ name: "a", model: "m" }] }),
395
+ 'arms.0: Unrecognized key: "model"',
396
+ ],
397
+ ])("refuses %s", (_why, t, why) => {
398
+ expect(parsePlan(t)).toEqual({ kind: "unreadable", why });
399
+ });
400
+ });
401
+
402
+ /** A plan under its old name, written by hand: keys of its own, a size, no seed, no check. */
403
+ const parseLegacyPlan = studyRegistryCodec.parseLegacyPlan;
404
+ const OLD = {
405
+ question: "How many rules can a linter check?",
406
+ population: {
407
+ source: "the crawl of 2026-10-01",
408
+ about: "rules in instruction files",
409
+ size: 4210,
410
+ draw: { method: "random", script: "tools/draw.py" },
411
+ },
412
+ arms: [{ name: "with the linter", model: "m" }],
413
+ primary_outcome: "",
414
+ hypotheses: ["H1"],
415
+ strata: { by: "type" },
416
+ };
417
+
418
+ describe("parsing a plan under its old name, prereg.json: loose", () => {
419
+ it("keys of its own accepted, the top-level ones kept in `extra`; size dropped; no seed and no check: a draw of all, null", () => {
420
+ expect(parseLegacyPlan(JSON.stringify(OLD))).toEqual({
421
+ kind: "draft",
422
+ plan: {
423
+ question: "How many rules can a linter check?",
424
+ population: {
425
+ source: "the crawl of 2026-10-01",
426
+ draw: { kind: "all", method: "random" },
427
+ },
428
+ arms: [{ name: "with the linter" }],
429
+ primary_outcome: "",
430
+ author_check: null,
431
+ extra: { hypotheses: ["H1"], strata: { by: "type" } },
432
+ },
433
+ empty: ["primary_outcome"],
434
+ });
435
+ });
436
+ });
437
+
438
+ describe("parsing a plan under its old name, prereg.json: its draw", () => {
439
+ it("a seed, and a check: a seeded draw, the check kept", () => {
440
+ const old = {
441
+ ...OLD,
442
+ population: {
443
+ source: "s",
444
+ draw: { method: "random", seed: "a1" },
445
+ },
446
+ primary_outcome: "share",
447
+ author_check: { by: "A. Author", date: "2026-10-14", quote: "Go." },
448
+ };
449
+ expect(parseLegacyPlan(JSON.stringify(old))).toMatchObject({
450
+ kind: "ready",
451
+ plan: {
452
+ population: {
453
+ source: "s",
454
+ draw: { kind: "seeded", method: "random", seed: "a1" },
455
+ },
456
+ author_check: { by: "A. Author", date: "2026-10-14", quote: "Go." },
457
+ },
458
+ });
459
+ });
460
+
461
+ it("a seed of null is a draw of all", () => {
462
+ const old = {
463
+ ...OLD,
464
+ population: { source: "s", draw: { method: "census", seed: null } },
465
+ };
466
+ expect(parseLegacyPlan(JSON.stringify(old))).toMatchObject({
467
+ plan: { population: { draw: { kind: "all", method: "census" } } },
468
+ });
469
+ });
470
+
471
+ it("still needs the plan's own fields", () => {
472
+ expect(parseLegacyPlan(JSON.stringify({ hypotheses: ["H1"] }))).toEqual({
473
+ kind: "unreadable",
474
+ why: "question: Invalid input: expected string, received undefined; population: Invalid input: expected object, received undefined; arms: Invalid input: expected array, received undefined; primary_outcome: Invalid input: expected string, received undefined",
475
+ });
476
+ });
477
+
478
+ it("text that is not JSON", () => {
479
+ expect(parseLegacyPlan("{")).toEqual({
480
+ kind: "unreadable",
481
+ why: "not JSON",
482
+ });
483
+ });
484
+ });