paperlint 10.2.0 → 10.4.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 (147) hide show
  1. package/.github/workflows/ci.yml +15 -4
  2. package/README.md +1 -0
  3. package/dist/adapters/git/index.d.ts +10 -0
  4. package/dist/adapters/git/index.d.ts.map +1 -0
  5. package/dist/adapters/git/index.js +151 -0
  6. package/dist/adapters/git/index.js.map +1 -0
  7. package/dist/adapters/memory/index.d.ts +6 -0
  8. package/dist/adapters/memory/index.d.ts.map +1 -1
  9. package/dist/adapters/memory/index.js +8 -0
  10. package/dist/adapters/memory/index.js.map +1 -1
  11. package/dist/adapters/node/index.d.ts +1 -0
  12. package/dist/adapters/node/index.d.ts.map +1 -1
  13. package/dist/adapters/node/index.js +1 -0
  14. package/dist/adapters/node/index.js.map +1 -1
  15. package/dist/adapters/node/list-entries.io.d.ts +3 -0
  16. package/dist/adapters/node/list-entries.io.d.ts.map +1 -0
  17. package/dist/adapters/node/list-entries.io.js +24 -0
  18. package/dist/adapters/node/list-entries.io.js.map +1 -0
  19. package/dist/adapters/study-registry/index.d.ts +412 -1
  20. package/dist/adapters/study-registry/index.d.ts.map +1 -1
  21. package/dist/adapters/study-registry/index.js +245 -8
  22. package/dist/adapters/study-registry/index.js.map +1 -1
  23. package/dist/cli.d.ts.map +1 -1
  24. package/dist/cli.js +38 -6
  25. package/dist/cli.js.map +1 -1
  26. package/dist/domain/decide-first.d.ts +100 -0
  27. package/dist/domain/decide-first.d.ts.map +1 -0
  28. package/dist/domain/decide-first.js +102 -0
  29. package/dist/domain/decide-first.js.map +1 -0
  30. package/dist/domain/index-columns.d.ts +45 -0
  31. package/dist/domain/index-columns.d.ts.map +1 -0
  32. package/dist/domain/index-columns.js +62 -0
  33. package/dist/domain/index-columns.js.map +1 -0
  34. package/dist/domain/study-mode.d.ts +37 -0
  35. package/dist/domain/study-mode.d.ts.map +1 -0
  36. package/dist/domain/study-mode.js +45 -0
  37. package/dist/domain/study-mode.js.map +1 -0
  38. package/dist/domain/study-registry.d.ts +253 -10
  39. package/dist/domain/study-registry.d.ts.map +1 -1
  40. package/dist/domain/study-registry.js +325 -27
  41. package/dist/domain/study-registry.js.map +1 -1
  42. package/dist/outputs-before-decision-rule.d.ts +17 -0
  43. package/dist/outputs-before-decision-rule.d.ts.map +1 -0
  44. package/dist/outputs-before-decision-rule.js +90 -0
  45. package/dist/outputs-before-decision-rule.js.map +1 -0
  46. package/dist/ports/git-history.d.ts +44 -0
  47. package/dist/ports/git-history.d.ts.map +1 -0
  48. package/dist/ports/git-history.js +2 -0
  49. package/dist/ports/git-history.js.map +1 -0
  50. package/dist/ports/list-entries.d.ts +17 -0
  51. package/dist/ports/list-entries.d.ts.map +1 -0
  52. package/dist/ports/list-entries.js +2 -0
  53. package/dist/ports/list-entries.js.map +1 -0
  54. package/dist/ports/study-registry-codec.d.ts +9 -2
  55. package/dist/ports/study-registry-codec.d.ts.map +1 -1
  56. package/dist/studies.d.ts +4 -0
  57. package/dist/studies.d.ts.map +1 -1
  58. package/dist/studies.js +55 -24
  59. package/dist/studies.js.map +1 -1
  60. package/dist/study-gate.d.ts +14 -0
  61. package/dist/study-gate.d.ts.map +1 -0
  62. package/dist/study-gate.js +95 -0
  63. package/dist/study-gate.js.map +1 -0
  64. package/dist/study-index-input.d.ts +5 -0
  65. package/dist/study-index-input.d.ts.map +1 -0
  66. package/dist/study-index-input.js +13 -0
  67. package/dist/study-index-input.js.map +1 -0
  68. package/dist/study-modes.d.ts +45 -0
  69. package/dist/study-modes.d.ts.map +1 -0
  70. package/dist/study-modes.js +70 -0
  71. package/dist/study-modes.js.map +1 -0
  72. package/dist/study-registry-rule.d.ts +29 -6
  73. package/dist/study-registry-rule.d.ts.map +1 -1
  74. package/dist/study-registry-rule.js +91 -40
  75. package/dist/study-registry-rule.js.map +1 -1
  76. package/dist/study-run.d.ts +19 -0
  77. package/dist/study-run.d.ts.map +1 -0
  78. package/dist/study-run.js +9 -0
  79. package/dist/study-run.js.map +1 -0
  80. package/docs/design/study-modes.md +247 -0
  81. package/docs/design/study-registry.md +137 -15
  82. package/docs/e2e.md +8 -4
  83. package/docs/layout.md +26 -21
  84. package/docs/rules/repro/outputs-before-decision.md +119 -0
  85. package/docs/rules/repro/study-plan-name.md +70 -0
  86. package/docs/rules/repro/study-registered.md +206 -15
  87. package/docs/rules.md +54 -52
  88. package/docs/testing.md +1 -0
  89. package/package.json +2 -1
  90. package/scripts/check.ts +6 -0
  91. package/skills/build-benchmark/SKILL.md +24 -12
  92. package/skills/build-benchmark/SKILL.md.spec.ts +23 -11
  93. package/skills/build-label-page/SKILL.md +9 -7
  94. package/skills/build-label-page/SKILL.md.spec.ts +8 -6
  95. package/skills/camera-ready/SKILL.md +6 -1
  96. package/skills/camera-ready/SKILL.md.spec.ts +5 -0
  97. package/skills/draft-paper/SKILL.md +11 -3
  98. package/skills/draft-paper/SKILL.md.spec.ts +10 -2
  99. package/skills/harden-paper/SKILL.md +12 -2
  100. package/skills/harden-paper/SKILL.md.spec.ts +11 -1
  101. package/skills/osf-artifact-upload/SKILL.md +7 -1
  102. package/skills/osf-artifact-upload/SKILL.md.spec.ts +6 -0
  103. package/skills/pc-panel-review/SKILL.md +10 -3
  104. package/skills/pc-panel-review/SKILL.md.spec.ts +9 -2
  105. package/skills/run-agent-grid/SKILL.md +10 -5
  106. package/skills/run-agent-grid/SKILL.md.spec.ts +9 -4
  107. package/skills/tighten-paper/SKILL.md +5 -1
  108. package/skills/tighten-paper/SKILL.md.spec.ts +4 -0
  109. package/src/adapters/git/history.test.ts +121 -0
  110. package/src/adapters/git/index.test.ts +268 -0
  111. package/src/adapters/git/index.ts +227 -0
  112. package/src/adapters/memory/index.test.ts +15 -1
  113. package/src/adapters/memory/index.ts +13 -0
  114. package/src/adapters/node/index.ts +1 -0
  115. package/src/adapters/node/list-entries.io.ts +26 -0
  116. package/src/adapters/node/list-entries.test.ts +43 -0
  117. package/src/adapters/study-registry/index.test.ts +343 -0
  118. package/src/adapters/study-registry/index.ts +374 -10
  119. package/src/adapters/study-registry/mode.test.ts +162 -0
  120. package/src/cli.ts +52 -6
  121. package/src/domain/decide-first.test.ts +258 -0
  122. package/src/domain/decide-first.ts +243 -0
  123. package/src/domain/index-columns.test.ts +216 -0
  124. package/src/domain/index-columns.ts +105 -0
  125. package/src/domain/study-measurements.test.ts +633 -0
  126. package/src/domain/study-mode.test.ts +55 -0
  127. package/src/domain/study-mode.ts +78 -0
  128. package/src/domain/study-registry.test.ts +48 -32
  129. package/src/domain/study-registry.ts +621 -36
  130. package/src/new-paper.test.ts +6 -1
  131. package/src/outputs-before-decision-rule.test.ts +206 -0
  132. package/src/outputs-before-decision-rule.ts +124 -0
  133. package/src/ports/git-history.ts +44 -0
  134. package/src/ports/list-entries.ts +18 -0
  135. package/src/ports/study-registry-codec.ts +13 -2
  136. package/src/rule-cases.test.ts +103 -1
  137. package/src/studies-cli.test.ts +2 -2
  138. package/src/studies-index-modes.test.ts +129 -0
  139. package/src/studies.test.ts +39 -9
  140. package/src/studies.ts +99 -29
  141. package/src/study-gate.test.ts +316 -0
  142. package/src/study-gate.ts +161 -0
  143. package/src/study-index-input.ts +23 -0
  144. package/src/study-modes.ts +120 -0
  145. package/src/study-registry-rule.test.ts +333 -7
  146. package/src/study-registry-rule.ts +145 -41
  147. package/src/study-run.ts +21 -0
