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
@@ -5,16 +5,45 @@
5
5
  *
6
6
  * A row is a loose object: a paper merges its own measurement metadata into its rows (`role`, `frame`,
7
7
  * `rules_under_test`, `outcome` …), and those fields are kept as they are. The top level is strict, so
8
- * a misspelt key is an error rather than a key nobody reads.
8
+ * a misspelt key is an error rather than a key nobody reads. A measurement keeps its extra fields as a
9
+ * row does; a requirement and an approval are exactly their fields.
10
+ *
11
+ * A study's plan (`plan.json`) is parsed here too, strictly: a key of the paper's own goes in `extra`.
12
+ * Its texts may be empty — the template's are — and the parser says which: a plan comes out ready or
13
+ * a draft naming its empty texts, so nothing downstream looks at a text again. A plan still named
14
+ * `prereg.json` has a loose schema of its own.
15
+ *
16
+ * A plan's `mode` (docs/design/study-modes.md) is parsed here too, strictly, a union on its `kind`:
17
+ * one mode at most, a glob that leaves the study's folder or a decision outside the paper refused,
18
+ * and a measurement's plan ready only with a mode.
9
19
  */
10
20
  import { z } from "zod";
21
+ import { README } from "../../domain/repro-layout.ts";
11
22
  import {
12
23
  IN_PAPER,
24
+ MEASUREMENT_ROLES,
25
+ PLAN_FIELDS,
26
+ PLAN_TEXT_FIELDS,
13
27
  PREFIX_PATTERN,
14
28
  STUDY_KINDS,
15
29
  STUDY_STATUSES,
30
+ type Approval,
31
+ type AuthorCheck,
32
+ type Measurement,
33
+ type ParsedPlan,
16
34
  type ParsedRegistry,
35
+ type Requirement,
36
+ type Study,
37
+ type PlanTextField,
38
+ type StudyKind,
39
+ type StudyPlan,
40
+ type StudyRegistry,
17
41
  } from "../../domain/study-registry.ts";
42
+ import {
43
+ needsMode,
44
+ type Decision,
45
+ type StudyMode,
46
+ } from "../../domain/study-mode.ts";
18
47
  import type { StudyRegistryCodec } from "../../ports/study-registry-codec.ts";
19
48
 
