paperlint 13.0.3 → 13.2.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 (114) hide show
  1. package/CLAUDE.md +9 -1
  2. package/dist/adapters/git/index.d.ts.map +1 -1
  3. package/dist/adapters/git/index.js +13 -0
  4. package/dist/adapters/git/index.js.map +1 -1
  5. package/dist/adapters/study-registry/index.d.ts +142 -1
  6. package/dist/adapters/study-registry/index.d.ts.map +1 -1
  7. package/dist/adapters/study-registry/index.js +160 -1
  8. package/dist/adapters/study-registry/index.js.map +1 -1
  9. package/dist/cli.d.ts.map +1 -1
  10. package/dist/cli.js +3 -0
  11. package/dist/cli.js.map +1 -1
  12. package/dist/domain/decide-first.d.ts +43 -0
  13. package/dist/domain/decide-first.d.ts.map +1 -1
  14. package/dist/domain/decide-first.js +45 -2
  15. package/dist/domain/decide-first.js.map +1 -1
  16. package/dist/domain/study-gates.d.ts +96 -0
  17. package/dist/domain/study-gates.d.ts.map +1 -0
  18. package/dist/domain/study-gates.js +56 -0
  19. package/dist/domain/study-gates.js.map +1 -0
  20. package/dist/domain/study-mode.d.ts +6 -0
  21. package/dist/domain/study-mode.d.ts.map +1 -1
  22. package/dist/domain/study-mode.js.map +1 -1
  23. package/dist/domain/study-registry.d.ts +24 -4
  24. package/dist/domain/study-registry.d.ts.map +1 -1
  25. package/dist/domain/study-registry.js +39 -2
  26. package/dist/domain/study-registry.js.map +1 -1
  27. package/dist/domain/study-result.d.ts +151 -0
  28. package/dist/domain/study-result.d.ts.map +1 -0
  29. package/dist/domain/study-result.js +175 -0
  30. package/dist/domain/study-result.js.map +1 -0
  31. package/dist/outputs-before-decision-rule.d.ts.map +1 -1
  32. package/dist/outputs-before-decision-rule.js +37 -3
  33. package/dist/outputs-before-decision-rule.js.map +1 -1
  34. package/dist/ports/git-history.d.ts +12 -0
  35. package/dist/ports/git-history.d.ts.map +1 -1
  36. package/dist/ports/study-registry-codec.d.ts +3 -0
  37. package/dist/ports/study-registry-codec.d.ts.map +1 -1
  38. package/dist/skills/build-label-page/scripts/label-page-cli.d.ts +6 -0
  39. package/dist/skills/build-label-page/scripts/label-page-commands.d.ts +14 -0
  40. package/dist/skills/build-label-page/scripts/label-page-core.d.ts +149 -0
  41. package/dist/skills/build-label-page/scripts/label-page-html.d.ts +5 -0
  42. package/dist/skills/build-label-page/scripts/label-page.d.ts +2 -0
  43. package/dist/study-modes.d.ts +3 -0
  44. package/dist/study-modes.d.ts.map +1 -1
  45. package/dist/study-modes.js +8 -1
  46. package/dist/study-modes.js.map +1 -1
  47. package/dist/study-registry-rule.d.ts.map +1 -1
  48. package/dist/study-registry-rule.js +1 -0
  49. package/dist/study-registry-rule.js.map +1 -1
  50. package/dist/study-result-rule.d.ts +12 -0
  51. package/dist/study-result-rule.d.ts.map +1 -0
  52. package/dist/study-result-rule.js +87 -0
  53. package/dist/study-result-rule.js.map +1 -0
  54. package/docs/design/study-gates.md +208 -0
  55. package/docs/rules/repro/outputs-before-decision.md +8 -0
  56. package/docs/rules/repro/study-registered.md +38 -15
  57. package/docs/rules/repro/study-result.md +100 -0
  58. package/docs/rules.md +1 -0
  59. package/eslint.config.mjs +1 -1
  60. package/hooks/hooks.harness.ts +51 -13
  61. package/hooks/paper-skills-nudge.hook.d.mts +22 -0
  62. package/hooks/paper-skills-nudge.hook.mjs +85 -23
  63. package/hooks/paper-skills-nudge.test.ts +82 -4
  64. package/package.json +2 -1
  65. package/plugin/hooks/hooks.json +7 -2
  66. package/schemas/plan.v2.schema.json +341 -0
  67. package/skills/argument-arc/SKILL.md.spec.ts +1 -1
  68. package/skills/build-label-page/SKILL.md +85 -54
  69. package/skills/build-label-page/SKILL.md.spec.ts +85 -53
  70. package/skills/build-label-page/build-label-page.test.ts +10 -941
  71. package/skills/build-label-page/references/checklist.md +45 -3
  72. package/skills/build-label-page/references/closed-choice-generator.md +88 -0
  73. package/skills/build-label-page/scripts/label-page-cli.js +72 -0
  74. package/skills/build-label-page/scripts/label-page-cli.ts +85 -0
  75. package/skills/build-label-page/scripts/label-page-commands.js +14 -0
  76. package/skills/build-label-page/scripts/label-page-commands.ts +14 -0
  77. package/skills/build-label-page/scripts/label-page-core.js +850 -0
  78. package/skills/build-label-page/scripts/label-page-core.test.ts +1164 -0
  79. package/skills/build-label-page/scripts/label-page-core.ts +1210 -0
  80. package/skills/build-label-page/scripts/label-page-html.js +33 -0
  81. package/skills/build-label-page/scripts/label-page-html.test.ts +413 -0
  82. package/skills/build-label-page/scripts/label-page-html.ts +43 -0
  83. package/skills/build-label-page/scripts/label-page.js +70 -0
  84. package/skills/build-label-page/scripts/label-page.test.ts +71 -0
  85. package/skills/build-label-page/scripts/label-page.ts +71 -0
  86. package/skills/build-label-page/scripts/page-template.html +513 -0
  87. package/skills/find-venue/SKILL.md.spec.ts +1 -1
  88. package/skills/paper-status/SKILL.md.spec.ts +1 -1
  89. package/skills/render-paper/SKILL.md.spec.ts +1 -1
  90. package/src/adapters/git/index.test.ts +33 -0
  91. package/src/adapters/git/index.ts +19 -0
  92. package/src/adapters/study-registry/gates.test.ts +181 -0
  93. package/src/adapters/study-registry/index.ts +219 -1
  94. package/src/adapters/study-registry/mode.test.ts +6 -0
  95. package/src/cli.ts +3 -0
  96. package/src/domain/decide-first.test.ts +131 -0
  97. package/src/domain/decide-first.ts +92 -2
  98. package/src/domain/study-gates.ts +125 -0
  99. package/src/domain/study-measurements.test.ts +71 -0
  100. package/src/domain/study-mode.ts +6 -0
  101. package/src/domain/study-registry.ts +64 -2
  102. package/src/domain/study-result.test.ts +300 -0
  103. package/src/domain/study-result.ts +358 -0
  104. package/src/outputs-before-decision-rule.test.ts +115 -2
  105. package/src/outputs-before-decision-rule.ts +50 -2
  106. package/src/ports/git-history.ts +12 -0
  107. package/src/ports/study-registry-codec.ts +3 -0
  108. package/src/rule-cases.test.ts +74 -0
  109. package/src/studies.test.ts +3 -0
  110. package/src/study-modes.ts +10 -1
  111. package/src/study-registry-rule.ts +1 -0
  112. package/src/study-result-rule.test.ts +281 -0
  113. package/src/study-result-rule.ts +123 -0
  114. package/skills/build-label-page/references/example-generator.py +0 -803