@@ -10,12 +10,22 @@
10
10
  * ── JSON, NEVER MARKDOWN ─────────────────────────────────────────────────────────
11
11
  * The registry is data: its text is parsed once, where it enters, against the schema in
12
12
  * `src/adapters/study-registry/` (`StudyRegistryCodec`). The index in `repro/README.md` is OUTPUT: it is
13
- * generated from the registry and compared as a string, between two fixed markers. Nothing parses it.
13
+ * generated from the registry, the plans' modes and git's history (src/domain/index-columns.ts), and
14
+ * compared cell by cell between two fixed markers, the two columns read from git left out. Nothing
15
+ * else parses it.
14
16
  *
15
17
  * Pure: the caller lists `repro/`, reads the registry and the README, and says which result files exist.
16
18
  */
17
19
  import { isAbsolute, posix, win32 } from "node:path";
18
20
  import { STUDIES_FILE, STUDY_SLUG } from "./repro-layout.ts";
21
+ import { needsMode, type StudyMode } from "./study-mode.ts";
22
+ import {
23
+ decisionCell,
24
+ firstOutputCell,
25
+ modeCell,
26
+ type IndexFacts,
27
+ type IndexHistory,
28
+ } from "./index-columns.ts";
19
29
 
20
30
  /** The registry, at the top of the paper's `repro/`; the layout allows it there. */
21
31
  export { STUDIES_FILE };
@@ -61,6 +71,55 @@ export const INDEX_END = "<!-- studies:end -->";
61
71
  /** The README written when `repro/` has none. */
62
72
  const INDEX_HEADING = "# Reproduction index";
63
73
 
74
+ /** What a measurement is to the paper: the claim it carries, or what that claim is compared with. */
75
+ export const MEASUREMENT_ROLES = ["headline", "baseline"] as const;
76
+ export type MeasurementRole = (typeof MEASUREMENT_ROLES)[number];
77
+
78
+ /**
79
+ * The statuses of a study that has run: its plan was due before the first run. A `designing` study
80
+ * has not run yet, and a `dropped` one never will.
81
+ */
82
+ export const RAN_STATUSES: readonly StudyStatus[] = [
83
+ "running",
84
+ "done",
85
+ "stopped",
86
+ "superseded",
87
+ ];
88
+
89
+ /** A requirement of the author's on the paper's measurements, in their words. */
90
+ export interface Requirement {
91
+ readonly id: string;
92
+ /** The author's words, verbatim. */
93
+ readonly text: string;
94
+ readonly date: string;
95
+ /** Where they said it: a chat, a message, a review comment. */
96
+ readonly source: string;
97
+ }
98
+
99
+ /** Someone's approval, in their words: of a measurement, or of a study's drawn units. */
100
+ export interface Approval {
101
+ readonly by: string;
102
+ readonly date: string;
103
+ readonly quote: string;
104
+ readonly source?: string | undefined;
105
+ }
106
+
107
+ /**
108
+ * One measurement: what the paper measures, made of one or more studies (a pilot, the main run, a
109
+ * replication) whose rows name it in `measurement`. A measurement may carry more fields.
110
+ */
111
+ export interface Measurement {
112
+ readonly id: string;
113
+ readonly title: string;
114
+ /** The research question it answers. */
115
+ readonly rq?: string | undefined;
116
+ readonly role: MeasurementRole;
117
+ readonly approval?: Approval | undefined;
118
+ /** The ids of the requirements it must meet. */
119
+ readonly requirements?: readonly string[] | undefined;
120
+ readonly [extra: string]: unknown;
121
+ }
122
+
64
123
  /**
65
124
  * One study. The named fields are the registry's; a row may carry more (a paper merges its own
66
125
  * measurement metadata into it), and they are kept as they are.
@@ -77,14 +136,155 @@ export interface Study {
77
136
  readonly result_file?: string | undefined;
78
137
  readonly superseded_by?: string | undefined;
79
138
  readonly in_paper: InPaper;
139
+ /** The id of the measurement the study belongs to. */
140
+ readonly measurement?: string | undefined;
141
+ /** The study's plan: a path inside its folder, `plan.json`. */
142
+ readonly plan?: string | undefined;
80
143
  readonly [extra: string]: unknown;
