paperlint 10.3.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 (139) 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 +70 -10
  20. package/dist/adapters/study-registry/index.d.ts.map +1 -1
  21. package/dist/adapters/study-registry/index.js +84 -10
  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 +33 -3
  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 +57 -10
  39. package/dist/domain/study-registry.d.ts.map +1 -1
  40. package/dist/domain/study-registry.js +106 -30
  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 +6 -3
  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 +50 -22
  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 +17 -1
  73. package/dist/study-registry-rule.d.ts.map +1 -1
  74. package/dist/study-registry-rule.js +34 -13
  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 +6 -0
  82. package/docs/e2e.md +8 -4
  83. package/docs/layout.md +22 -19
  84. package/docs/rules/repro/outputs-before-decision.md +119 -0
  85. package/docs/rules/repro/study-registered.md +47 -14
  86. package/docs/rules.md +54 -53
  87. package/docs/testing.md +1 -0
  88. package/package.json +2 -1
  89. package/scripts/check.ts +6 -0
  90. package/skills/camera-ready/SKILL.md +6 -1
  91. package/skills/camera-ready/SKILL.md.spec.ts +5 -0
  92. package/skills/draft-paper/SKILL.md +11 -3
  93. package/skills/draft-paper/SKILL.md.spec.ts +10 -2
  94. package/skills/harden-paper/SKILL.md +12 -2
  95. package/skills/harden-paper/SKILL.md.spec.ts +11 -1
  96. package/skills/osf-artifact-upload/SKILL.md +7 -1
  97. package/skills/osf-artifact-upload/SKILL.md.spec.ts +6 -0
  98. package/skills/pc-panel-review/SKILL.md +10 -3
  99. package/skills/pc-panel-review/SKILL.md.spec.ts +9 -2
  100. package/skills/tighten-paper/SKILL.md +5 -1
  101. package/skills/tighten-paper/SKILL.md.spec.ts +4 -0
  102. package/src/adapters/git/history.test.ts +121 -0
  103. package/src/adapters/git/index.test.ts +268 -0
  104. package/src/adapters/git/index.ts +227 -0
  105. package/src/adapters/memory/index.test.ts +15 -1
  106. package/src/adapters/memory/index.ts +13 -0
  107. package/src/adapters/node/index.ts +1 -0
  108. package/src/adapters/node/list-entries.io.ts +26 -0
  109. package/src/adapters/node/list-entries.test.ts +43 -0
  110. package/src/adapters/study-registry/index.test.ts +12 -9
  111. package/src/adapters/study-registry/index.ts +130 -24
  112. package/src/adapters/study-registry/mode.test.ts +162 -0
  113. package/src/cli.ts +47 -3
  114. package/src/domain/decide-first.test.ts +258 -0
  115. package/src/domain/decide-first.ts +243 -0
  116. package/src/domain/index-columns.test.ts +216 -0
  117. package/src/domain/index-columns.ts +105 -0
  118. package/src/domain/study-measurements.test.ts +9 -5
  119. package/src/domain/study-mode.test.ts +55 -0
  120. package/src/domain/study-mode.ts +78 -0
  121. package/src/domain/study-registry.test.ts +38 -31
  122. package/src/domain/study-registry.ts +170 -31
  123. package/src/new-paper.test.ts +5 -1
  124. package/src/outputs-before-decision-rule.test.ts +206 -0
  125. package/src/outputs-before-decision-rule.ts +124 -0
  126. package/src/ports/git-history.ts +44 -0
  127. package/src/ports/list-entries.ts +18 -0
  128. package/src/ports/study-registry-codec.ts +10 -3
  129. package/src/rule-cases.test.ts +78 -3
  130. package/src/studies-index-modes.test.ts +129 -0
  131. package/src/studies.test.ts +35 -11
  132. package/src/studies.ts +88 -27
  133. package/src/study-gate.test.ts +316 -0
  134. package/src/study-gate.ts +161 -0
  135. package/src/study-index-input.ts +23 -0
  136. package/src/study-modes.ts +120 -0
  137. package/src/study-registry-rule.test.ts +198 -11
  138. package/src/study-registry-rule.ts +51 -14
  139. package/src/study-run.ts +21 -0