@@ -7,11 +7,13 @@
7
7
  * order whether the clone is shallow; the decision file's first commit; every file's first
8
8
  * commit under the study's folder (`git log --reverse --name-only`); and for each, whether
9
9
  * any commit that touched the decision is its strict ancestor (`git rev-list -- <decision>`)
10
+ * fileAt a file under the study's folder as it stood at a commit (`git ls-tree`, then `git show`)
10
11
  */
11
12
  import type { AbsolutePath } from "../../domain/paths.ts";
12
13
  import type {
13
14
  CommitAt,
14
15
  Committed,
16
+ FileAt,
15
17
  FirstCommit,
16
18
  GitHistory,
17
19
  GitOrder,
@@ -110,9 +112,26 @@ export function gitHistory(
110
112
  : { kind: "changed", commit };
111
113
  },
112
114
  order: (studyDir, decision) => orderIn(git, studyDir, decision),
115
+ fileAt: (studyDir, commit, path) => fileAtIn(git, studyDir, commit, path),
113
116
  };
114
117
  }
115
118
 
119
+ /** `path` (relative to `studyDir`) at `commit`: `ls-tree` says whether it is there, `show` gives its text. */
120
+ const fileAtIn = (
121
+ git: Git,
122
+ studyDir: AbsolutePath,
123
+ commit: string,
124
+ path: string,
125
+ ): FileAt => {
126
+ const listed = git(studyDir, ["ls-tree", "--name-only", commit, "--", path]);
127
+ if ("why" in listed) return { kind: "unknown", why: listed.why };
128
+ if (listed.out.trim() === "") return { kind: "absent" };
129
+ const shown = git(studyDir, ["show", `${commit}:./${path}`]);
130
+ return "why" in shown
131
+ ? { kind: "unknown", why: shown.why }
132
+ : { kind: "text", text: shown.out };
133
+ };
134
+
116
135
  type Git = ReturnType<typeof gitIn>;
117
136
 
118
137
  /** Whether `file` is in HEAD's tree (`git ls-tree`, which takes an absolute path in the worktree). */