81
144
  }
82
145
 
83
146
  export interface StudyRegistry {
84
147
  readonly prefix: string;
148
+ /** The paper's author: the one person whose approval makes a measurement the headline. */
149
+ readonly author?: string | undefined;
150
+ readonly requirements?: readonly Requirement[] | undefined;
151
+ /** When present, every row's `measurement` names one of them. */
152
+ readonly measurements?: readonly Measurement[] | undefined;
85
153
  readonly studies: readonly Study[];
86
154
  }
87
155
 
156
+ /** The file `paperlint studies new` writes a study's plan to, beside its README.md. */
157
+ export const PLAN_FILE = "plan.json";
158
+
159
+ /** The plan's name before it was a plan: still read as one, and reported by `repro/study-plan-name`. */
160
+ export const LEGACY_PLAN_FILE = "prereg.json";
161
+
162
+ /** The author's check of a study's drawn units, before the runs. */
163
+ export type AuthorCheck = Omit<Approval, "source">;
164
+
165
+ /**
166
+ * A study's plan, fixed before its runs: what it measures, on what population and how it is drawn,
167
+ * its arms, its primary outcome, and the author's check of the drawn units. Closed: a key the paper
168
+ * needs of its own goes in `extra`, so a misspelt key is an error, not a key nobody reads. "Not
169
+ * known" and "not yet" have one spelling each: null.
170
+ */
171
+ export interface StudyPlan {
172
+ readonly question: string;
173
+ readonly population: {
174
+ /** Where the units come from: the census, crawl or registry, with its date. */
175
+ readonly source: string;
176
+ readonly draw: Draw;
177
+ };
178
+ /** The conditions compared, each name once; none for a census. */
179
+ readonly arms: readonly { readonly name: string }[];
180
+ readonly primary_outcome: string;
181
+ /** null: the author has not checked the drawn units yet. Only a ready plan carries one. */
182
+ readonly author_check: AuthorCheck | null;
183
+ /** The paper's own keys: hypotheses, models, budget … Kept, never read; none is a plan field. */
184
+ readonly extra?: Readonly<Record<string, unknown>> | undefined;
185
+ /**
186
+ * How the outcomes are kept from the decisions on the inputs: decided before the run, or
187
+ * exploratory (src/domain/study-mode.ts). A measurement study's plan is not ready without it.
188
+ */
189
+ readonly mode?: StudyMode | undefined;
190
+ }
191
+
192
+ /**
193
+ * How the sample is drawn from the population: a seeded draw, whose seed is part of it, or all of
194
+ * the population (a census), which has none. The size of the population is not stored: it is
195
+ * counted from the source, and a stored copy would only contradict it.
196
+ */
197
+ export type Draw =
198
+ | {
199
+ readonly kind: "seeded";
200
+ /** `random`, `stratified` … */
201
+ readonly method: string;
202
+ readonly seed: string | number;
203
+ }
204
+ | {
205
+ readonly kind: "all";
206
+ /** `census` … */
207
+ readonly method: string;
208
+ };
209
+
210
+ /** The top-level fields of a plan: none of them may be a key of `extra`. */
211
+ export const PLAN_FIELDS = [
212
+ "question",
213
+ "population",
214
+ "arms",
215
+ "primary_outcome",
216
+ "author_check",
217
+ "extra",
218
+ "mode",
219
+ ] as const;
220
+
221
+ /** A plan with empty required texts: the author has not approved it. */
222
+ export type DraftPlan = StudyPlan & { readonly author_check: null };
223
+
224
+ /** The texts a plan must fill before the study runs, in the order they are named. */
225
+ export const PLAN_TEXT_FIELDS = [
226
+ "question",
227
+ "population.source",
228
+ "population.draw.method",
229
+ "primary_outcome",
230
+ "mode",
231
+ "mode.outputs",
232
+ "mode.decision.file",
233
+ "mode.decision.note",
234
+ "mode.reason",
235
+ ] as const;
236
+ export type PlanTextField = (typeof PLAN_TEXT_FIELDS)[number];
237
+
238
+ /**
239
+ * A plan's text, parsed: ready (every required text filled), a draft (its empty texts named, and
240
+ * no check of the author's: only a complete plan is approved), or the reasons it is not a plan. Whether a plan is complete is decided here, once, by the parser.
241
+ */
242
+ export type ParsedPlan =
243
+ | { readonly kind: "ready"; readonly plan: StudyPlan }
244
+ | {
245
+ readonly kind: "draft";
246
+ readonly plan: DraftPlan;
247
+ readonly empty: readonly PlanTextField[];
248
+ }
249
+ | { readonly kind: "unreadable"; readonly why: string };
250
+
251
+ /** What a study's folder holds of the plan its row names: absent, or what its text parsed to. */
252
+ export type PlanState = { readonly kind: "absent" } | ParsedPlan;
253
+
254
+ /** The decision file `paperlint studies new` names for a study in `folder`, relative to the paper. */
255
+ export const decisionPath = (folder: string): string =>
256
+ `repro/${folder}/${DECISION_FILE}`;
257
+
258
+ /** The decision file's name, beside the plan. */
259
+ export const DECISION_FILE = "decision.md";
260
+
261
+ /**
262
+ * The plan `paperlint studies new` writes for a study of `kind` in `folder`: every field present, its
263
+ * value to fill. Its draw is of all the population (a census); a sampled study changes it to
264
+ * `{ "kind": "seeded", "method", "seed" }`. A study that must declare its mode gets the default one,
265
+ * decided before the run, its decision file named and its outputs and note to fill.
266
+ */
267
+ export const planTemplate = (kind: StudyKind, folder: string): DraftPlan => ({
268
+ question: "",
269
+ population: { source: "", draw: { kind: "all", method: "" } },
270
+ arms: [],
271
+ primary_outcome: "",
272
+ author_check: null,
273
+ ...(needsMode(kind)
274
+ ? {
275
+ mode: {
276
+ kind: "decide_before_run",
277
+ outputs: [],
278
+ decision: { file: decisionPath(folder), note: "" },
279
+ },
280
+ }
281
+ : {}),
282
+ });
283
+
284
+ /** A plan's text as `paperlint studies new` writes it: JSON, two-space indented, a final newline. */
285
+ export const planText = (p: StudyPlan): string =>
286
+ `${JSON.stringify(p, null, 2)}\n`;
287
+
88
288
  /** A registry's text, parsed: the registry, or the one reason it is not one. */