@@ -0,0 +1,243 @@
1
+ /**
2
+ * DECIDE FIRST — the default mode of a measurement study (src/domain/study-mode.ts): the author's
3
+ * decisions on the inputs are committed before any output exists, and git's history is the proof of
4
+ * order. `repro/outputs-before-decision` judges it here, from facts git gave (src/adapters/git/):
5
+ *
6
+ * - each output in the tree or ever committed while the decision file was never committed;
7
+ * - each output whose FIRST commit is not a strict descendant of the decision file's FIRST commit —
8
+ * earlier, on a line of history the decision is not on, or in the very same commit (which says
9
+ * nothing about which came first);
10
+ * - a decision file the outputs match (it would be "produced" by the run it gates);
11
+ * - a decision file committed and since deleted: it is not in HEAD, so there is no decision;
12
+ * - a history that cannot be read: no git, or a shallow clone, whose every file starts at the clone.
13
+ *
14
+ * Outputs that were committed and later deleted count: an output that existed before the decision is
15
+ * evidence after it is gone. Pure.
16
+ */
17
+ import { README } from "./repro-layout.ts";
18
+ import {
19
+ decisionAsOutput,
20
+ inStudy,
21
+ isOutput,
22
+ type DecideFirst,
23
+ } from "./study-mode.ts";
24
+
25
+ /** What a decision path names on disk when it is no file git can vouch for. */
26
+ export type NotAFile = "directory" | "symbolic link";
27
+
28
+ /** A commit, and its committer date (ISO 8601 with its offset). */
29
+ export interface CommitAt {
30
+ readonly commit: string;
31
+ readonly date: string;
32
+ }
33
+
34
+ /** Where a file's first commit stands against the decision's first commit. */
35
+ export type Order =
36
+ /** A strict descendant: the file entered history after the decision. */
37
+ | "after"
38
+ /** The same commit: nothing in history says which came first. */
39
+ | "same"
40
+ /** Not a descendant: earlier, or on a line of history the decision is not on — or no decision. */
41
+ | "not-after";
42
+
43
+ /** The first commit of a file under the study's folder, and its order against the decision. */
44
+ export interface FirstCommit extends CommitAt {
45
+ /** Relative to the study's folder. */
46
+ readonly path: string;
47
+ readonly order: Order;
48
+ }
49
+
50
+ /**
51
+ * What git's history says of a decide-first study: when its decision file entered history, and when
52
+ * every file ever committed under the study's folder did — or, when the decision names a directory,
53
+ * that history was not asked (git answers for a directory with the commits of anything under it).
54
+ */
55
+ export type OrderFacts =
56
+ | { readonly kind: "not-a-file"; readonly what: NotAFile }
57
+ | { readonly kind: "unknown"; readonly why: string }
58
+ /** A shallow clone: every file's first commit is the clone's first, so no order can be read. */
59
+ | { readonly kind: "shallow" }
60
+ | {
61
+ readonly kind: "read";
62
+ /** null: the decision file was never committed. */
63
+ readonly decision: CommitAt | null;
64
+ /** The decision was committed, then deleted: it is not in HEAD. */
65
+ readonly decisionDeleted: boolean;
66
+ readonly files: readonly FirstCommit[];
67
+ };
68
+
69
+ /** What the judge reads of one decide-first study. */
70
+ export interface DecideFirstStudy {
71
+ readonly id: string;
72
+ readonly folder: string;
73
+ /** The plan's path in the folder. */
74
+ readonly plan: string;
75
+ readonly mode: DecideFirst;
76
+ /** Every file under the study's folder now, relative to it. */
77
+ readonly tree: readonly string[];
78
+ readonly facts: OrderFacts;
79
+ }
80
+
81
+ /** One defect `repro/outputs-before-decision` reports. */
82
+ export type OrderFinding =
83
+ | {
84
+ readonly problem: "decision-is-output";
85
+ readonly id: string;
86
+ readonly file: string;
87
+ readonly glob: string;
88
+ }
89
+ | {
90
+ readonly problem: "decision-deleted";
91
+ readonly id: string;
92
+ readonly file: string;
93
+ }
94
+ | {
95
+ readonly problem: "decision-not-file";
96
+ readonly id: string;
97
+ readonly file: string;
98
+ readonly what: NotAFile;
99
+ }
100
+ | {
101
+ readonly problem: "no-history";
102
+ readonly id: string;
103
+ readonly why: string;
104
+ }
105
+ | { readonly problem: "shallow"; readonly id: string }
106
+ | {
107
+ readonly problem: "undecided-output";
108
+ readonly id: string;
109
+ readonly folder: string;
110
+ readonly path: string;
111
+ readonly decision: string;
112
+ }
113
+ | {
114
+ readonly problem: "output-before-decision";
115
+ readonly id: string;
116
+ readonly folder: string;
117
+ readonly path: string;
118
+ readonly output: CommitAt;
119
+ readonly decision: CommitAt;
120
+ readonly same: boolean;
121
+ };
122
+
123
+ /** Whether `path` (relative to the study's folder) is an output and none of the study's own files. */
124
+ const outputOf =
125
+ (s: DecideFirstStudy) =>
126
+ (path: string): boolean =>
127
+ ![README, s.plan, inStudy(s.folder, s.mode.decision.file)].includes(path) &&
128
+ isOutput(s.mode.outputs, path);
129
+
130
+ /** The files of a read history that are outputs of the study, oldest first commit first. */
131
+ export const outputCommits = (
132
+ s: DecideFirstStudy,
133
+ files: readonly FirstCommit[],
134
+ ): readonly FirstCommit[] => files.filter((f) => outputOf(s)(f.path));
135
+
136
+ /** The findings of a history that was read. */
137
+ const ordered = (
138
+ s: DecideFirstStudy,
139
+ decision: CommitAt | null,
140
+ files: readonly FirstCommit[],
141
+ ): readonly OrderFinding[] => {
142
+ const isOut = outputOf(s);
143
+ const at = { id: s.id, folder: s.folder };
144
+ if (decision === null)
145
+ // The tree's outputs, then those only history holds: deleting an output does not erase it.
146
+ return [...new Set([...s.tree, ...files.map((f) => f.path)])]
147
+ .filter(isOut)
148
+ .map((path) => ({
149
+ problem: "undecided-output",
150
+ ...at,
151
+ path,
152
+ decision: s.mode.decision.file,
153
+ }));
154
+ return files
155
+ .filter((f) => isOut(f.path) && f.order !== "after")
156
+ .map((f) => ({
157
+ problem: "output-before-decision",
158
+ ...at,
159
+ path: f.path,
160
+ output: { commit: f.commit, date: f.date },
161
+ decision,
162
+ same: f.order === "same",
163
+ }));
164
+ };
165
+
166
+ /** Every defect of a decide-first study, in a fixed order: the plan's, then the history's. */
167
+ export const judgeOrder = (s: DecideFirstStudy): readonly OrderFinding[] => {
168
+ const glob = decisionAsOutput(s.folder, s.mode);
169
+ const clash: readonly OrderFinding[] =
170
+ glob === null
171
+ ? []
172
+ : [
173
+ {
174
+ problem: "decision-is-output",
175
+ id: s.id,
176
+ file: s.mode.decision.file,
177
+ glob,
178
+ },
179
+ ];
180
+ return [...clash, ...historyFindings(s)];
181
+ };
182
+
183
+ /** The findings of a decide-first study's history, whatever git could say of it. */
184
+ const historyFindings = (s: DecideFirstStudy): readonly OrderFinding[] => {
185
+ const file = s.mode.decision.file;
186
+ switch (s.facts.kind) {
187
+ case "not-a-file":
188
+ return [
189
+ { problem: "decision-not-file", id: s.id, file, what: s.facts.what },
190
+ ];
191
+ case "unknown":
192
+ return [{ problem: "no-history", id: s.id, why: s.facts.why }];
193
+ case "shallow":
194
+ return [{ problem: "shallow", id: s.id }];
195
+ case "read":
196
+ // A decision deleted since is no decision: the order is not judged against it.
197
+ return s.facts.decisionDeleted
198
+ ? [{ problem: "decision-deleted", id: s.id, file }]
199
+ : ordered(s, s.facts.decision, s.facts.files);
200
+ }
201
+ };
202
+
203
+ const RULE = "repro/outputs-before-decision";
204
+
205
+ /** Each finding by its problem. */
206
+ type FindingOf = {
207
+ readonly [F in OrderFinding as F["problem"]]: F;
208
+ };
209
+
210
+ /** Why git's history says nothing of a decision that is not a file. */
211
+ const NOT_A_FILE: Readonly<Record<NotAFile, string>> = {
212
+ directory:
213
+ "git answers for a directory with the commits of anything under it",
214
+ "symbolic link": "git records the link, not the file it points to",
215
+ };
216
+
217
+ const short = (c: CommitAt): string => `${c.commit.slice(0, 12)} (${c.date})`;
218
+
219
+ const HEADS: {
220
+ readonly [P in keyof FindingOf]: (f: FindingOf[P]) => string;
221
+ } = {
222
+ "decision-is-output": (f) =>
223
+ `The decision of \`${f.id}\`, \`${f.file}\`, is matched by its output \`${f.glob}\`: the decision would be one of the files its own run produces. What to do: keep the decision out of the outputs — a file the globs do not match, or one elsewhere in the paper — and set \`mode.decision.file\`.`,
224
+ "decision-deleted": (f) =>
225
+ `The decision of \`${f.id}\`, \`${f.file}\`, was committed and then deleted: it is not in HEAD, so the study has no decision its outputs could follow. What to do: restore the decision file as it was decided and commit it — never write a new one after the outputs; if the decision no longer holds, declare the study \`exploratory\` with the reason.`,
226
+ "decision-not-file": (f) =>
227
+ `The decision of \`${f.id}\`, \`${f.file}\`, is a ${f.what}, not a file: ${NOT_A_FILE[f.what]}, so no order can be read from it. What to do: name the decision file itself in \`mode.decision.file\`.`,
228
+ "no-history": (f) =>
229
+ `\`${f.id}\` is decided before its run, and git's history is the proof of that order, but git could not answer: ${f.why}. What to do: lint the paper inside its git repository, with git installed.`,
230
+ shallow: (f) =>
231
+ `\`${f.id}\` is decided before its run, and this clone is shallow: every file's first commit is the clone's first, so the order of decision and outputs cannot be read. What to do: fetch the whole history (\`git fetch --unshallow\`; in GitHub Actions, \`actions/checkout\` with \`fetch-depth: 0\`).`,
232
+ "undecided-output": (f) =>
233
+ `\`repro/${f.folder}/${f.path}\` is an output of \`${f.id}\` — in the tree, or in its history — and the study is decided before its run, but its decision \`${f.decision}\` was never committed. What to do: write and commit the decision, check it with \`npx paperlint studies gate <paper-dir> ${f.id}\`, then run the study again; if the output was made before any decision, declare the study \`exploratory\` with the reason. Deleting the output does not undo it: history keeps it.`,
234
+ "output-before-decision": (f) =>
235
+ `\`repro/${f.folder}/${f.path}\`, an output of \`${f.id}\`, entered history in ${short(f.output)}, ${f.same ? "the very commit" : "not after the commit"} that first recorded its decision, ${short(f.decision)}. The study is declared decided before its run, and its history does not show that order. What to do: if the decision really came first, nothing in this history can prove it — declare the study \`exploratory\` with the reason, or run it again after the decision; never rewrite the history to make it pass.`,
236
+ };
237
+
238
+ const headOf = <P extends keyof FindingOf>(p: P, f: FindingOf[P]): string =>
239
+ HEADS[p](f);
240
+
241
+ /** The one text a finding is reported with: what is wrong, what to do, and the rule's page. */
242
+ export const orderMessage = (f: OrderFinding): string =>
243
+ `${headOf(f.problem, f)}\nRule ${RULE}: https://github.com/zernie/paperlint/blob/main/docs/rules/${RULE}.md`;
@@ -0,0 +1,216 @@
1
+ /**
2
+ * The index's mode columns (`src/domain/index-columns.ts`) and the table that carries them
3
+ * (`studyIndex`, `indexInSync`, `indexCurrent` in `src/domain/study-registry.ts`): the mode from the
4
+ * plan, the decision's and the first output's commits from git's history, and the two git columns
5
+ * left out of what the lint compares.
6
+ */
7
+ import { describe, expect, it } from "vitest";
8
+ import type { DecideFirstStudy } from "./decide-first.ts";
9
+ import { decisionCell, indexHistory } from "./index-columns.ts";
10
+ import type { DecideFirst } from "./study-mode.ts";
11
+ import {
12
+ indexCurrent,
13
+ indexInSync,
14
+ studyIndex,
15
+ withIndex,
16
+ type IndexInput,
17
+ type PlanState,
18
+ type Study,
19
+ type StudyPlan,
20
+ type StudyRegistry,
21
+ } from "./study-registry.ts";
22
+
23
+ const D = { commit: "d".repeat(40), date: "2026-10-14T09:12:00+05:00" };
24
+ const A = { commit: "a".repeat(40), date: "2026-10-16T11:00:00+05:00" };
25
+
26
+ const DECIDED: DecideFirst = {
27
+ kind: "decide_before_run",
28
+ outputs: ["RESULTS.md"],
29
+ decision: { file: "repro/P1-S01-hint/decision.md", note: "exclusions" },
30
+ };
31
+
32
+ const plan = (mode: StudyPlan["mode"]): PlanState => ({
33
+ kind: "ready",
34
+ plan: {
35
+ question: "q?",
36
+ population: { source: "s", draw: { kind: "all", method: "census" } },
37
+ arms: [],
38
+ primary_outcome: "o",
39
+ author_check: null,
40
+ mode,
41
+ },
42
+ });
43
+
44
+ const row = (n: number, over: Partial<Study> = {}): Study => ({
45
+ id: `P1-S0${String(n)}`,
46
+ folder: `P1-S0${String(n)}-s`,
47
+ title: `S${String(n)}`,
48
+ question: "q?",
49
+ kind: "measurement",
50
+ status: "running",
51
+ date: "2026-10-15",
52
+ in_paper: "no",
53
+ plan: "plan.json",
54
+ ...over,
55
+ });
56
+
57
+ /** Nine studies, one per state a mode cell can be in. */
58
+ const NINE: StudyRegistry = {
59
+ prefix: "P1",
60
+ studies: [
61
+ row(1, { folder: "P1-S01-hint" }),
62
+ row(2),
63
+ row(3),
64
+ row(4),
65
+ row(5),
66
+ row(6, { kind: "spike", plan: undefined }),
67
+ row(7, { plan: "../plan.json" }),
68
+ row(8),
69
+ row(9),
70
+ ],
71
+ };
72
+ /** Their plans and histories. */
73
+ const NINE_AT: IndexInput = {
74
+ plans: new Map<string, PlanState>([
75
+ ["P1-S01-hint/plan.json", plan(DECIDED)],
76
+ ["P1-S02-s/plan.json", plan(DECIDED)],
77
+ ["P1-S03-s/plan.json", plan({ kind: "exploratory", reason: "a | look" })],
78
+ ["P1-S04-s/plan.json", plan(DECIDED)],
79
+ ["P1-S05-s/plan.json", plan(DECIDED)],
80
+ ["P1-S08-s/plan.json", { kind: "unreadable", why: "not JSON" }],
81
+ ["P1-S09-s/plan.json", plan(undefined)],
82
+ ]),
83
+ history: new Map([
84
+ [
85
+ "P1-S01",
86
+ { kind: "read", decision: D, decisionDeleted: false, firstOutput: A },
87
+ ],
88
+ [
89
+ "P1-S02",
90
+ {
91
+ kind: "read",
92
+ decision: null,
93
+ decisionDeleted: false,
94
+ firstOutput: null,
95
+ },
96
+ ],
97
+ ["P1-S04", { kind: "unread", why: "shallow clone" }],
98
+ ]),
99
+ };
100
+
101
+ describe("the mode columns of the index", () => {
102
+ it("say each study's mode and, for one decided before its run, its two commits", () => {
103
+ // Guards: the sample row a reader and the review skills read — decided before the run, the
104
+ // decision's first commit and date, the first output's — and every other state its own words.
105
+ expect(studyIndex(NINE, NINE_AT).split("\n").slice(2)).toEqual([
106
+ "| P1-S01 | S1 | running | — | no | decide_before_run | dddddddddddd 2026-10-14 | aaaaaaaaaaaa 2026-10-16 | [P1-S01-hint/](P1-S01-hint/) |",
107
+ "| P1-S02 | S2 | running | — | no | decide_before_run | not committed | none | [P1-S02-s/](P1-S02-s/) |",
108
+ "| P1-S03 | S3 | running | — | no | exploratory: a \\| look | — | — | [P1-S03-s/](P1-S03-s/) |",
109
+ "| P1-S04 | S4 | running | — | no | decide_before_run | unknown (shallow clone) | unknown (shallow clone) | [P1-S04-s/](P1-S04-s/) |",
110
+ "| P1-S05 | S5 | running | — | no | decide_before_run | not read | not read | [P1-S05-s/](P1-S05-s/) |",
111
+ "| P1-S06 | S6 | running | — | no | — | — | — | [P1-S06-s/](P1-S06-s/) |",
112
+ "| P1-S07 | S7 | running | — | no | — | — | — | [P1-S07-s/](P1-S07-s/) |",
113
+ "| P1-S08 | S8 | running | — | no | — | — | — | [P1-S08-s/](P1-S08-s/) |",
114
+ "| P1-S09 | S9 | running | — | no | — | — | — | [P1-S09-s/](P1-S09-s/) |",
115
+ ]);
116
+ });
117
+
118
+ it("the lint compares the mode, never the two commits; `studies index` rewrites on either", () => {
119
+ const r: StudyRegistry = { prefix: "P1", studies: [row(1)] };
120
+ const plans = new Map([["P1-S01-s/plan.json", plan(DECIDED)]]);
121
+ const was: IndexInput = { plans, history: new Map() };
122
+ const readme = withIndex("# index\n", r, was);
123
+ const committed: IndexInput = {
124
+ plans,
125
+ history: new Map([
126
+ [
127
+ "P1-S01",
128
+ {
129
+ kind: "read",
130
+ decision: D,
131
+ decisionDeleted: false,
132
+ firstOutput: null,
133
+ },
134
+ ],
135
+ ]),
136
+ };
137
+ const explored: IndexInput = {
138
+ plans: new Map([
139
+ ["P1-S01-s/plan.json", plan({ kind: "exploratory", reason: "r" })],
140
+ ]),
141
+ history: new Map(),
142
+ };
143
+ // Guards: a squash merge renames the commits, so the lint cannot hold them; the command can.
144
+ expect([
145
+ indexInSync(readme, r, committed),
146
+ indexCurrent(readme, r, committed),
147
+ indexInSync(readme, r, explored),
148
+ indexCurrent(readme, r, was),
149
+ indexCurrent(null, r, was),
150
+ ]).toEqual([true, false, false, true, false]);
151
+ });
152
+ });
153
+
154
+ it("a decision committed and since deleted shows its commit, and that it is gone", () => {
155
+ // Guards: the Methods sentence must not cite a decision that no longer stands.
156
+ expect(
157
+ decisionCell({
158
+ mode: DECIDED,
159
+ history: {
160
+ kind: "read",
161
+ decision: D,
162
+ decisionDeleted: true,
163
+ firstOutput: null,
164
+ },
165
+ }),
166
+ ).toBe("dddddddddddd 2026-10-14, deleted since");
167
+ });
168
+
169
+ describe("indexHistory", () => {
170
+ const study = (facts: DecideFirstStudy["facts"]): DecideFirstStudy => ({
171
+ id: "P1-S01",
172
+ folder: "P1-S01-hint",
173
+ plan: "plan.json",
174
+ mode: DECIDED,
175
+ tree: [],
176
+ facts,
177
+ });
178
+
179
+ it("is the decision's first commit and the oldest output's, or what kept git from saying", () => {
180
+ expect([
181
+ indexHistory(study({ kind: "unknown", why: "not a repository" })),
182
+ indexHistory(study({ kind: "shallow" })),
183
+ indexHistory(study({ kind: "not-a-file", what: "directory" })),
184
+ indexHistory(
185
+ study({
186
+ kind: "read",
187
+ decision: D,
188
+ decisionDeleted: false,
189
+ files: [
190
+ { path: "decision.md", ...D, order: "same" },
191
+ { path: "RESULTS.md", ...A, order: "after" },
192
+ ],
193
+ }),
194
+ ),
195
+ indexHistory(
196
+ study({
197
+ kind: "read",
198
+ decision: null,
199
+ decisionDeleted: false,
200
+ files: [],
201
+ }),
202
+ ),
203
+ ]).toEqual([
204
+ { kind: "unread", why: "git could not answer" },
205
+ { kind: "unread", why: "shallow clone" },
206
+ { kind: "unread", why: "the decision is a directory" },
207
+ { kind: "read", decision: D, decisionDeleted: false, firstOutput: A },
208
+ {
209
+ kind: "read",
210
+ decision: null,
211
+ decisionDeleted: false,
212
+ firstOutput: null,
213
+ },
214
+ ]);
215
+ });
216
+ });
@@ -0,0 +1,105 @@
1
+ /**
2
+ * THE MODE COLUMNS OF THE INDEX — three columns of `repro/README.md`'s study index beyond the row:
3
+ * the study's mode, as its plan declares it, and for a study decided before its run the commit that
4
+ * first recorded its decision and the commit that first recorded an output (docs/design/study-modes.md).
5
+ *
6
+ * The mode comes from the plan, which is in the tree, so `repro/study-registered` compares it. The
7
+ * two commits come from git's history as `paperlint studies index` read it when it ran: a squash merge
8
+ * or a rebase gives the same files new commits, and a shallow CI clone has none to read, so the lint
9
+ * never compares those two columns. `repro/outputs-before-decision` judges the order itself. Pure.
10
+ */
11
+ import {
12
+ outputCommits,
13
+ type CommitAt,
14
+ type DecideFirstStudy,
15
+ } from "./decide-first.ts";
16
+ import type { StudyMode } from "./study-mode.ts";
17
+
18
+ /** What git's history says of a decide-first study's decision and outputs. */
19
+ export type IndexHistory =
20
+ /** No order could be read: why, in a few words (`shallow clone`). */
21
+ | { readonly kind: "unread"; readonly why: string }
22
+ | {
23
+ readonly kind: "read";
24
+ /** The decision file's first commit; null: never committed. */
25
+ readonly decision: CommitAt | null;
26
+ /** The decision was committed, then deleted: it is not in HEAD. */
27
+ readonly decisionDeleted: boolean;
28
+ /** The first commit of the oldest output; null: no output was ever committed. */
29
+ readonly firstOutput: CommitAt | null;
30
+ };
31
+
32
+ /** What the index shows of a study beyond its row. */
33
+ export interface IndexFacts {
34
+ /** The mode its plan declares; null when its plan declares none, or there is no plan to read. */
35
+ readonly mode: StudyMode | null;
36
+ /** For a decide-first study, its history; null when it was not asked (the lint does not ask). */
37
+ readonly history: IndexHistory | null;
38
+ }
39
+
40
+ /** The cell of a column that does not apply to a study. */
41
+ export const NOT_APPLICABLE = "—";
42
+
43
+ /** A commit as a cell: its first twelve characters, and its date. */
44
+ const commitCell = (c: CommitAt): string =>
45
+ `${c.commit.slice(0, 12)} ${c.date.slice(0, 10)}`;
46
+
47
+ /** The mode cell: `decide_before_run`, `exploratory: <reason>`, or `—`. */
48
+ export const modeCell = (f: IndexFacts): string => {
49
+ if (f.mode === null) return NOT_APPLICABLE;
50
+ return f.mode.kind === "exploratory"
51
+ ? `exploratory: ${f.mode.reason}`
52
+ : f.mode.kind;
53
+ };
54
+
55
+ /** A history cell of a decide-first study: the text `pick` makes of it, `absent` when it makes none. */
56
+ const historyCell =
57
+ (
58
+ pick: (h: IndexHistory & { readonly kind: "read" }) => string | null,
59
+ absent: string,
60
+ ) =>
61
+ (f: IndexFacts): string => {
62
+ if (f.mode?.kind !== "decide_before_run") return NOT_APPLICABLE;
63
+ if (f.history === null) return "not read";
64
+ if (f.history.kind === "unread") return `unknown (${f.history.why})`;
65
+ return pick(f.history) ?? absent;
66
+ };
67
+
68
+ /** The decision cell: its first commit and date (`, deleted since` when it is not in HEAD), or `not committed`. */
69
+ export const decisionCell = historyCell(
70
+ (h) =>
71
+ h.decision === null
72
+ ? null
73
+ : `${commitCell(h.decision)}${h.decisionDeleted ? ", deleted since" : ""}`,
74
+ "not committed",
75
+ );
76
+
77
+ /** The first-output cell: the commit and date of the first output, or `none`. */
78
+ export const firstOutputCell = historyCell(
79
+ (h) => (h.firstOutput === null ? null : commitCell(h.firstOutput)),
80
+ "none",
81
+ );
82
+
83
+ /** The history the index shows of a decide-first study, from the facts git gave. */
84
+ export const indexHistory = (s: DecideFirstStudy): IndexHistory => {
85
+ switch (s.facts.kind) {
86
+ case "not-a-file":
87
+ return { kind: "unread", why: `the decision is a ${s.facts.what}` };
88
+ case "unknown":
89
+ return { kind: "unread", why: "git could not answer" };
90
+ case "shallow":
91
+ return { kind: "unread", why: "shallow clone" };
92
+ case "read": {
93
+ const [first] = outputCommits(s, s.facts.files);
94
+ return {
95
+ kind: "read",
96
+ decision: s.facts.decision,
97
+ decisionDeleted: s.facts.decisionDeleted,
98
+ firstOutput:
99
+ first === undefined
100
+ ? null
101
+ : { commit: first.commit, date: first.date },
102
+ };
103
+ }
104
+ }
105
+ };
@@ -21,8 +21,12 @@ import {
21
21
  type StudyInput,
22
22
  type StudyPlan,
23
23
  type StudyRegistry,
24
+ type IndexInput,
24
25
  } from "./study-registry.ts";