@@ -0,0 +1,181 @@
1
+ /**
2
+ * The schema of a plan v2's gates (src/domain/study-gates.ts). Both halves: a plan v2 with every gate
3
+ * parses to itself, ready, and a plan v1 stays as it was; every gate missing, misplaced or malformed
4
+ * is refused with its reason; a gate still null keeps the plan a draft naming it.
5
+ */
6
+ import { readFileSync } from "node:fs";
7
+ import { dirname, join } from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+ import { expect, it } from "vitest";
10
+ import { PLAN_V2 } from "../../domain/study-gates.ts";
11
+ import type { DecideFirst } from "../../domain/study-mode.ts";
12
+ import { planText, type StudyPlan } from "../../domain/study-registry.ts";
13
+ import { planV2JsonSchema, studyRegistryCodec } from "./index.ts";
14
+
15
+ const DECIDED: DecideFirst = {
16
+ kind: "decide_before_run",
17
+ outputs: ["result.json"],
18
+ decision: { file: "repro/P1-S01-x/decision.md", note: "the draw" },
19
+ };
20
+
21
+ const V1: StudyPlan = {
22
+ question: "Do the readings disagree?",
23
+ population: {
24
+ source: "repositories, 2026-10-31",
25
+ draw: { kind: "seeded", method: "one rule per repository", seed: 7 },
26
+ },
27
+ arms: [],
28
+ primary_outcome: "share of rules whose readings disagree",
29
+ author_check: null,
30
+ mode: DECIDED,
31
+ };
32
+
33
+ const V2: StudyPlan = {
34
+ ...V1,
35
+ $schema: PLAN_V2,
36
+ mode: { ...DECIDED, we_lose_if: { result: "share_upper95", below: 0.2 } },
37
+ second_check: [
38
+ { step: "lint-checkable", min_kappa: 0.6 },
39
+ { step: "admissible", min_kappa: 0.6 },
40
+ ],
41
+ self_test: { made_by: "scripts/selftest.mjs" },
42
+ funnel: ["no linter rule", "clone failed"],
43
+ recall_audit: { n_files: 50, min_recall: 0.6 },
44
+ };
45
+
46
+ const parse = (p: unknown) =>
47
+ studyRegistryCodec.parsePlan(JSON.stringify(p), "measurement");
48
+ const why = (p: unknown): string => {
49
+ const r = parse(p);
50
+ return r.kind === "unreadable" ? r.why : r.kind;
51
+ };
52
+ const empty = (p: unknown) => {
53
+ const r = parse(p);
54
+ return r.kind === "draft" ? r.empty : r.kind;
55
+ };
56
+
57
+ it("a plan v2 with every gate parses to itself, ready; a plan v1 stays ready", () => {
58
+ // Guards: the v2 fields round-trip through the text planText writes, and v1 is unchanged.
59
+ expect(
60
+ [V1, V2].map((p) =>
61
+ studyRegistryCodec.parsePlan(planText(p), "measurement"),
62
+ ),
63
+ ).toEqual([
64
+ { kind: "ready", plan: V1 },
65
+ { kind: "ready", plan: V2 },
66
+ ]);
67
+ });
68
+
69
+ it('"none" is a gate decided: a census with nothing labelled, tested or dropped is ready', () => {
70
+ const p = {
71
+ ...V2,
72
+ mode: { ...DECIDED, we_lose_if: { result: "rate", above: 0.1 } },
73
+ second_check: "none",
74
+ self_test: "none",
75
+ funnel: "none",
76
+ recall_audit: "none",
77
+ };
78
+ // Guards: "not this study" has one spelling, and it is a decision, not a gap.
79
+ expect(parse(p)).toEqual({ kind: "ready", plan: p });
80
+ });
81
+
82
+ it("every gate still null keeps the plan a draft naming it", () => {
83
+ const p = {
84
+ ...V2,
85
+ mode: { ...DECIDED, we_lose_if: null },
86
+ second_check: null,
87
+ self_test: null,
88
+ funnel: null,
89
+ recall_audit: null,
90
+ };
91
+ // Guards: null is "not decided yet" — the study may not run on it.
92
+ expect(empty(p)).toEqual([
93
+ "mode.we_lose_if",
94
+ "second_check",
95
+ "self_test",
96
+ "funnel",
97
+ "recall_audit",
98
+ ]);
99
+ });
100
+
101
+ it("a plan v2 that leaves a gate out is no plan, the gate named", () => {
102
+ const noFunnel = { ...V2, funnel: undefined };
103
+ const modeWithout = DECIDED;
104
+ // Guards: a forgotten gate cannot be written — only "none" or null say "not this one".
105
+ expect(why(noFunnel)).toMatch(/^funnel: is missing, and a plan v2/u);
106
+ expect(why({ ...V2, recall_audit: undefined })).toMatch(
107
+ /^recall_audit: is missing, and a plan v2/u,
108
+ );
109
+ expect(why({ ...V2, mode: modeWithout })).toMatch(
110
+ /^mode\.we_lose_if: is missing/u,
111
+ );
112
+ });
113
+
114
+ it("a plan without $schema that carries a gate is refused: nobody would read it", () => {
115
+ // Guards: a v1 plan cannot half-declare the gates.
116
+ expect(why({ ...V1, funnel: "none" })).toBe(
117
+ `funnel: is a field of a plan v2 — add "$schema": "${PLAN_V2}"`,
118
+ );
119
+ expect(why({ ...V1, recall_audit: "none" })).toBe(
120
+ `recall_audit: is a field of a plan v2 — add "$schema": "${PLAN_V2}"`,
121
+ );
122
+ });
123
+
124
+ it("an exploratory mode cannot carry we_lose_if, and an exploratory plan v2 needs none", () => {
125
+ const exploratory = { kind: "exploratory", reason: "a first look" };
126
+ // Guards: no claim, no falsifier — the shape refuses one.
127
+ expect(why({ ...V2, mode: { ...exploratory, we_lose_if: null } })).toContain(
128
+ "we_lose_if",
129
+ );
130
+ expect(parse({ ...V2, mode: exploratory }).kind).toBe("ready");
131
+ });
132
+
133
+ it("each malformed gate is refused", () => {
134
+ const bad: readonly unknown[] = [
135
+ { ...V2, $schema: "https://example.com/plan.json" },
136
+ {
137
+ ...V2,
138
+ mode: { ...DECIDED, we_lose_if: { result: "x", below: 1, above: 2 } },
139
+ },
140
+ { ...V2, mode: { ...DECIDED, we_lose_if: { result: "x" } } },
141
+ { ...V2, second_check: [] },
142
+ { ...V2, second_check: [{ step: "a", min_kappa: 0 }] },
143
+ { ...V2, second_check: [{ step: "a", min_kappa: 1.2 }] },
144
+ {
145
+ ...V2,
146
+ second_check: [
147
+ { step: "a", min_kappa: 0.6 },
148
+ { step: "a", min_kappa: 0.7 },
149
+ ],
150
+ },
151
+ { ...V2, self_test: "n/a" },
152
+ { ...V2, self_test: { made_by: "../outside.mjs" } },
153
+ { ...V2, self_test: { must_catch: "bad.js" } },
154
+ { ...V2, funnel: [] },
155
+ { ...V2, funnel: ["clone failed", "clone failed"] },
156
+ { ...V2, recall_audit: "n/a" },
157
+ { ...V2, recall_audit: { n_files: 0, min_recall: 0.6 } },
158
+ { ...V2, recall_audit: { n_files: 2.5, min_recall: 0.6 } },
159
+ { ...V2, recall_audit: { n_files: 50, min_recall: 0 } },
160
+ { ...V2, recall_audit: { n_files: 50, min_recall: 1.2 } },
161
+ { ...V2, recall_audit: { n_files: 50 } },
162
+ ];
163
+ // Guards: each of these is refused — none parses to a plan.
164
+ expect(bad.map((p) => parse(p).kind)).toEqual(bad.map(() => "unreadable"));
165
+ expect(why(bad[6])).toContain("the step «a» is listed more than once");
166
+ expect(why(bad[11])).toContain("the reason «clone failed»");
167
+ });
168
+
169
+ it("the shipped schemas/plan.v2.schema.json is the one the parser generates", () => {
170
+ const shipped: unknown = JSON.parse(
171
+ readFileSync(
172
+ join(
173
+ dirname(fileURLToPath(import.meta.url)),
174
+ "../../../schemas/plan.v2.schema.json",
175
+ ),
176
+ "utf8",
177
+ ),
178
+ );
179
+ // Guards: an editor's suggestions for a plan v2 never drift from what the parser accepts.
180
+ expect(shipped).toEqual(planV2JsonSchema());
181
+ });
@@ -44,6 +44,21 @@ import {
44
44
  type Decision,
45
45
  type StudyMode,
46
46
  } from "../../domain/study-mode.ts";