89
289
  export type ParsedRegistry =
90
290
  | { readonly ok: true; readonly registry: StudyRegistry }
@@ -105,6 +305,8 @@ export interface StudyInput {
105
305
  readonly readme: string | null;
106
306
  /** Each `<folder>/<result_file>` of the registry that is a file. */
107
307
  readonly existingResults: ReadonlySet<string>;
308
+ /** The plan each row names, by `<folder>/<plan>`; one not listed is absent. */
309
+ readonly plans: ReadonlyMap<string, PlanState>;
108
310
  }
109
311
 
110
312
  /** One defect of the registry, of a row, or of the index. */
@@ -157,8 +359,79 @@ export type StudyFinding =
157
359
  | {
158
360
  readonly problem: "stale-index";
159
361
  readonly why: "no-readme" | "no-markers" | "differs";
362
+ }
363
+ | {
364
+ readonly problem: "unknown-measurement";
365
+ readonly id: string;
366
+ readonly measurement: string;
367
+ }
368
+ | { readonly problem: "empty-measurement"; readonly measurement: string }
369
+ | {
370
+ readonly problem: "unknown-requirement";
371
+ readonly measurement: string;
372
+ readonly requirement: string;
373
+ }
374
+ | {
375
+ readonly problem: "headline-approval";
376
+ readonly measurement: string;
377
+ readonly why: "missing";
378
+ readonly author: string | null;
379
+ }
380
+ | {
381
+ readonly problem: "headline-approval";
382
+ readonly measurement: string;
383
+ readonly why: "not-author";
384
+ readonly by: string;
385
+ readonly author: string;
386
+ }
387
+ | {
388
+ readonly problem: "bad-plan";
389
+ readonly id: string;
390
+ readonly path: string;
391
+ readonly why: "missing" | "outside";
392
+ }
393
+ | {
394
+ readonly problem: "bad-plan";
395
+ readonly id: string;
396
+ readonly path: string;
397
+ readonly why: "unreadable";
398
+ readonly reasons: string;
399
+ }
400
+ | {
401
+ readonly problem: "bad-plan";
402
+ readonly id: string;
403
+ readonly path: string;
404
+ readonly why: "unfilled";
405
+ readonly fields: readonly PlanTextField[];
406
+ readonly status: StudyStatus;
407
+ }
408
+ | {
409
+ readonly problem: "author-check";
410
+ readonly id: string;
411
+ readonly measurement: string;
412
+ readonly status: StudyStatus;
413
+ readonly why: "no-plan" | "missing";
414
+ }
415
+ | {
416
+ readonly problem: "author-check";
417
+ readonly id: string;
418
+ readonly measurement: string;
419
+ readonly status: StudyStatus;
420
+ readonly why: "not-author";
421
+ readonly by: string;
422
+ readonly author: string;
423
+ }
424
+ | {
425
+ readonly problem: "legacy-plan";
426
+ readonly id: string;
427
+ readonly path: string;
160
428
  };
161
429
 
430
+ /** The findings `repro/study-plan-name` reports, a warning; every other is `repro/study-registered`'s. */
431
+ export const PLAN_NAME_PROBLEMS: readonly StudyFinding["problem"][] = [
432
+ "legacy-plan",
433
+ ];
434
+
162
435
  /** The number of an id of `prefix` (`P1-S07` → 7); null for an id of another shape. */