25
26
 
27
+ /** No plan and no history: the index with every mode column `—`. */
28
+ const NO_FACTS: IndexInput = { plans: new Map(), history: new Map() };
29
+
26
30
  const study = (over: Partial<Study> = {}): Study => ({
27
31
  id: "P1-S01",
28
32
  folder: "P1-S01-census",
@@ -105,7 +109,7 @@ const input = (
105
109
  ): StudyInput => ({
106
110
  folders: r.studies.map((s) => s.folder),
107
111
  registry: { kind: "read", registry: r },
108
- readme: withIndex("# Reproduction index\n", r),
112
+ readme: withIndex("# Reproduction index\n", r, NO_FACTS),
109
113
  existingResults: new Set(),
110
114
  plans: new Map(
111
115
  r.studies.flatMap((s) =>
@@ -158,7 +162,7 @@ describe("measurements and plans — silent", () => {
158
162
  ],
159
163
  });
160
164
  const plans = new Map([
161
- [`P1-S01-census/${PLAN_FILE}`, draft(planTemplate())],
165
+ [`P1-S01-census/${PLAN_FILE}`, draft(planTemplate("spike", "x"))],
162
166
  ]);
163
167
  expect(findings(r, { plans })).toEqual([]);
164
168
  });
@@ -325,7 +329,7 @@ describe("plans — filled once the study has run, and named plan.json", () => {
325
329
  measurements: [],
326
330
  });
327
331
  const plans = new Map([
328
- [`P1-S01-census/${PLAN_FILE}`, draft(planTemplate())],
332
+ [`P1-S01-census/${PLAN_FILE}`, draft(planTemplate("spike", "x"))],
329
333
  ]);
330
334
  expect(findings(r, { plans })).toEqual([
331
335
  { problem: "no-result", id: "P1-S01", missing: ["result_file"] },
@@ -460,7 +464,7 @@ describe("planKeys", () => {
460
464
 
461
465
  describe("planTemplate and planText", () => {
462
466
  it("every field present, its value to fill, and author_check null", () => {
463
- expect(planTemplate()).toEqual({
467
+ expect(planTemplate("spike", "x")).toEqual({
464
468
  question: "",
465
469
  population: { source: "", draw: { kind: "all", method: "" } },
466
470
  arms: [],
@@ -532,7 +536,7 @@ const MESSAGES = [
532
536
  why: "unreadable",
533
537
  reasons: "question: required",
534
538
  },
535
- "The plan of `P1-S01`, `plan.json` in its folder, is not a study plan: question: required. What to do: give it the fields on the rule's page — `question`, `population`, `arms`, `primary_outcome`.",
539
+ "The plan of `P1-S01`, `plan.json` in its folder, is not a study plan: question: required. What to do: give it the fields on the rule's page — `question`, `population`, `arms`, `primary_outcome`, and for a measurement its `mode`.",
536
540
  ],
537
541
  [
538
542
  {
@@ -0,0 +1,55 @@
1
+ /**
2
+ * A study's mode (`src/domain/study-mode.ts`): which kinds must declare one, where a decision lies
3
+ * relative to the study, and when a decision would be one of the study's own outputs.
4
+ */
5
+ import { expect, it } from "vitest";
6
+ import {
7
+ decisionAsOutput,
8
+ inStudy,
9
+ needsMode,
10
+ type DecideFirst,
11
+ } from "./study-mode.ts";
12
+
13
+ const DECIDED: DecideFirst = {
14
+ kind: "decide_before_run",
15
+ outputs: ["**/*.md"],
16
+ decision: { file: "repro/S1-x/decision.md", note: "n" },
17
+ };
18
+
19
+ it("only a measurement must declare its mode", () => {
20
+ // Guards: a spike and the other kinds may omit it.
21
+ expect(
22
+ (
23
+ [
24
+ "measurement",
25
+ "spike",
26
+ "tooling",
27
+ "data",
28
+ "artifact",
29
+ "salvage",
30
+ ] as const
31
+ ).map(needsMode),
32
+ ).toEqual([true, false, false, false, false, false]);
33
+ });
34
+
35
+ it("a paper-relative path inside the study's folder, normalised; outside it, none", () => {
36
+ // Guards: `./` and `x/../` spellings land where they point; another folder is not this study's.
37
+ expect([
38
+ inStudy("S1-x", "repro/S1-x/a/b.md"),
39
+ inStudy("S1-x", "./repro/S1-x/z/../c.md"),
40
+ inStudy("S1-x", "repro/S1-xy/c.md"),
41
+ inStudy("S1-x", "research/d.md"),
42
+ ]).toEqual(["a/b.md", "c.md", null, null]);
43
+ });
44
+
45
+ it("the output glob that would swallow the decision; none when it lies elsewhere or no glob matches", () => {
46
+ // Guards: a decision in the folder that an output matches is named by the glob; one elsewhere is not.
47
+ expect([
48
+ decisionAsOutput("S1-x", DECIDED),
49
+ decisionAsOutput("S1-x", { ...DECIDED, outputs: ["out/**"] }),
50
+ decisionAsOutput("S1-x", {
51
+ ...DECIDED,
52
+ decision: { file: "research/d.md", note: "n" },
53
+ }),
54
+ ]).toEqual(["**/*.md", null, null]);
55
+ });