47
+ import {
48
+ NONE,
49
+ PLAN_V2,
50
+ type Funnel,
51
+ type KappaStep,
52
+ type RecallAudit,
53
+ type SecondCheck,
54
+ type SelfTest,
55
+ type WeLoseIf,
56
+ } from "../../domain/study-gates.ts";
57
+ import {
58
+ CLAIM_LOST,
59
+ type ParsedResult,
60
+ type StudyResult,
61
+ } from "../../domain/study-result.ts";
47
62
  import type { StudyRegistryCodec } from "../../ports/study-registry-codec.ts";
48
63
 
49
64
  const study = z.looseObject({
@@ -190,12 +205,81 @@ const decisionShape = z.strictObject({ file: inPaper, note: z.string() });
190
205
  /** The output globs: each once; none yet in a draft. */
191
206
  const outputs = z.array(inFolder).superRefine(once("the output"));
192
207
 
208
+ // ── a plan v2's gates (src/domain/study-gates.ts) ───────────────────────────────────
209
+
210
+ /** A threshold: a finite number. */
211
+ const threshold = z.number().refine(Number.isFinite, "is not a finite number");
212
+
213
+ /** When the claim loses: below a threshold or above one, exactly one of the two. */
214
+ const weLoseIf = z.union([
215
+ z.strictObject({ result: words, below: threshold }),
216
+ z.strictObject({ result: words, above: threshold }),
217
+ ]);
218
+
219
+ /** The literal that says "this study has none of it". */
220
+ const none = z.literal(NONE);
221
+
222
+ const kappaStep = z.strictObject({
223
+ step: words,
224
+ min_kappa: z
225
+ .number()
226
+ .gt(0, "is a kappa above 0")
227
+ .lte(1, "is a kappa of at most 1"),
228
+ });
229
+
230
+ /** The steps judged by two raters, each named once; or none. */
231
+ const secondCheck = z.union([
232
+ none,
233
+ z
234
+ .array(kappaStep)
235
+ .min(1, 'lists no step — write "none" when nothing is labelled')
236
+ .superRefine((steps, ctx) => {
237
+ once("the step")(
238
+ steps.map((t) => t.step),
239
+ ctx,
240
+ );
241
+ }),
242
+ ]);
243
+
244
+ /** A path inside the study's folder: a file or a folder. */
245
+ const inStudyFolder = z
246
+ .string()
247
+ .min(1)
248
+ .refine(staysInside, "is not a path inside the study's folder");
249
+
250
+ const selfTest = z.union([
251
+ none,
252
+ z.strictObject({ made_by: inStudyFolder }),
253
+ z.strictObject({ must_catch: inStudyFolder, must_pass: inStudyFolder }),
254
+ ]);
255
+
256
+ const funnel = z.union([
257
+ none,
258
+ z
259
+ .array(words)
260
+ .min(1, 'lists no reason — write "none" when no unit is dropped')
261
+ .superRefine(once("the reason")),
262
+ ]);
263
+
264
+ /** The audit of the extractor: files a person reads in full, and the least recall in (0, 1]. */
265
+ const recallAudit = z.union([
266
+ none,
267
+ z.strictObject({
268
+ n_files: z.number().int().positive("is at least 1 file"),
269
+ min_recall: z
270
+ .number()
271
+ .gt(0, "is a recall above 0")
272
+ .lte(1, "is a recall of at most 1"),
273
+ }),
274
+ ]);
275
+
193
276
  /** How the outcomes are kept from the decisions: exactly one mode, by its `kind`. */
194
277
  const mode = z.discriminatedUnion("kind", [
195
278
  z.strictObject({
196
279
  kind: z.literal("decide_before_run"),
197
280
  outputs,
198
281
  decision: decisionShape,
282
+ we_lose_if: weLoseIf.nullable().optional(),
199
283
  }),
200
284
  z.strictObject({ kind: z.literal("exploratory"), reason: z.string() }),
201
285
  ]);
@@ -209,8 +293,68 @@ const planShape = z.strictObject({
209
293
  author_check: authorCheck.nullable(),
210
294
  extra: extra.optional(),
211
295
  mode: mode.optional(),
296
+ $schema: z.literal(PLAN_V2).optional(),
297
+ second_check: secondCheck.nullable().optional(),
298
+ self_test: selfTest.nullable().optional(),
299
+ funnel: funnel.nullable().optional(),
300
+ recall_audit: recallAudit.nullable().optional(),
212
301
  });
213
302
 
303
+ /**
304
+ * The JSON Schema of a plan v2, generated from the parser's own shape: what an editor offers for a
305
+ * `plan.json` whose `$schema` names it. Shipped as `schemas/plan.v2.schema.json`; a test holds the
306
+ * file to this. The rules, not the schema, decide: a gate left out of a decide-first mode, or a
307
+ * draft, is theirs to report.
308
+ */
309
+ export const planV2JsonSchema = (): unknown =>
310
+ z.toJSONSchema(
311
+ planShape.extend({
312
+ $schema: z.literal(PLAN_V2),
313
+ second_check: secondCheck.nullable(),
314
+ self_test: selfTest.nullable(),
315
+ funnel: funnel.nullable(),
316
+ recall_audit: recallAudit.nullable(),
317
+ }),
318
+ { io: "input", unrepresentable: "any" },
319
+ );
320
+
321
+ /** The gates a plan v2 declares at its top, each present (null while undecided). */
322
+ const V2_GATES = [
323
+ "second_check",
324
+ "self_test",
325
+ "funnel",
326
+ "recall_audit",
327
+ ] as const;
328
+
329
+ /**
330
+ * A plan v2 declares every gate, and a decide-first mode its `we_lose_if`; a plan v1 declares none
331
+ * of them: a gate in a plan that does not say it is v2 would be read by nobody.
332
+ */
333
+ const gatesOfItsVersion = (
334
+ p: Readonly<z.output<typeof planShape>>,
335
+ ctx: Readonly<z.core.$RefinementCtx>,
336
+ ): void => {
337
+ const v2 = p.$schema !== undefined;
338
+ const decided = p.mode?.kind === "decide_before_run" ? p.mode : undefined;
339
+ const gates = [
340
+ ...V2_GATES.map((g) => ({ path: [g], value: p[g] })),
341
+ ...(decided === undefined
342
+ ? []
343
+ : [{ path: ["mode", "we_lose_if"], value: decided.we_lose_if }]),
344
+ ];
345
+ gates
346
+ .filter((g) => (g.value === undefined) === v2)
347
+ .forEach((g) => {
348
+ ctx.addIssue({
349
+ code: "custom",
350
+ path: g.path,
351
+ message: v2
352
+ ? 'is missing, and a plan v2 declares it — "none" when the study has none of it, null until decided'
353
+ : `is a field of a plan v2 — add "$schema": "${PLAN_V2}"`,
354
+ });
355
+ });
356
+ };
357
+
214
358
  /**
215
359
  * A plan under its old name, `prereg.json`, written by hand before the plan had a schema: open at
216
360
  * every level, its own top-level keys kept in `extra`. A seed makes the draw seeded, none (or null) a
@@ -291,6 +435,60 @@ const modeEmpties = (
291
435
  );
292
436
  };
293
437
 
438
+ /** A count: a whole number, 0 or more. */
439
+ const count = z.number().int().nonnegative();
440
+
441
+ /**
442
+ * A study's `result.json` (src/domain/study-result.ts): open — the study's own numbers by any name —
443
+ * with the four parts the gates read, each of a fixed shape when present.
444
+ */
445
+ const studyResult = z.looseObject({
446
+ claim: z.literal(CLAIM_LOST).optional(),
447
+ // Cohen's kappa lies in -1..1: a percentage (75) or a count is refused here, not compared with a bar.
448
+ kappa: z
449
+ .record(
450
+ z.string(),
451
+ z
452
+ .number()
453
+ .gte(-1, "is a kappa of at least -1")
454
+ .lte(1, "is a kappa of at most 1"),
455
+ )
456
+ .optional(),
457
+ self_test: z.strictObject({ passed: count, failed: count }).optional(),
458
+ funnel: z.record(z.string(), count).optional(),
459
+ // The counts of the audit, not a share: the files are checked against the plan, the recall computed.
460
+ recall_audit: z
461
+ .strictObject({
462
+ files: count,
463
+ units_in_files: z
464
+ .number()
465
+ .int()
466
+ .positive(
467
+ "is at least 1 unit — an audit that found none measures no recall",
468
+ ),
469
+ extracted: count,
470
+ })
471
+ .refine(
472
+ (c) => c.extracted <= c.units_in_files,
473
+ "counts more units extracted than the person found in the files",
474
+ )
475
+ .optional(),
476
+ });
477
+
478
+ /** A plan v2's gates still null, by their field: decided is anything but null. */
479
+ const gateEmpties = (p: StudyPlan): readonly PlanTextField[] =>
480
+ PLAN_TEXT_FIELDS.filter(
481
+ (f) =>
482
+ (f === "mode.we_lose_if" &&
483
+ p.mode?.kind === "decide_before_run" &&
484
+ p.mode.we_lose_if === null) ||
485
+ ((f === "second_check" ||
486
+ f === "self_test" ||
487
+ f === "funnel" ||
488
+ f === "recall_audit") &&
489
+ p[f] === null),
490
+ );
491
+
294
492
  /** A plan that parsed: ready, or a draft. */
295
493
  type ReadyOrDraft = Exclude<ParsedPlan, { readonly kind: "unreadable" }>;
296
494
 
@@ -308,6 +506,7 @@ const readyOrDraft =
308
506
  const empty = [
309
507
  ...PLAN_TEXT_FIELDS.filter((f) => paths.includes(f)),
310
508
  ...modeEmpties(p, kind),
509
+ ...gateEmpties(p),
311
510
  ];
312
511
  if (empty.length === 0) return { kind: "ready", plan: p };
313
512
  if (p.author_check === null)
@@ -321,7 +520,8 @@ const readyOrDraft =
321
520
  };
322
521
 
323
522
  /** 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));
523
+ const planFor = (kind: StudyKind) =>
524
+ planShape.superRefine(gatesOfItsVersion).transform(readyOrDraft(kind));
325
525
 
326
526
  /**
327
527
  * A `prereg.json`: loose, mapped onto the plan, then ready or draft. Written before modes existed, it
@@ -368,6 +568,13 @@ export type SchemasAreTheDomain = [
368
568
  Same<z.output<typeof legacyPlan>, ReadyOrDraft>,
369
569
  Same<z.output<typeof mode>, StudyMode>,
370
570
  Same<z.output<typeof decisionShape>, Decision>,
571
+ Same<z.output<typeof weLoseIf>, WeLoseIf>,
572
+ Same<z.output<typeof kappaStep>, KappaStep>,
573
+ Same<z.output<typeof secondCheck>, SecondCheck>,
574
+ Same<z.output<typeof selfTest>, SelfTest>,
575
+ Same<z.output<typeof funnel>, Funnel>,
576
+ Same<z.output<typeof recallAudit>, RecallAudit>,
577
+ Same<z.output<typeof studyResult>, StudyResult>,
371
578
  ] extends true[]
372
579
  ? true
373
580
  : never;
@@ -427,8 +634,19 @@ export const parseStudyPlan = (text: string, kind: StudyKind): ParsedPlan =>
427
634
  /** A plan still named `prereg.json`: loose, read only when a row names it. */
428
635
  export const parseLegacyPlan = planParser(legacyPlan);
429
636
 
637
+ /** A study's `result.json` text, parsed once at the boundary: the result, or every reason it is none. */
638
+ export function parseStudyResult(text: string): ParsedResult {
639
+ const json = jsonOf(text);
640
+ if (json === undefined) return { kind: "unreadable", why: "not JSON" };
641
+ const parsed = studyResult.safeParse(json);
642
+ return parsed.success
643
+ ? { kind: "read", result: parsed.data }
644
+ : { kind: "unreadable", why: reasons(parsed.error.issues) };
645
+ }
646
+
430
647
  export const studyRegistryCodec: StudyRegistryCodec = {
431
648
  parse: parseStudyRegistry,
432
649
  parsePlan: parseStudyPlan,
433
650
  parseLegacyPlan,
651
+ parseResult: parseStudyResult,
434
652
  };
@@ -76,6 +76,7 @@ it("the template of a measurement is a draft decided before its run, its decisio
76
76
  kind: "decide_before_run",
77
77
  outputs: [],
78
78
  decision: { file: "repro/P1-S02-x/decision.md", note: "" },
79
+ we_lose_if: null,
79
80
  });