163
436
  function idNumber(prefix: string, id: string): number | null {
164
437
  const m = new RegExp(`^${prefix}-S(\\d{2,})$`, "u").exec(id);
@@ -191,13 +464,42 @@ export const titleWords = (title: string): number =>
191
464
  * Whether `path` stays inside the folder it is relative to. A backslash or a Windows absolute path
192
465
  * is refused outright: `posix.normalize` does not resolve `..\\`, while the host's `join` may.
193
466
  */
194
- const isInside = (path: string): boolean => {
467
+ export const isInside = (path: string): boolean => {
195
468
  if (path.includes("\\") || isAbsolute(path) || win32.isAbsolute(path))
196
469
  return false;
197
470
  const n = posix.normalize(path);
198
471
  return n !== ".." && !n.startsWith("../");
199
472
  };
200
473
 
474
+ /**
475
+ * Whether `folder` names one folder directly inside `repro/`: not empty, not `.` or `..`, and no
476
+ * separator or NUL. Every command and rule that reads a study's folder reads only such a one: a row
477
+ * naming `../x` would have them read and write outside the paper.
478
+ */
479
+ export const isFolderName = (folder: string): boolean =>
480
+ folder !== "." && folder !== ".." && /^[^/\\\0]+$/u.test(folder);
481
+
482
+ /**
483
+ * The `<folder>/<plan>` of each row whose plan lies inside its folder: the files the judge needs read
484
+ * (`StudyInput.plans`). A plan outside its folder is reported without being read.
485
+ */
486
+ export const planKeys = (r: StudyRegistry): readonly string[] =>
487
+ planRefs(r).map((p) => p.key);
488
+
489
+ /** Each plan `planKeys` names, with the kind of the study whose row names it: what its parser needs. */
490
+ export const planRefs = (
491
+ r: StudyRegistry,
492
+ ): readonly {
493
+ readonly key: string;
494
+ readonly folder: string;
495
+ readonly kind: StudyKind;
496
+ }[] =>
497
+ r.studies.flatMap((s) =>
498
+ s.plan !== undefined && isInside(s.plan) && isFolderName(s.folder)
499
+ ? [{ key: `${s.folder}/${s.plan}`, folder: s.folder, kind: s.kind }]
500
+ : [],
501
+ );
502
+
201
503
  /** One row of a registry, with what the judge read beside it. */
202
504
  interface Row {
203
505
  readonly r: StudyRegistry;
@@ -283,6 +585,102 @@ const noSuccessor: RowCheck = ({ r, s }) => {
283
585
  : null;
284
586
  };
285
587
 
588
+ /** A row's `measurement`, when the registry declares its measurements, names one of them. */
589
+ const unknownMeasurement: RowCheck = ({ r, s }) =>
590
+ r.measurements === undefined ||
591
+ s.measurement === undefined ||
592
+ r.measurements.some((m) => m.id === s.measurement)
593
+ ? null
594
+ : { problem: "unknown-measurement", id: s.id, measurement: s.measurement };
595
+
596
+ /** The state of the plan the row names (absent when outside its folder); null when it names none. */
597
+ const planOf = ({ s, input }: Row): PlanState | null => {
598
+ if (s.plan === undefined) return null;
599
+ return isInside(s.plan)
600
+ ? (input.plans.get(`${s.folder}/${s.plan}`) ?? { kind: "absent" })
601
+ : { kind: "absent" };
602
+ };
603
+
604
+ const hasRun = (s: Study): boolean => RAN_STATUSES.includes(s.status);
605
+
606
+ /** The finding for the plan `path` of `s`, in the state its folder holds it; null when it stands. */
607
+ const planFinding = (
608
+ s: Study,
609
+ path: string,
610
+ state: PlanState,
611
+ ): StudyFinding | null => {
612
+ const at = { problem: "bad-plan", id: s.id, path } as const;
613
+ switch (state.kind) {
614
+ case "absent":
615
+ return { ...at, why: "missing" };
616
+ case "unreadable":
617
+ return { ...at, why: "unreadable", reasons: state.why };
618
+ case "draft":
619
+ return hasRun(s)
620
+ ? { ...at, why: "unfilled", fields: state.empty, status: s.status }
621
+ : null;
622
+ case "ready":
623
+ return null;
624
+ }
625
+ };
626
+
627
+ /** The plan a row names is a file inside its folder, of the schema, and ready once the study has run. */
628
+ const badPlan: RowCheck = ({ s, input }) => {
629
+ const path = s.plan;
630
+ if (path === undefined) return null;
631
+ if (!isInside(path))
632
+ return { problem: "bad-plan", id: s.id, path, why: "outside" };
633
+ return planFinding(
634
+ s,
635
+ path,
636
+ input.plans.get(`${s.folder}/${path}`) ?? { kind: "absent" },
637
+ );
638
+ };
639
+
640
+ /** A plan under its old name: read, and named for renaming. */
641
+ const legacyPlan: RowCheck = ({ s }) =>
642
+ s.plan !== undefined && posix.basename(s.plan) === LEGACY_PLAN_FILE
643
+ ? { problem: "legacy-plan", id: s.id, path: s.plan }
644
+ : null;
645
+
646
+ /**
647
+ * A study of a headline measurement that has run carries the author's check of its drawn units in
648
+ * its plan, by the registry's author when it names one. A plan that cannot be read is `badPlan`'s.
649
+ */
650
+ const authorCheck: RowCheck = (row) => {
651
+ const { r, s } = row;
652
+ const m = r.measurements?.find((o) => o.id === s.measurement);
653
+ if (m?.role !== "headline" || !hasRun(s)) return null;
654
+ const at = {
655
+ problem: "author-check",
656
+ id: s.id,
657
+ measurement: m.id,
658
+ status: s.status,
659
+ } as const;
660
+ const state = planOf(row);
661
+ if (state === null) return { ...at, why: "no-plan" };
662
+ const wrong =
663
+ state.kind === "ready" || state.kind === "draft"
664
+ ? checkProblem(r, state.plan)
665
+ : null;
666
+ return wrong === null ? null : { ...at, ...wrong };
667
+ };
668
+
669
+ /** What a read plan's author check lacks: it is not there, or it is not the registry's author's. */
670
+ const checkProblem = (
671
+ r: StudyRegistry,
672
+ plan: StudyPlan,
673
+ ):
674
+ | { readonly why: "missing" }
675
+ | { readonly why: "not-author"; readonly by: string; readonly author: string }
676
+ | null => {
677
+ const check = plan.author_check;
678
+ if (check === null) return { why: "missing" };
679
+ return r.author === undefined || check.by === r.author
680
+ ? null
681
+ : { why: "not-author", by: check.by, author: r.author };
682
+ };
683
+
286
684
  /** The checks of a row, in the order their findings are reported. */
287
685
  const ROW_CHECKS: readonly RowCheck[] = [
288
686
  badId,
@@ -294,8 +692,58 @@ const ROW_CHECKS: readonly RowCheck[] = [
294
692
  noResult,
295
693
  resultFile,
296
694
  noSuccessor,
695
+ unknownMeasurement,
696
+ badPlan,
697
+ legacyPlan,
698
+ authorCheck,
297
699
  ];
298
700
 
701
+ /** A headline measurement's approval: missing, or not the registry's author's; null when it stands. */
702
+ const headlineApproval = (
703
+ r: StudyRegistry,
704
+ m: Measurement,
705
+ ): StudyFinding | null => {
706
+ if (m.role !== "headline") return null;
707
+ const author = r.author ?? null;
708
+ if (m.approval === undefined)
709
+ return {
710
+ problem: "headline-approval",
711
+ measurement: m.id,
712
+ why: "missing",
713
+ author,
714
+ };
715
+ return author === null || m.approval.by === author
716
+ ? null
717
+ : {
718
+ problem: "headline-approval",
719
+ measurement: m.id,
720
+ why: "not-author",
721
+ by: m.approval.by,
722
+ author,
723
+ };
724
+ };
725
+
726
+ /** What is wrong with one measurement: no study, a requirement not held, a headline not approved. */
727
+ const judgeMeasurement = (
728
+ r: StudyRegistry,
729
+ m: Measurement,
730
+ ): readonly StudyFinding[] => {
731
+ const approval = headlineApproval(r, m);
732
+ return [
733
+ ...(r.studies.some((s) => s.measurement === m.id)
734
+ ? []
735
+ : [{ problem: "empty-measurement", measurement: m.id } as const]),
736
+ ...(m.requirements ?? [])
737
+ .filter((id) => !(r.requirements ?? []).some((q) => q.id === id))
738
+ .map((requirement): StudyFinding => ({
739
+ problem: "unknown-requirement",
740
+ measurement: m.id,
741
+ requirement,
742
+ })),
743
+ ...(approval === null ? [] : [approval]),
744
+ ];
745
+ };
746
+
299
747
  /** What is wrong with a row, in a fixed order. */
300
748
  const judgeRow = (row: Row): readonly StudyFinding[] =>
301
749
  ROW_CHECKS.map((check) => check(row)).filter(
@@ -306,11 +754,12 @@ const judgeRow = (row: Row): readonly StudyFinding[] =>
306
754
  function judgeIndex(
307
755
  r: StudyRegistry,
308
756
  readme: string | null,
757
+ plans: ReadonlyMap<string, PlanState>,
309
758
  ): StudyFinding | null {
310
759
  if (readme === null) return { problem: "stale-index", why: "no-readme" };
311
760
  const block = embeddedIndex(readme);
312
761
  if (block === null) return { problem: "stale-index", why: "no-markers" };
313
- return indexInSync(readme, r)
762
+ return indexInSync(readme, r, { plans, history: new Map() })
314
763
  ? null
315
764
  : { problem: "stale-index", why: "differs" };
316
765
  }
@@ -329,7 +778,7 @@ export function judgeStudies(input: StudyInput): readonly StudyFinding[] {
329
778
  return [{ problem: "unreadable", why: state.why }];
330
779
  const r = state.registry;
331
780
  const next = nextStudyId(r);
332
- const index = judgeIndex(r, input.readme);
781
+ const index = judgeIndex(r, input.readme, input.plans);
333
782
  return [
334
783
  ...input.folders
335
784
  .filter((f) => !r.studies.some((s) => s.folder === f))
@@ -339,6 +788,7 @@ export function judgeStudies(input: StudyInput): readonly StudyFinding[] {
339
788
  next,
340
789
  })),
341
790
  ...r.studies.flatMap((s, i) => judgeRow({ r, s, i, input })),
791
+ ...(r.measurements ?? []).flatMap((m) => judgeMeasurement(r, m)),
342
792
  ...(index === null ? [] : [index]),
343
793
  ];
344
794
  }
@@ -349,19 +799,65 @@ export function judgeStudies(input: StudyInput): readonly StudyFinding[] {
349
799
  const cell = (s: string): string =>
350
800
  s.replace(/\r?\n/gu, " ").replace(/\\/gu, "\\\\").replace(/\|/gu, "\\|");
351
801
 
352
- /** The columns of the index: each one's header, and its cell for a study. */
802
+ /**
803
+ * What the index is generated from beyond the registry: the plans by `<folder>/<plan>` (as
804
+ * `StudyInput.plans`), and the history of each decide-first study by id. A study with no history
805
+ * listed shows `not read` — the lint, which never compares those columns, asks none.
806
+ */
807
+ export interface IndexInput {
808
+ readonly plans: ReadonlyMap<string, PlanState>;
809
+ readonly history: ReadonlyMap<string, IndexHistory>;
810
+ }
811
+
812
+ /** The mode the plan of `s` declares; null when it names none, or the plan is absent or unreadable. */
813
+ const declaredMode = (
814
+ s: Study,
815
+ plans: ReadonlyMap<string, PlanState>,
816
+ ): StudyMode | null => {
817
+ const state =
818
+ s.plan !== undefined && isInside(s.plan)
819
+ ? plans.get(`${s.folder}/${s.plan}`)
820
+ : undefined;
821
+ return state === undefined ||
822
+ state.kind === "absent" ||
823
+ state.kind === "unreadable"
824
+ ? null
825
+ : (state.plan.mode ?? null);
826
+ };
827
+
828
+ /** What the index shows of `s` beyond its row. */
829
+ const factsOf = (s: Study, at: IndexInput): IndexFacts => ({
830
+ mode: declaredMode(s, at.plans),
831
+ history: at.history.get(s.id) ?? null,
832
+ });
833
+
834
+ /**
835
+ * The columns of the index: each one's header, its cell for a study, and whether git's history is
836
+ * where the cell comes from — those two are never compared (src/domain/index-columns.ts).
837
+ */
353
838
  const COLUMNS: readonly {
354
839
  readonly head: string;
355
- readonly of: (s: Study) => string;
840
+ readonly of: (s: Study, f: IndexFacts) => string;
841
+ readonly fromGit: boolean;
356
842
  }[] = [
357
- { head: "id", of: (s) => s.id },
358
- { head: "title", of: (s) => cell(s.title) },
359
- { head: "status", of: (s) => s.status },
360
- { head: "result", of: (s) => cell(s.result ?? "—") },
361
- { head: "in paper", of: (s) => s.in_paper },
362
- { head: "folder", of: (s) => `[${s.folder}/](${s.folder}/)` },
843
+ { head: "id", of: (s) => s.id, fromGit: false },
844
+ { head: "title", of: (s) => cell(s.title), fromGit: false },
845
+ { head: "status", of: (s) => s.status, fromGit: false },
846
+ { head: "result", of: (s) => cell(s.result ?? "—"), fromGit: false },
847
+ { head: "in paper", of: (s) => s.in_paper, fromGit: false },
848
+ { head: "mode", of: (_, f) => cell(modeCell(f)), fromGit: false },
849
+ { head: "decision", of: (_, f) => decisionCell(f), fromGit: true },
850
+ { head: "first output", of: (_, f) => firstOutputCell(f), fromGit: true },
851
+ {
852
+ head: "folder",
853
+ of: (s) => `[${s.folder}/](${s.folder}/)`,
854
+ fromGit: false,
855
+ },
363
856
  ];
364
857
 
858
+ /** The positions of the columns read from git: left out when an index is compared. */
859
+ const FROM_GIT = COLUMNS.flatMap((c, i) => (c.fromGit ? [i] : []));
860
+
365
861
  /** The width a cell takes: its code points. */
366
862
  const widthOf = (s: string): number => Array.from(s).length;
367
863
 
@@ -374,20 +870,23 @@ const padded = (t: string, width: number): string =>
374
870
  * widest cell (at least 3), the way prettier writes a table — so formatting the README leaves it as
375
871
  * it is.
376
872
  */
377
- export function studyIndex(r: StudyRegistry): string {
873
+ export function studyIndex(r: StudyRegistry, at: IndexInput): string {
874
+ const studies = r.studies.map((s) => ({ s, f: factsOf(s, at) }));
378
875
  const sized = COLUMNS.map((c) => ({
379
876
  ...c,
380
877
  width: Math.max(
381
878
  3,
382
879
  widthOf(c.head),
383
- ...r.studies.map((s) => widthOf(c.of(s))),
880
+ ...studies.map(({ s, f }) => widthOf(c.of(s, f))),
384
881
  ),
385
882
  }));
386
883
  const line = (cells: readonly string[]): string => `| ${cells.join(" | ")} |`;
387
884
  return [
388
885
  line(sized.map((c) => padded(c.head, c.width))),
389
886
  line(sized.map((c) => "-".repeat(c.width))),
390
- ...r.studies.map((s) => line(sized.map((c) => padded(c.of(s), c.width)))),
887
+ ...studies.map(({ s, f }) =>
888
+ line(sized.map((c) => padded(c.of(s, f), c.width))),
889
+ ),
391
890
  ].join("\n");
392
891
  }
393
892
 
@@ -396,7 +895,7 @@ export function studyIndex(r: StudyRegistry): string {
396
895
  * `---`. The index is judged on this, because the padding belongs to the formatter: prettier sizes a
397
896
  * column by display width, so a wide character pads differently from a code-point count.
398
897
  */
399
- const cellsOf = (table: string): string =>
898
+ const cellsOf = (table: string, skip: readonly number[]): string =>
400
899
  table
401
900
  .trim()
402
901
  .split("\n")
@@ -406,19 +905,44 @@ const cellsOf = (table: string): string =>
406
905
  .replace(/^\||\|$/gu, "")
407
906
  .split(/(?<!\\)\|/u)
408
907
  .map((c) => c.trim().replace(/^:?-{3,}:?$/u, "---"))
908
+ .filter((_, i) => !skip.includes(i))
409
909
  .join("|"),
410
910
  )
411
911
  .join("\n");
412
912
 
413
- /** Whether `readme` holds the index of `r`, whatever padding a formatter gave its table. */
414
- export function indexInSync(readme: string | null, r: StudyRegistry): boolean {
913
+ /** Whether `readme` holds the index of `r` in every column but those at `skip`, whatever its padding. */
914
+ const sameCells = (
915
+ readme: string | null,
916
+ index: string,
917
+ skip: readonly number[],
918
+ ): boolean => {
415
919
  const block = readme === null ? null : embeddedIndex(readme);
416
- return block !== null && cellsOf(block) === cellsOf(studyIndex(r));
417
- }
920
+ return block !== null && cellsOf(block, skip) === cellsOf(index, skip);
921
+ };
922
+
923
+ /**
924
+ * Whether `readme` holds the index of `r`, whatever padding a formatter gave its table and whatever
925
+ * its two columns read from git say: what `repro/study-registered` checks.
926
+ */
927
+ export const indexInSync = (
928
+ readme: string | null,
929
+ r: StudyRegistry,
930
+ at: IndexInput,
931
+ ): boolean => sameCells(readme, studyIndex(r, at), FROM_GIT);
932
+
933
+ /**
934
+ * Whether `readme` holds the index of `r` in every column, whatever its padding: when it does,
935
+ * `studies index` leaves the README as it is, so the padding a formatter gave the table stays.
936
+ */
937
+ export const indexCurrent = (
938
+ readme: string | null,
939
+ r: StudyRegistry,
940
+ at: IndexInput,
941
+ ): boolean => sameCells(readme, studyIndex(r, at), []);
418
942
 
419
943
  /** The index between its markers, a blank line on each side. */
420
- export const indexBlock = (r: StudyRegistry): string =>
421
- `${INDEX_BEGIN}\n\n${studyIndex(r)}\n\n${INDEX_END}`;
944
+ export const indexBlock = (r: StudyRegistry, at: IndexInput): string =>
945
+ `${INDEX_BEGIN}\n\n${studyIndex(r, at)}\n\n${INDEX_END}`;
422
946
 
423
947
  /**
424
948
  * Where `marker` stands alone on a line of `text`, at or after `from`; -1 when it does not. A marker
@@ -442,13 +966,18 @@ export function embeddedIndex(readme: string): string | null {
442
966
  * `readme` with its index regenerated from `r`: the block replaced in place, appended when the README
443
967
  * has none, a README of its own when there is none.
444
968
  */
445
- export function withIndex(readme: string | null, r: StudyRegistry): string {
446
- if (readme === null) return `${INDEX_HEADING}\n\n${indexBlock(r)}\n`;
969
+ export function withIndex(
970
+ readme: string | null,
971
+ r: StudyRegistry,
972
+ at: IndexInput,
973
+ ): string {
974
+ const fresh = indexBlock(r, at);
975
+ if (readme === null) return `${INDEX_HEADING}\n\n${fresh}\n`;
447
976
  const block = embeddedIndex(readme);
448
- if (block === null) return `${readme.trimEnd()}\n\n${indexBlock(r)}\n`;
977
+ if (block === null) return `${readme.trimEnd()}\n\n${fresh}\n`;
449
978
  const begin = markerAt(readme, INDEX_BEGIN);
450
979
  const after = begin + INDEX_BEGIN.length + block.length + INDEX_END.length;
451
- return `${readme.slice(0, begin)}${indexBlock(r)}${readme.slice(after)}`;
980
+ return `${readme.slice(0, begin)}${fresh}${readme.slice(after)}`;
452
981
  }
453
982
 
454
983
  /** The registry's text as `paperlint studies new` writes it: JSON, two-space indented, a final newline. */
@@ -466,6 +995,14 @@ const RENAME =
466
995
 
467
996
  const tick = (s: string): string => `\`${s}\``;
468
997
 
998
+ /** `a`, `b` and `c`: names in backticks, the last joined by "and". */
999
+ const listed = (names: readonly string[]): string => {
1000
+ const t = names.map(tick);
1001
+ return [t.slice(0, -1).join(", "), ...t.slice(-1)]
1002
+ .filter((part) => part !== "")
1003
+ .join(" and ");
1004
+ };
1005
+
469
1006
  /** Each finding by its problem: the map the messages are looked up in. */
470
1007
  type FindingOf = {
471
1008
  readonly [F in StudyFinding as F["problem"]]: F;
@@ -506,15 +1043,58 @@ const HEADS: { readonly [P in Problem]: (f: FindingOf[P]) => string } = {
506
1043
  "no-successor": (f) =>
507
1044
  `${REGISTRY}: ${tick(f.id)} is \`superseded\` ${f.superseded_by === null ? "without `superseded_by`" : `by ${tick(f.superseded_by)}, which is no study in the registry`}. What to do: name the id of the study that replaced it.`,
508
1045
  "stale-index": (f) => `${STALE_HEAD[f.why]} ${RUN_INDEX}`,
1046
+ "unknown-measurement": (f) =>
1047
+ `${REGISTRY}: the row ${tick(f.id)} names the measurement ${tick(f.measurement)}, which is not in \`measurements\`. What to do: add the measurement to \`measurements\`, or correct the row's \`measurement\`.`,
1048
+ "empty-measurement": (f) =>
1049
+ `${REGISTRY}: no row names the measurement ${tick(f.measurement)}, and a measurement is made of studies. What to do: set \`"measurement": "${f.measurement}"\` on the rows of its studies, or remove the measurement.`,
1050
+ "unknown-requirement": (f) =>
1051
+ `${REGISTRY}: the measurement ${tick(f.measurement)} must meet the requirement ${tick(f.requirement)}, 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\`.`,
1052
+ "headline-approval": (f) =>
1053
+ f.why === "missing"
1054
+ ? `${REGISTRY}: the measurement ${tick(f.measurement)} is a \`headline\` one and has no \`approval\`. What to do: record ${f.author === null ? "the author's approval" : `the approval of ${f.author}, the registry's \`author\``} — \`by\`, \`date\`, and their words in \`quote\` — or make it a \`baseline\`.`
1055
+ : `${REGISTRY}: the measurement ${tick(f.measurement)} is a \`headline\` one, and its \`approval\` is by ${f.by}, not by the registry's \`author\`, ${f.author}. What to do: only the author approves a headline measurement; record their own approval.`,
1056
+ "bad-plan": (f) => {
1057
+ const of = `the \`plan\` of ${tick(f.id)}, ${tick(f.path)},`;
1058
+ const plan = `The plan of ${tick(f.id)}, ${tick(f.path)} in its folder,`;
1059
+ switch (f.why) {
1060
+ case "missing":
1061
+ return `${REGISTRY}: ${of} 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\`.`;
1062
+ case "outside":
1063
+ return `${REGISTRY}: ${of} is outside its folder. What to do: keep the plan in the study's folder, as \`${PLAN_FILE}\`, and name it relative to the folder.`;
1064
+ case "unreadable":
1065
+ return `${plan} is not a study plan: ${f.reasons}. What to do: give it the fields on the rule's page — \`question\`, \`population\`, \`arms\`, \`primary_outcome\`, and for a measurement its \`mode\`.`;
1066
+ case "unfilled":
1067
+ return `${plan} leaves ${listed(f.fields)} empty, and the study is ${tick(f.status)}: a plan is written before the runs. What to do: fill them in, or set the study back to \`designing\`.`;
1068
+ }
1069
+ },
1070
+ "author-check": (f) => {
1071
+ const head = `${tick(f.id)} is ${tick(f.status)} in the headline measurement ${tick(f.measurement)}, and`;
1072
+ switch (f.why) {
1073
+ case "no-plan":
1074
+ return `${head} its row names no plan to hold the author's check of the drawn units. What to do: write its plan (\`${PLAN_FILE}\` in its folder, named in the row's \`plan\`) with \`author_check\` — \`by\`, \`date\`, and the author's words in \`quote\`.`;
1075
+ case "missing":
1076
+ return `${head} 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\`.`;
1077
+ case "not-author":
1078
+ return `${head} the \`author_check\` in its plan is by ${f.by}, not by the registry's \`author\`, ${f.author}. What to do: only the author approves the drawn units of a headline measurement; record their own answer.`;
1079
+ }
1080
+ },
1081
+ "legacy-plan": (f) =>
1082
+ `The plan of ${tick(f.id)} is ${tick(f.path)}, its old name; it is read as the plan. What to do: rename it to \`${PLAN_FILE}\` in the study's folder (\`git mv\`) and set the row's \`plan\` to \`${PLAN_FILE}\`.`,
509
1083
  };
510
1084
 
511
1085
  /** The head of finding `f` of problem `p`: the lookup, typed by the problem. */
512
1086
  const headOf = <P extends Problem>(p: P, f: FindingOf[P]): string =>
513
1087
  HEADS[p](f);
514
1088
 
1089
+ /** The rule that reports finding `f`. */
1090
+ const ruleOf = (f: StudyFinding): string =>
1091
+ PLAN_NAME_PROBLEMS.includes(f.problem)
1092
+ ? "repro/study-plan-name"
1093
+ : "repro/study-registered";
1094
+
515
1095
  /** The one text a finding is reported with: what is wrong, what to do, and the rule's page. */
516
1096
  export const studyMessage = (f: StudyFinding): string =>
517
- `${headOf(f.problem, f)}\nRule repro/study-registered: https://github.com/zernie/paperlint/blob/main/docs/rules/repro/study-registered.md`;
1097
+ `${headOf(f.problem, f)}\nRule ${ruleOf(f)}: https://github.com/zernie/paperlint/blob/main/docs/rules/${ruleOf(f)}.md`;
518
1098
 
519
1099
  // ── a new study ───────────────────────────────────────────────────────────────────
520
1100
 
@@ -541,20 +1121,20 @@ function titleProblem(title: string): string | null {
541
1121
  : null;
542
1122
  }
543
1123
 
544
- /**
545
- * `r` with a new study appended: the next id, the folder `<id>-<slug>`, `status: designing`,
546
- * `in_paper: no` and an empty question; or why the title cannot make one.
547
- */
548
- export function addStudy(
549
- r: StudyRegistry,
550
- n: NewStudy,
551
- ):
1124
+ /** A registry with a study added, and the study; or why it could not be. */
1125
+ export type Added =
552
1126
  | {
553
1127
  readonly ok: true;
554
1128
  readonly registry: StudyRegistry;
555
1129
  readonly study: Study;
556
1130
  }
557
- | { readonly ok: false; readonly why: string } {
1131
+ | { readonly ok: false; readonly why: string };
1132
+
1133
+ /**
1134
+ * `r` with a new study appended: the next id, the folder `<id>-<slug>`, `status: designing`,
1135
+ * `in_paper: no`, an empty question and its plan, `plan.json`; or why the title cannot make one.
1136
+ */
1137
+ export function addStudy(r: StudyRegistry, n: NewStudy): Added {
558
1138
  const title = n.title.trim().replace(/\s+/gu, " ");
559
1139
  const why = titleProblem(title);
560
1140
  if (why !== null) return { ok: false, why };
@@ -568,6 +1148,7 @@ export function addStudy(
568
1148
  status: "designing",
569
1149
  date: n.date,
570
1150
  in_paper: "no",
1151
+ plan: PLAN_FILE,
571
1152
  };
572
1153
  return {
573
1154
  ok: true,
@@ -589,6 +1170,10 @@ export const studyReadme = (s: Study): string =>
589
1170
  `Its row in \`../${STUDIES_FILE}\` holds its status. When the study ends, set \`status\`, \`result\`,`,
590
1171
  "`result_file` and `in_paper` there, then run `npx paperlint studies index <paper-dir>`.",
591
1172
  "",
1173
+ `Its plan is \`${PLAN_FILE}\`: what it measures, the population and the draw, the arms and the primary`,
1174
+ "outcome, filled in before the first run; the author's check of the drawn units goes in its",
1175
+ "`author_check`.",
1176
+ "",
592
1177
  "## Question",
593
1178
  "",
594
1179
  "## How to run",