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
@@ -0,0 +1,258 @@
1
+ /**
2
+ * What `repro/outputs-before-decision` decides from git's facts (`src/domain/decide-first.ts`): silent
3
+ * on outputs that came after the decision, loud on each output that did not, on outputs with no
4
+ * decision committed, on a decision the outputs match, and on a history it cannot read.
5
+ */
6
+ import { expect, it } from "vitest";
7
+ import {
8
+ judgeOrder,
9
+ orderMessage,
10
+ type DecideFirstStudy,
11
+ type OrderFinding,
12
+ } from "./decide-first.ts";
13
+
14
+ const D = { commit: "d".repeat(40), date: "2026-10-16T10:00:00+05:00" };
15
+ const A = { commit: "a".repeat(40), date: "2026-10-16T11:00:00+05:00" };
16
+
17
+ const STUDY: DecideFirstStudy = {
18
+ id: "P1-S01",
19
+ folder: "P1-S01-x",
20
+ plan: "plan.json",
21
+ mode: {
22
+ kind: "decide_before_run",
23
+ outputs: ["out/**", "RESULTS.md"],
24
+ decision: { file: "repro/P1-S01-x/decision.md", note: "exclusions" },
25
+ },
26
+ tree: ["plan.json", "README.md", "decision.md", "RESULTS.md", "notes.txt"],
27
+ facts: {
28
+ kind: "read",
29
+ decision: D,
30
+ decisionDeleted: false,
31
+ files: [
32
+ { path: "decision.md", ...D, order: "same" },
33
+ { path: "plan.json", ...D, order: "same" },
34
+ { path: "RESULTS.md", ...A, order: "after" },
35
+ { path: "notes.txt", ...D, order: "same" },
36
+ ],
37
+ },
38
+ };
39
+
40
+ it("silent when every output entered history after the decision; the study's own files never count", () => {
41
+ // Guards: the clean history — and the decision and the plan, committed together, are no outputs.
42
+ expect(judgeOrder(STUDY)).toEqual([]);
43
+ });
44
+
45
+ it("an output in the decision's commit, or not after it, deleted or not, is one finding each", () => {
46
+ const facts = {
47
+ kind: "read",
48
+ decision: D,
49
+ decisionDeleted: false,
50
+ files: [
51
+ { path: "RESULTS.md", ...D, order: "same" },
52
+ { path: "out/gone.csv", ...A, order: "not-after" },
53
+ ],
54
+ } as const;
55
+ // Guards: loud on each defect, the deleted early output included.
56
+ expect(judgeOrder({ ...STUDY, facts })).toEqual([
57
+ {
58
+ problem: "output-before-decision",
59
+ id: "P1-S01",
60
+ folder: "P1-S01-x",
61
+ path: "RESULTS.md",
62
+ output: D,
63
+ decision: D,
64
+ same: true,
65
+ },
66
+ {
67
+ problem: "output-before-decision",
68
+ id: "P1-S01",
69
+ folder: "P1-S01-x",
70
+ path: "out/gone.csv",
71
+ output: A,
72
+ decision: D,
73
+ same: false,
74
+ },
75
+ ]);
76
+ });
77
+
78
+ it("an output in the tree while the decision was never committed", () => {
79
+ // Guards: the run started before any decision existed in history.
80
+ expect(
81
+ judgeOrder({
82
+ ...STUDY,
83
+ facts: {
84
+ kind: "read",
85
+ decision: null,
86
+ decisionDeleted: false,
87
+ files: [],
88
+ },
89
+ }),
90
+ ).toEqual([
91
+ {
92
+ problem: "undecided-output",
93
+ id: "P1-S01",
94
+ folder: "P1-S01-x",
95
+ path: "RESULTS.md",
96
+ decision: "repro/P1-S01-x/decision.md",
97
+ },
98
+ ]);
99
+ });
100
+
101
+ it("an output committed and since deleted, while the decision was never committed, still counts", () => {
102
+ // Guards: history is the evidence — an output that existed before any decision is not erased by
103
+ // deleting it; each path is said once, whether it is in the tree, in history, or both.
104
+ expect(
105
+ judgeOrder({
106
+ ...STUDY,
107
+ tree: ["plan.json", "README.md", "RESULTS.md"],
108
+ facts: {
109
+ kind: "read",
110
+ decision: null,
111
+ decisionDeleted: false,
112
+ files: [
113
+ { path: "plan.json", ...D, order: "not-after" },
114
+ { path: "out/gone.csv", ...A, order: "not-after" },
115
+ { path: "RESULTS.md", ...A, order: "not-after" },
116
+ ],
117
+ },
118
+ }).map((f) => (f.problem === "undecided-output" ? f.path : f.problem)),
119
+ ).toEqual(["RESULTS.md", "out/gone.csv"]);
120
+ });
121
+
122
+ it("a history it cannot read, and a decision the outputs match, are said", () => {
123
+ const clash = {
124
+ ...STUDY,
125
+ mode: { ...STUDY.mode, outputs: ["**/*.md"] },
126
+ };
127
+ // Guards: no git and a shallow clone are findings, never a pass; the clash comes first.
128
+ expect([
129
+ judgeOrder({
130
+ ...STUDY,
131
+ facts: { kind: "unknown", why: "git is not installed" },
132
+ }),
133
+ judgeOrder({ ...clash, facts: { kind: "shallow" } }),
134
+ ]).toEqual([
135
+ [{ problem: "no-history", id: "P1-S01", why: "git is not installed" }],
136
+ [
137
+ {
138
+ problem: "decision-is-output",
139
+ id: "P1-S01",
140
+ file: "repro/P1-S01-x/decision.md",
141
+ glob: "**/*.md",
142
+ },
143
+ { problem: "shallow", id: "P1-S01" },
144
+ ],
145
+ ]);
146
+ });
147
+
148
+ it("a decision committed and since deleted is reported, and no order is judged against it", () => {
149
+ // Guards: outputs that came after a decision that no longer stands are not passed on it.
150
+ expect(
151
+ judgeOrder({
152
+ ...STUDY,
153
+ facts: {
154
+ kind: "read",
155
+ decision: D,
156
+ decisionDeleted: true,
157
+ files: [{ path: "RESULTS.md", ...A, order: "after" }],
158
+ },
159
+ }),
160
+ ).toEqual([
161
+ {
162
+ problem: "decision-deleted",
163
+ id: "P1-S01",
164
+ file: "repro/P1-S01-x/decision.md",
165
+ },
166
+ ]);
167
+ });
168
+
169
+ it("a decision that names a directory is reported, and no history is judged", () => {
170
+ // Guards: the outputs of such a study are never passed on a directory's commits.
171
+ expect(
172
+ judgeOrder({ ...STUDY, facts: { kind: "not-a-file", what: "directory" } }),
173
+ ).toEqual([
174
+ {
175
+ problem: "decision-not-file",
176
+ id: "P1-S01",
177
+ file: "repro/P1-S01-x/decision.md",
178
+ what: "directory",
179
+ },
180
+ ]);
181
+ });
182
+
183
+ const PAGE =
184
+ "\nRule repro/outputs-before-decision: https://github.com/zernie/paperlint/blob/main/docs/rules/repro/outputs-before-decision.md";
185
+
186
+ /** One finding of every problem, the decision's first. */
187
+ const ALL: readonly OrderFinding[] = [
188
+ {
189
+ problem: "decision-is-output",
190
+ id: "P1-S01",
191
+ file: "repro/P1-S01-x/decision.md",
192
+ glob: "**/*.md",
193
+ },
194
+ { problem: "decision-deleted", id: "P1-S01", file: "repro/d.md" },
195
+ {
196
+ problem: "decision-not-file",
197
+ id: "P1-S01",
198
+ file: "repro/d",
199
+ what: "directory",
200
+ },
201
+ {
202
+ problem: "decision-not-file",
203
+ id: "P1-S01",
204
+ file: "repro/l.md",
205
+ what: "symbolic link",
206
+ },
207
+ { problem: "no-history", id: "P1-S01", why: "git is not installed" },
208
+ { problem: "shallow", id: "P1-S01" },
209
+ {
210
+ problem: "undecided-output",
211
+ id: "P1-S01",
212
+ folder: "P1-S01-x",
213
+ path: "RESULTS.md",
214
+ decision: "repro/P1-S01-x/decision.md",
215
+ },
216
+ {
217
+ problem: "output-before-decision",
218
+ id: "P1-S01",
219
+ folder: "P1-S01-x",
220
+ path: "RESULTS.md",
221
+ output: D,
222
+ decision: D,
223
+ same: true,
224
+ },
225
+ {
226
+ problem: "output-before-decision",
227
+ id: "P1-S01",
228
+ folder: "P1-S01-x",
229
+ path: "RESULTS.md",
230
+ output: A,
231
+ decision: D,
232
+ same: false,
233
+ },
234
+ ];
235
+
236
+ it("each finding's message: what is wrong, what to do, the rule's page", () => {
237
+ // Guards: the whole text a person reads, for every problem.
238
+ expect(ALL.map(orderMessage)).toEqual([
239
+ "The decision of `P1-S01`, `repro/P1-S01-x/decision.md`, is matched by its output `**/*.md`: 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`." +
240
+ PAGE,
241
+ "The decision of `P1-S01`, `repro/d.md`, 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." +
242
+ PAGE,
243
+ "The decision of `P1-S01`, `repro/d`, is a directory, not a file: git answers for a directory with the commits of anything under it, so no order can be read from it. What to do: name the decision file itself in `mode.decision.file`." +
244
+ PAGE,
245
+ "The decision of `P1-S01`, `repro/l.md`, is a symbolic link, not a file: git records the link, not the file it points to, so no order can be read from it. What to do: name the decision file itself in `mode.decision.file`." +
246
+ PAGE,
247
+ "`P1-S01` is decided before its run, and git's history is the proof of that order, but git could not answer: git is not installed. What to do: lint the paper inside its git repository, with git installed." +
248
+ PAGE,
249
+ "`P1-S01` 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`)." +
250
+ PAGE,
251
+ "`repro/P1-S01-x/RESULTS.md` is an output of `P1-S01` — in the tree, or in its history — and the study is decided before its run, but its decision `repro/P1-S01-x/decision.md` was never committed. What to do: write and commit the decision, check it with `npx paperlint studies gate <paper-dir> P1-S01`, 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." +
252
+ PAGE,
253
+ `\`repro/P1-S01-x/RESULTS.md\`, an output of \`P1-S01\`, entered history in ${"d".repeat(12)} (${D.date}), the very commit that first recorded its decision, ${"d".repeat(12)} (${D.date}). 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.` +
254
+ PAGE,
255
+ `\`repro/P1-S01-x/RESULTS.md\`, an output of \`P1-S01\`, entered history in ${"a".repeat(12)} (${A.date}), not after the commit that first recorded its decision, ${"d".repeat(12)} (${D.date}). 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.` +
256
+ PAGE,
257
+ ]);
258
+ });
@@ -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`;