80
81
  expect(empty(planText(t))).toEqual([
81
82
  "question",
@@ -84,6 +85,11 @@ it("the template of a measurement is a draft decided before its run, its decisio
84
85
  "primary_outcome",
85
86
  "mode.outputs",
86
87
  "mode.decision.note",
88
+ "mode.we_lose_if",
89
+ "second_check",
90
+ "self_test",
91
+ "funnel",
92
+ "recall_audit",
87
93
  ]);
88
94
  expect(planTemplate("spike", "P1-S03-y").mode).toBeUndefined();
89
95
  });
package/src/cli.ts CHANGED
@@ -215,6 +215,7 @@ import {
215
215
  outputsBeforeDecisionRules,
216
216
  } from "./outputs-before-decision-rule.ts";
217
217
  import { runStudies } from "./studies.ts";
218
+ import { RESULT_RULE_LEVELS, studyResultRules } from "./study-result-rule.ts";
218
219
  import typography from "#eslint-rules/paper-typography";
219
220
  import texBuild from "#eslint-rules/tex-build";
220
221
  import reviewFrontmatter from "#eslint-rules/review-frontmatter";
@@ -490,11 +491,13 @@ const REPRO_RULES = {
490
491
  ...reproLayoutRules(LAYOUT_RULE_DEPS),
491
492
  ...studyRegistryRules(STUDY_RULE_DEPS),
492
493
  ...outputsBeforeDecisionRules({ ...STUDY_RULE_DEPS, git: GIT }),
494
+ ...studyResultRules(STUDY_RULE_DEPS),
493
495
  };