20
49
  const study = z.looseObject({
@@ -29,15 +58,323 @@ const study = z.looseObject({
29
58
  result_file: z.string().optional(),
30
59
  superseded_by: z.string().optional(),
31
60
  in_paper: z.enum(IN_PAPER),
61
+ measurement: z.string().optional(),
62
+ plan: z.string().optional(),
63
+ });
64
+
65
+ const words = z.string().min(1);
66
+
67
+ const requirement = z.strictObject({
68
+ id: words,
69
+ text: words,
70
+ date: z.iso.date(),
71
+ source: words,
72
+ });
73
+
74
+ const approval = z.strictObject({
75
+ by: words,
76
+ date: z.iso.date(),
77
+ quote: words,
78
+ source: words.optional(),
79
+ });
80
+
81
+ const measurement = z.looseObject({
82
+ id: words,
83
+ title: words,
84
+ rq: z.string().optional(),
85
+ role: z.enum(MEASUREMENT_ROLES),
86
+ approval: approval.optional(),
87
+ requirements: z.array(z.string()).optional(),
32
88
  });
33
89
 
34
90
  const registry = z.strictObject({
35
91
  prefix: z
36
92
  .string()
37
93
  .regex(PREFIX_PATTERN, "a capital letter, then capitals or digits (P1)"),
94
+ author: words.optional(),
95
+ requirements: z.array(requirement).optional(),
96
+ measurements: z.array(measurement).optional(),
38
97
  studies: z.array(study),
39
98
  });
40
99
 
100
+ const authorCheck = approval.omit({ source: true });
101
+
102
+ /** A draw: seeded, its seed part of it, or of all the population, with none. */
103
+ const draw = z.discriminatedUnion("kind", [
104
+ z.strictObject({
105
+ kind: z.literal("seeded"),
106
+ method: z.string(),
107
+ seed: z.union([z.string(), z.number()]),
108
+ }),
109
+ z.strictObject({ kind: z.literal("all"), method: z.string() }),
110
+ ]);
111
+
112
+ /** The values that occur more than once in `values`, each once. */
113
+ const repeated = (values: readonly string[]): readonly string[] => [
114
+ ...new Set(values.filter((v, i) => values.indexOf(v) !== i)),
115
+ ];
116
+
117
+ /** Arms whose names are unique: a repeated name is an error naming it. */
118
+ const uniquelyNamed = (
119
+ arms: readonly { readonly name: string }[],
120
+ ctx: Readonly<z.core.$RefinementCtx>,
121
+ ): void => {
122
+ repeated(arms.map((a) => a.name)).forEach((name) => {
123
+ ctx.addIssue({
124
+ code: "custom",
125
+ message: `more than one arm is named «${name}» — an arm's name is unique`,
126
+ });
127
+ });
128
+ };
129
+
130
+ /** The paper's own keys: none of them a field of the plan, which would say a thing twice. */
131
+ const extra = z.record(z.string(), z.unknown()).superRefine((e, ctx) => {
132
+ Object.keys(e)
133
+ .filter((k) => PLAN_FIELDS.some((f) => f === k))
134
+ .forEach((k) => {
135
+ ctx.addIssue({
136
+ code: "custom",
137
+ path: [k],
138
+ message: "is a field of the plan itself — set it there, not in extra",
139
+ });
140
+ });
141
+ });
142
+
143
+ /** Every value of `values` once: a repeat is an error naming it. */
144
+ const once =
145
+ (what: string) =>
146
+ (values: readonly string[], ctx: Readonly<z.core.$RefinementCtx>): void => {
147
+ repeated(values).forEach((v) => {
148
+ ctx.addIssue({
149
+ code: "custom",
150
+ message: `${what} «${v}» is listed more than once`,
151
+ });
152
+ });
153
+ };
154
+
155
+ /** Whether `p` is a relative path that stays where it starts: no backslash, not absolute, no `..`. */
156
+ const staysInside = (p: string): boolean =>
157
+ !p.includes("\\") && !p.startsWith("/") && !p.split("/").includes("..");
158
+
159
+ /** An output glob, relative to the study's folder: inside it, and never the study's README. */
160
+ const inFolder = z
161
+ .string()
162
+ .min(1)
163
+ .refine(
164
+ (p) => staysInside(p) && p !== README,
165
+ `is not a path inside the study's folder, or names its ${README}`,
166
+ );
167
+
168
+ /**
169
+ * Whether `p` names a file by one or more names joined by `/`, none empty, `.` or `..`: never the
170
+ * paper's folder itself (`.`) nor a path ending in `/`. Whether a name is a directory on disk is the
171
+ * gate's and the order rule's to see.
172
+ */
173
+ const namesAFile = (p: string): boolean =>
174
+ p.split("/").every((n) => n !== "" && n !== "." && n !== "..");
175
+
176
+ /**
177
+ * A decision file, relative to the PAPER's folder: inside it, and a file's path. Empty in a draft (the
178
+ * parser names it).
179
+ */
180
+ const inPaper = z
181
+ .string()
182
+ .refine(staysInside, "is not a path inside the paper's folder")
183
+ .refine(
184
+ (p) => p === "" || !staysInside(p) || namesAFile(p),
185
+ "is not a path to a file inside the paper's folder: one or more names joined by /, none empty, . or ..",
186
+ );
187
+
188
+ const decisionShape = z.strictObject({ file: inPaper, note: z.string() });
189
+
190
+ /** The output globs: each once; none yet in a draft. */
191
+ const outputs = z.array(inFolder).superRefine(once("the output"));
192
+
193
+ /** How the outcomes are kept from the decisions: exactly one mode, by its `kind`. */
194
+ const mode = z.discriminatedUnion("kind", [
195
+ z.strictObject({
196
+ kind: z.literal("decide_before_run"),
197
+ outputs,
198
+ decision: decisionShape,
199
+ }),
200
+ z.strictObject({ kind: z.literal("exploratory"), reason: z.string() }),
201
+ ]);
202
+
203
+ /** A plan's shape: closed at every level; the paper's own keys go in `extra`. Null is the one "none". */
204
+ const planShape = z.strictObject({
205
+ question: z.string(),
206
+ population: z.strictObject({ source: z.string(), draw }),
207
+ arms: z.array(z.strictObject({ name: words })).superRefine(uniquelyNamed),
208
+ primary_outcome: z.string(),
209
+ author_check: authorCheck.nullable(),
210
+ extra: extra.optional(),
211
+ mode: mode.optional(),
212
+ });
213
+
214
+ /**
215
+ * A plan under its old name, `prereg.json`, written by hand before the plan had a schema: open at
216
+ * every level, its own top-level keys kept in `extra`. A seed makes the draw seeded, none (or null) a
217
+ * draw of all; a size, and keys of its own deeper down, are not read; the file is never rewritten.
218
+ */
219
+ const legacyAsPlan = z
220
+ .looseObject({
221
+ question: z.string(),
222
+ population: z.looseObject({
223
+ source: z.string(),
224
+ draw: z.looseObject({
225
+ method: z.string(),
226
+ seed: z.union([z.string(), z.number()]).nullable().optional(),
227
+ }),
228
+ }),
229
+ arms: z.array(z.looseObject({ name: words })).superRefine(uniquelyNamed),
230
+ primary_outcome: z.string(),
231
+ author_check: authorCheck.nullable().optional(),
232
+ })
233
+ .transform((p): StudyPlan => {
234
+ const {
235
+ question,
236
+ population,
237
+ arms,
238
+ primary_outcome,
239
+ author_check,
240
+ ...rest
241
+ } = p;
242
+ const { method, seed } = population.draw;
243
+ return {
244
+ question,
245
+ population: {
246
+ source: population.source,
247
+ draw:
248
+ seed === undefined || seed === null
249
+ ? { kind: "all", method }
250
+ : { kind: "seeded", method, seed },
251
+ },
252
+ arms: arms.map((a) => ({ name: a.name })),
253
+ primary_outcome,
254
+ author_check: author_check ?? null,
255
+ extra: rest,
256
+ };
257
+ });
258
+
259
+ /** A text that says something: more than white space. */
260
+ const filled = z.string().regex(/\S/u);
261
+
262
+ /** The texts a plan fills before its study runs; what fails here is what is still empty. */
263
+ const readyTexts = z.object({
264
+ question: filled,
265
+ population: z.object({
266
+ source: filled,
267
+ draw: z.object({ method: filled }),
268
+ }),
269
+ primary_outcome: filled,
270
+ });
271
+
272
+ /** Whether `t` says something. */
273
+ const says = (t: string): boolean => /\S/u.test(t);
274
+
275
+ /**
276
+ * The mode's texts that are still empty, by their field: the mode itself when a study of `kind` must
277
+ * declare one and the plan does not, a decide-first mode's outputs and decision, an exploratory reason.
278
+ */
279
+ const modeEmpties = (
280
+ p: StudyPlan,
281
+ kind: StudyKind,
282
+ ): readonly PlanTextField[] => {
283
+ const m = p.mode;
284
+ if (m === undefined) return needsMode(kind) ? ["mode"] : [];
285
+ if (m.kind === "exploratory") return says(m.reason) ? [] : ["mode.reason"];
286
+ return PLAN_TEXT_FIELDS.filter(
287
+ (f) =>
288
+ (f === "mode.outputs" && m.outputs.length === 0) ||
289
+ (f === "mode.decision.file" && !says(m.decision.file)) ||
290
+ (f === "mode.decision.note" && !says(m.decision.note)),
291
+ );
292
+ };
293
+
294
+ /** A plan that parsed: ready, or a draft. */
295
+ type ReadyOrDraft = Exclude<ParsedPlan, { readonly kind: "unreadable" }>;
296
+
297
+ /**
298
+ * A parsed plan, ready when every required text is filled, else a draft naming the empty ones. A
299
+ * draft that carries the author's check is no plan: the author approved one with empty texts.
300
+ */
301
+ const readyOrDraft =
302
+ (kind: StudyKind) =>
303
+ (p: StudyPlan, ctx: Readonly<z.core.$RefinementCtx>): ReadyOrDraft => {
304
+ const checked = readyTexts.safeParse(p);
305
+ const paths = checked.success
306
+ ? []
307
+ : checked.error.issues.map((i) => i.path.join("."));
308
+ const empty = [
309
+ ...PLAN_TEXT_FIELDS.filter((f) => paths.includes(f)),
310
+ ...modeEmpties(p, kind),
311
+ ];
312
+ if (empty.length === 0) return { kind: "ready", plan: p };
313
+ if (p.author_check === null)
314
+ return { kind: "draft", plan: { ...p, author_check: null }, empty };
315
+ ctx.addIssue({
316
+ code: "custom",
317
+ path: ["author_check"],
318
+ message: `is set, and the plan leaves ${empty.map((f) => `\`${f}\``).join(", ")} empty — the author approves a complete plan: fill them, or set author_check to null`,
319
+ });
320
+ return z.NEVER;
321
+ };
322
+
323
+ /** A `plan.json` of a study of `kind`: strict, then ready or draft — a measurement's ready only with its mode. */
324
+ const planFor = (kind: StudyKind) => planShape.transform(readyOrDraft(kind));
325
+
326
+ /**
327
+ * A `prereg.json`: loose, mapped onto the plan, then ready or draft. Written before modes existed, it
328
+ * is never asked for one; `repro/study-plan-name` already asks to move it to `plan.json`, where the
329
+ * strict parser does.
330
+ */
331
+ const legacyPlan = legacyAsPlan.transform(readyOrDraft("spike"));
332
+
333
+ // ── the schemas ARE the domain's types ─────────────────────────────────────────
334
+ // Each schema's output and its hand-written domain type are the same type, both ways, once both are
335
+ // made readonly all the way down: a field added to one and not the other stops the build.
336
+
337
+ /** `T`, readonly all the way down: zod's outputs are mutable, the domain's are not. */
338
+ type Frozen<T> = T extends readonly (infer U)[]
339
+ ? readonly Frozen<U>[]
340
+ : T extends object
341
+ ? { readonly [K in keyof T]: Frozen<T[K]> }
342
+ : T;
343
+
344
+ /**
345
+ * True when `A` and `B`, both made readonly all the way down, are one type, optional modifiers
346
+ * included: two generic signatures whose returns are deferred conditionals are related only when
347
+ * the conditionals are identical. Written out, not through an alias: an alias is compared by the
348
+ * variance of its parameter, which misses a dropped optional field.
349
+ */
350
+ type Same<A, B> =
351
+ (<T>() => T extends Frozen<A> ? readonly [T] : 2) extends <
352
+ T,
353
+ >() => T extends Frozen<B> ? readonly [T] : 2
354
+ ? true
355
+ : false;
356
+
357
+ /** Compiles only when every schema's output is its domain type. */
358
+ export type SchemasAreTheDomain = [
359
+ Same<z.output<typeof registry>, StudyRegistry>,
360
+ Same<z.output<typeof study>, Study>,
361
+ Same<z.output<typeof measurement>, Measurement>,
362
+ Same<z.output<typeof requirement>, Requirement>,
363
+ Same<z.output<typeof approval>, Approval>,
364
+ Same<z.output<typeof authorCheck>, AuthorCheck>,
365
+ Same<z.output<typeof planShape>, StudyPlan>,
366
+ Same<z.output<typeof legacyAsPlan>, StudyPlan>,
367
+ Same<z.output<ReturnType<typeof planFor>>, ReadyOrDraft>,
368
+ Same<z.output<typeof legacyPlan>, ReadyOrDraft>,
369
+ Same<z.output<typeof mode>, StudyMode>,
370
+ Same<z.output<typeof decisionShape>, Decision>,
371
+ ] extends true[]
372
+ ? true
373
+ : never;
374
+
375
+ /** The proof is used, so the build checks it. */
376
+ export const SCHEMAS_ARE_THE_DOMAIN: SchemasAreTheDomain = true;
377
+
41
378
  /** The JSON `text` holds, or undefined when it is not JSON. */
42
379
  function jsonOf(text: string): unknown {
43
380
  try {
@@ -47,6 +384,20 @@ function jsonOf(text: string): unknown {
47
384
  }
48
385
  }
49
386
 
387
+ /** Every reason zod gave, each with the path it is about. */
388
+ const reasons = (
389
+ issues: readonly {
390
+ readonly path: readonly PropertyKey[];
391
+ readonly message: string;
392
+ }[],
393
+ ): string =>
394
+ issues
395
+ .map(
396
+ (i) =>
397
+ `${i.path.length === 0 ? "(top)" : i.path.join(".")}: ${i.message}`,
398
+ )
399
+ .join("; ");
400
+
50
401
  /** A `studies.json`'s text, parsed once at the boundary: the registry, or every reason it is not one. */
51
402
  export function parseStudyRegistry(text: string): ParsedRegistry {
52
403
  const json = jsonOf(text);
@@ -54,17 +405,30 @@ export function parseStudyRegistry(text: string): ParsedRegistry {
54
405
  const parsed = registry.safeParse(json);
55
406
  return parsed.success
56
407
  ? { ok: true, registry: parsed.data }
57
- : {
58
- ok: false,
59
- why: parsed.error.issues
60
- .map(
61
- (i) =>
62
- `${i.path.length === 0 ? "(top)" : i.path.join(".")}: ${i.message}`,
63
- )
64
- .join("; "),
65
- };
408
+ : { ok: false, why: reasons(parsed.error.issues) };
66
409
  }
67
410
 
411
+ /** A plan's text, parsed once at the boundary by `schema`: ready, a draft, or every reason it is neither. */
412
+ const planParser =
413
+ (schema: Readonly<z.ZodType<ReadyOrDraft>>) =>
414
+ (text: string): ParsedPlan => {
415
+ const json = jsonOf(text);
416
+ if (json === undefined) return { kind: "unreadable", why: "not JSON" };
417
+ const parsed = schema.safeParse(json);
418
+ return parsed.success
419
+ ? parsed.data
420
+ : { kind: "unreadable", why: reasons(parsed.error.issues) };
421
+ };
422
+
423
+ /** A `plan.json` of a study of `kind`: strict. */
424
+ export const parseStudyPlan = (text: string, kind: StudyKind): ParsedPlan =>
425
+ planParser(planFor(kind))(text);
426
+
427
+ /** A plan still named `prereg.json`: loose, read only when a row names it. */
428
+ export const parseLegacyPlan = planParser(legacyPlan);
429
+
68
430
  export const studyRegistryCodec: StudyRegistryCodec = {
69
431
  parse: parseStudyRegistry,
432
+ parsePlan: parseStudyPlan,
433
+ parseLegacyPlan,
70
434
  };
@@ -0,0 +1,162 @@
1
+ /**
2
+ * The schema of a study's mode (`mode` in its plan). Both halves: what `studies new` writes parses back
3
+ * to itself; a measurement's plan is ready only with exactly one mode; every contradiction is refused
4
+ * with its reason.
5
+ */
6
+ import { expect, it } from "vitest";
7
+ import type { StudyMode } from "../../domain/study-mode.ts";
8
+ import {
9
+ planTemplate,
10
+ planText,
11
+ type StudyKind,
12
+ type StudyPlan,
13
+ } from "../../domain/study-registry.ts";
14
+ import { studyRegistryCodec } from "./index.ts";
15
+
16
+ const DECIDED: StudyMode = {
17
+ kind: "decide_before_run",
18
+ outputs: ["data/outcome/**", "RESULTS.md"],
19
+ decision: {
20
+ file: "repro/P1-S01-x/decision.md",
21
+ note: "exclusions and the sample approved",
22
+ },
23
+ };
24
+ const EXPLORATORY: StudyMode = {
25
+ kind: "exploratory",
26
+ reason: "a first look at the logs, to find the question",
27
+ };
28
+
29
+ const PLAN: StudyPlan = {
30
+ question: "Does the hint help?",
31
+ population: {
32
+ source: "tasks, 2026-10-01",
33
+ draw: { kind: "all", method: "census" },
34
+ },
35
+ arms: [{ name: "hint" }, { name: "none" }],
36
+ primary_outcome: "pass rate",
37
+ author_check: null,
38
+ mode: DECIDED,
39
+ };
40
+
41
+ const parse = (text: string, kind: StudyKind = "measurement") =>
42
+ studyRegistryCodec.parsePlan(text, kind);
43
+ const withMode = (mode: unknown): string => JSON.stringify({ ...PLAN, mode });
44
+ const why = (text: string): string => {
45
+ const r = parse(text);
46
+ return r.kind === "unreadable" ? r.why : r.kind;
47
+ };
48
+ const empty = (text: string, kind: StudyKind = "measurement") => {
49
+ const r = parse(text, kind);
50
+ return r.kind === "draft" ? r.empty : r.kind;
51
+ };
52
+
53
+ it("each mode parses to itself, ready", () => {
54
+ // Guards: the three modes round-trip through the text planText writes.
55
+ expect(
56
+ [DECIDED, EXPLORATORY].map((mode) => parse(planText({ ...PLAN, mode }))),
57
+ ).toEqual(
58
+ [DECIDED, EXPLORATORY].map((mode) => ({
59
+ kind: "ready",
60
+ plan: { ...PLAN, mode },
61
+ })),
62
+ );
63
+ });
64
+
65
+ it("a measurement's plan without a mode is a draft naming `mode`; a spike's is ready", () => {
66
+ const none: StudyPlan = { ...PLAN, mode: undefined };
67
+ // Guards: "none" is not a ready state for a measurement — and a spike may omit it.
68
+ expect(empty(planText(none))).toEqual(["mode"]);
69
+ expect(empty(planText(none), "spike")).toBe("ready");
70
+ });
71
+
72
+ it("the template of a measurement is a draft decided before its run, its decision file named", () => {
73
+ const t = planTemplate("measurement", "P1-S02-x");
74
+ // Guards: `studies new` scaffolds the default mode; what is left to fill is named.
75
+ expect(t.mode).toEqual({
76
+ kind: "decide_before_run",
77
+ outputs: [],
78
+ decision: { file: "repro/P1-S02-x/decision.md", note: "" },
79
+ });
80
+ expect(empty(planText(t))).toEqual([
81
+ "question",
82
+ "population.source",
83
+ "population.draw.method",
84
+ "primary_outcome",
85
+ "mode.outputs",
86
+ "mode.decision.note",
87
+ ]);
88
+ expect(planTemplate("spike", "P1-S03-y").mode).toBeUndefined();
89
+ });
90
+
91
+ it("each mode's empty texts are named: the decision's, an exploratory reason", () => {
92
+ // Guards: a mode left half-written is a draft, so a study that has run on it is reported.
93
+ expect(
94
+ empty(
95
+ withMode({ ...DECIDED, outputs: [], decision: { file: " ", note: "" } }),
96
+ ),
97
+ ).toEqual(["mode.outputs", "mode.decision.file", "mode.decision.note"]);
98
+ expect(empty(withMode({ ...EXPLORATORY, reason: "" }))).toEqual([
99
+ "mode.reason",
100
+ ]);
101
+ });
102
+
103
+ it("two modes at once, or a mode that is neither, cannot be written", () => {
104
+ // Guards: exactly one — a second mode's key is an unknown key, an unknown kind is refused.
105
+ expect(why(withMode({ ...DECIDED, reason: "r" }))).toBe(
106
+ 'mode: Unrecognized key: "reason"',
107
+ );
108
+ expect(why(withMode({ kind: "preregistered" }))).toBe(
109
+ "mode.kind: Invalid discriminator value. Expected 'decide_before_run' | 'exploratory'",
110
+ );
111
+ expect(why(withMode({ kind: "blind" }))).toBe(
112
+ "mode.kind: Invalid discriminator value. Expected 'decide_before_run' | 'exploratory'",
113
+ );
114
+ });
115
+
116
+ it("refuses an output outside the folder or naming the study's README, and a decision outside the paper", () => {
117
+ const outside =
118
+ "is not a path inside the study's folder, or names its README.md";
119
+ // Guards: a glob cannot reach past the study's folder; a decision cannot leave the paper.
120
+ expect(
121
+ ["../x", "/abs", "a\\b", "README.md"].map((o) =>
122
+ why(withMode({ ...DECIDED, outputs: [o] })),
123
+ ),
124
+ ).toEqual(Array.from({ length: 4 }, () => `mode.outputs.0: ${outside}`));
125
+ expect(why(withMode({ ...DECIDED, outputs: ["a", "a"] }))).toBe(
126
+ "mode.outputs: the output «a» is listed more than once",
127
+ );
128
+ expect(
129
+ ["../d.md", "/d.md", "a\\d.md"].map((file) =>
130
+ why(withMode({ ...DECIDED, decision: { file, note: "n" } })),
131
+ ),
132
+ ).toEqual(
133
+ Array.from(
134
+ { length: 3 },
135
+ () => "mode.decision.file: is not a path inside the paper's folder",
136
+ ),
137
+ );
138
+ });
139
+
140
+ it("refuses a decision path that names the paper's folder or a directory, and keeps an empty one a draft", () => {
141
+ const notFile =
142
+ "mode.decision.file: is not a path to a file inside the paper's folder: one or more names joined by /, none empty, . or ..";
143
+ // Guards: a decision of `.` would make the gate ask git about the whole paper, and the order rule
144
+ // read the paper's first commit as the decision; such a path cannot be written down.
145
+ expect(
146
+ [".", "./", "repro/", "a//d.md", "a/./d.md", "./d.md"].map((file) =>
147
+ why(withMode({ ...DECIDED, decision: { file, note: "n" } })),
148
+ ),
149
+ ).toEqual(Array(6).fill(notFile));
150
+ expect(
151
+ empty(withMode({ ...DECIDED, decision: { file: "", note: "n" } })),
152
+ ).toEqual(["mode.decision.file"]);
153
+ });
154
+
155
+ it("mode is a field of the plan, so extra cannot carry it", () => {
156
+ // Guards: one place says how a study is gated.
157
+ expect(
158
+ why(JSON.stringify({ ...PLAN, mode: undefined, extra: { mode: {} } })),
159
+ ).toBe(
160
+ "extra.mode: is a field of the plan itself — set it there, not in extra",
161
+ );
162
+ });
package/src/cli.ts CHANGED
@@ -190,6 +190,12 @@ import {
190
190
  studyRegistryRules,
191
191
  } from "./study-registry-rule.ts";
192
192
  import { studyRegistryCodec } from "./adapters/study-registry/index.ts";
193
+ import { nodeListEntries, spawnProcess } from "./adapters/node/index.ts";
194
+ import { gitHistory } from "./adapters/git/index.ts";
195
+ import {
196
+ ORDER_RULE_LEVELS,
197
+ outputsBeforeDecisionRules,
198
+ } from "./outputs-before-decision-rule.ts";
193
199
  import { runStudies } from "./studies.ts";
194
200
  import typography from "#eslint-rules/paper-typography";
195
201
  import texBuild from "#eslint-rules/tex-build";
@@ -238,10 +244,16 @@ const USAGE = `paperlint — machine-checkable gates for a paper kept in git
238
244
  change and keeps nothing
239
245
  npx paperlint studies new <paper-dir> "<title>" [--kind <kind>]
240
246
  register a study: the next id in repro/studies.json, the folder
241
- repro/<id>-<slug>/ with its README.md, the row (status designing,
242
- today's date), and the index in repro/README.md regenerated
247
+ repro/<id>-<slug>/ with its README.md and plan.json (a template),
248
+ the row (status designing, today's date, its plan), and the
249
+ index in repro/README.md regenerated
243
250
  npx paperlint studies index <paper-dir>
244
251
  regenerate the index in repro/README.md from repro/studies.json
252
+ npx paperlint studies gate <paper-dir> <id>
253
+ may the study's run start? For a study decided before its run
254
+ (the default mode), exit 0 only once its decision file is
255
+ committed as it stands, printing the commit and date; else exit
256
+ 1 saying why. A runner calls it first: gate … && ./run.sh
245
257
  npx paperlint hook <name> run an editor hook (.claude/settings.json calls this)
246
258
  npx paperlint --help
247
259
 
@@ -264,8 +276,12 @@ new:
264
276
 
265
277
  studies:
266
278
  --kind <kind> new: measurement (the default), spike, tooling, data, artifact or salvage.
267
- A study's status, result and in_paper are edited in repro/studies.json;
268
- the rule repro/study-registered checks them (docs/rules/repro/study-registered.md)
279
+ A study's status, result and in_paper are edited in repro/studies.json, its
280
+ plan in its plan.json; the rule repro/study-registered checks them
281
+ (docs/rules/repro/study-registered.md). A measurement's plan declares its
282
+ mode: decide_before_run (written by new) or exploratory;
283
+ repro/outputs-before-decision checks the order in git
284
+ (docs/design/study-modes.md)
269
285
 
270
286
  submission:
271
287
  The venue preset declares the portal — "portal": { "kind": "hotcrp", "url": "<site>" } — and the
@@ -381,20 +397,43 @@ const FRAGMENT_RULES = {
381
397
  "heading-case": REGISTER_AND_CASE_RULES["heading-case"],
382
398
  };
383
399
 
400
+ /** `names` of this process's environment, and nothing else of it. */
401
+ const envOf =
402
+ (names: readonly string[]) =>
403
+ (env: Readonly<NodeJS.ProcessEnv>): Readonly<Record<string, string>> =>
404
+ Object.fromEntries(
405
+ Object.entries(env).filter(
406
+ (e): e is [string, string] =>
407
+ names.includes(e[0]) && e[1] !== undefined,
408
+ ),
409
+ );
410
+
411
+ /** The environment git runs in: PATH, and HOME for its configuration. */
412
+ const gitEnv = envOf(["PATH", "HOME"]);
413
+
414
+ /** git's history, as the decide-first rule and the gate read it. */
415
+ const GIT = gitHistory(spawnProcess(), gitEnv(process.env));
416
+
384
417
  /** What the two layout rules read with: the disk and its directory listings. */
385
418
  const LAYOUT_RULE_DEPS = { files: nodeFiles, listDir: nodeListDir };
386
419
 
387
420
  /** What `repro/study-registered` and `paperlint studies` read with: the layout's, and the registry's parser. */
388
- const STUDY_RULE_DEPS = { ...LAYOUT_RULE_DEPS, codec: studyRegistryCodec };
421
+ const STUDY_RULE_DEPS = {
422
+ ...LAYOUT_RULE_DEPS,
423
+ entries: nodeListEntries,
424
+ codec: studyRegistryCodec,
425
+ };
389
426
 
390
427
  /** The `repro` plugin's rules, on every `PIPELINE-STATUS.md`: the layout of `repro/`, and its study registry. */
391
428
  const REPRO_RULES = {
392
429
  ...reproLayoutRules(LAYOUT_RULE_DEPS),
393
430
  ...studyRegistryRules(STUDY_RULE_DEPS),
431
+ ...outputsBeforeDecisionRules({ ...STUDY_RULE_DEPS, git: GIT }),
394
432
  };
395
433
  const REPRO_RULE_LEVELS = {
396
434
  ...REPRO_LAYOUT_RULE_LEVELS,
397
435
  ...STUDY_REGISTRY_RULE_LEVELS,
436
+ ...ORDER_RULE_LEVELS,
398
437
  };
399
438
 
400
439
  /** Today in this machine's time zone, `YYYY-MM-DD`: the date `paperlint studies new` registers a study on. */
@@ -1673,7 +1712,14 @@ const SIMPLE: Readonly<
1673
1712
  extra: a.paths.slice(3),
1674
1713
  kind: a.kind,
1675
1714
  },
1676
- { ...STUDY_RULE_DEPS, cwd, today: today(), log, err },
1715
+ {
1716
+ ...STUDY_RULE_DEPS,
1717
+ git: GIT,
1718
+ cwd,
1719
+ today: today(),
1720
+ log,
1721
+ err,
1722
+ },
1677
1723
  ),
1678
1724
  submission: (a, { log, err, cwd }) =>
1679
1725
  runSubmission(