494
496
  const REPRO_RULE_LEVELS = {
495
497
  ...REPRO_LAYOUT_RULE_LEVELS,
496
498
  ...STUDY_REGISTRY_RULE_LEVELS,
497
499
  ...ORDER_RULE_LEVELS,
500
+ ...RESULT_RULE_LEVELS,
498
501
  };
499
502
 
500
503
  /** Today in this machine's time zone, `YYYY-MM-DD`: the date `paperlint studies new` registers a study on. */
@@ -5,11 +5,14 @@
5
5
  */
6
6
  import { expect, it } from "vitest";
7
7
  import {
8
+ firstOutputAfter,
8
9
  judgeOrder,
9
10
  orderMessage,
10
11
  type DecideFirstStudy,
11
12
  type OrderFinding,
13
+ type PlanThen,
12
14
  } from "./decide-first.ts";
15
+ import type { PlanGates } from "./study-gates.ts";
13
16
 
14
17
  const D = { commit: "d".repeat(40), date: "2026-10-16T10:00:00+05:00" };
15
18
  const A = { commit: "a".repeat(40), date: "2026-10-16T11:00:00+05:00" };
@@ -289,3 +292,131 @@ it("each finding's message: what is wrong, what to do, the rule's page", () => {
289
292
  PAGE,
290
293
  ]);
291
294
  });
295
+
296
+ // ── a plan v2's gates against the plan at the first output ──────────────────────────────────────
297
+
298
+ const GATES: PlanGates = {
299
+ we_lose_if: { result: "share", below: 0.2 },
300
+ second_check: [{ step: "fits", min_kappa: 0.6 }],
301
+ self_test: "none",
302
+ funnel: ["no linter rule"],
303
+ recall_audit: "none",
304
+ };
305
+ const OUT = { path: "RESULTS.md", ...A, order: "after" as const };
306
+ const withGates = (
307
+ then: PlanThen,
308
+ now: PlanGates = GATES,
309
+ ): DecideFirstStudy => ({
310
+ ...STUDY,
311
+ gates: { now, output: OUT, then },
312
+ });
313
+
314
+ it("the first output after the decision is the one the gates are held to; none while none followed it", () => {
315
+ // Guards: the study's own files and outputs not after the decision are not it.
316
+ expect([
317
+ firstOutputAfter(
318
+ STUDY,
319
+ STUDY.facts.kind === "read" ? STUDY.facts.files : [],
320
+ ),
321
+ firstOutputAfter(STUDY, [{ path: "RESULTS.md", ...D, order: "same" }]),
322
+ ]).toEqual([OUT, null]);
323
+ });
324
+
325
+ it("gates as they stood at the first output are silent; each one changed since is a finding", () => {
326
+ const moved: PlanGates = {
327
+ ...GATES,
328
+ we_lose_if: { result: "share", below: 0.1 },
329
+ funnel: ["no linter rule", "clone failed"],
330
+ recall_audit: { n_files: 50, min_recall: 0.5 },
331
+ };
332
+ const at = {
333
+ id: "P1-S01",
334
+ folder: "P1-S01-x",
335
+ plan: "plan.json",
336
+ output: OUT,
337
+ };
338
+ // Guards: deep equality, not identity; one finding per changed gate, in the gates' order.
339
+ expect([
340
+ judgeOrder(withGates({ kind: "gates", gates: structuredClone(GATES) })),
341
+ judgeOrder(withGates({ kind: "gates", gates: GATES }, moved)),
342
+ ]).toEqual([
343
+ [],
344
+ [
345
+ {
346
+ problem: "gates-changed",
347
+ ...at,
348
+ gate: "we_lose_if",
349
+ then: { result: "share", below: 0.2 },
350
+ now: { result: "share", below: 0.1 },
351
+ },
352
+ {
353
+ problem: "gates-changed",
354
+ ...at,
355
+ gate: "funnel",
356
+ then: ["no linter rule"],
357
+ now: ["no linter rule", "clone failed"],
358
+ },
359
+ {
360
+ problem: "gates-changed",
361
+ ...at,
362
+ gate: "recall_audit",
363
+ then: "none",
364
+ now: { n_files: 50, min_recall: 0.5 },
365
+ },
366
+ ],
367
+ ]);
368
+ });
369
+
370
+ it("no gates at the first output is a finding with its reason; a history git cannot read is said", () => {
371
+ const at = {
372
+ id: "P1-S01",
373
+ folder: "P1-S01-x",
374
+ plan: "plan.json",
375
+ output: OUT,
376
+ };
377
+ expect([
378
+ judgeOrder(withGates({ kind: "no-gates", why: "absent" })),
379
+ judgeOrder(withGates({ kind: "unknown", why: "fatal: x" })),
380
+ ]).toEqual([
381
+ [{ problem: "gates-not-fixed", ...at, why: "absent" }],
382
+ [{ problem: "no-history", id: "P1-S01", why: "fatal: x" }],
383
+ ]);
384
+ });
385
+
386
+ it("the gates' messages: which gate, then and now, the output's commit, and what to do", () => {
387
+ const at = {
388
+ id: "P1-S01",
389
+ folder: "P1-S01-x",
390
+ plan: "plan.json",
391
+ output: OUT,
392
+ };
393
+ const when = `\`RESULTS.md\`, entered history in ${"a".repeat(12)} (${A.date})`;
394
+ const fix =
395
+ "What to do: run the study again now that the plan v2 is committed; if the outputs must stand, declare the study `exploratory` with the reason.";
396
+ // Guards: the whole text for each finding and each reason.
397
+ expect(
398
+ [
399
+ {
400
+ problem: "gates-changed" as const,
401
+ ...at,
402
+ gate: "we_lose_if" as const,
403
+ then: { result: "share", below: 0.2 },
404
+ now: { result: "share", below: 0.1 },
405
+ },
406
+ ...(["absent", "v1", "unreadable"] as const).map((why) => ({
407
+ problem: "gates-not-fixed" as const,
408
+ ...at,
409
+ why,
410
+ })),
411
+ ].map(orderMessage),
412
+ ).toEqual([
413
+ `The gate \`we_lose_if\` in the plan of \`P1-S01\`, \`repro/P1-S01-x/plan.json\`, was {"result":"share","below":0.2} when its first output, ${when}, and is {"result":"share","below":0.1} now. A gate is fixed before the run: moved after a result exists, it can only follow the result. What to do: put the gate back as it was; if it was wrong, say so in the paper and run the study again under the corrected plan — never edit the history to make it pass.` +
414
+ PAGE,
415
+ `The plan of \`P1-S01\`, \`repro/P1-S01-x/plan.json\`, was not in history yet when its first output, ${when}: its gates were not fixed before the run. ${fix}` +
416
+ PAGE,
417
+ `The plan of \`P1-S01\`, \`repro/P1-S01-x/plan.json\`, was a plan v1, which declares no gates, when its first output, ${when}: its gates were not fixed before the run. ${fix}` +
418
+ PAGE,
419
+ `The plan of \`P1-S01\`, \`repro/P1-S01-x/plan.json\`, was no plan paperlint could read when its first output, ${when}: its gates were not fixed before the run. ${fix}` +
420
+ PAGE,
421
+ ]);
422
+ });