paperlint 10.3.0 → 10.4.1

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 (187) hide show
  1. package/.github/workflows/ci.yml +15 -4
  2. package/README.md +1 -0
  3. package/dist/CLAUDE.md.spec.d.ts.map +1 -1
  4. package/dist/CLAUDE.md.spec.js +6 -3
  5. package/dist/CLAUDE.md.spec.js.map +1 -1
  6. package/dist/adapters/git/index.d.ts +10 -0
  7. package/dist/adapters/git/index.d.ts.map +1 -0
  8. package/dist/adapters/git/index.js +151 -0
  9. package/dist/adapters/git/index.js.map +1 -0
  10. package/dist/adapters/latex/captions.d.ts +9 -0
  11. package/dist/adapters/latex/captions.d.ts.map +1 -0
  12. package/dist/adapters/latex/captions.js +40 -0
  13. package/dist/adapters/latex/captions.js.map +1 -0
  14. package/dist/adapters/latex/index.d.ts +1 -0
  15. package/dist/adapters/latex/index.d.ts.map +1 -1
  16. package/dist/adapters/latex/index.js +1 -0
  17. package/dist/adapters/latex/index.js.map +1 -1
  18. package/dist/adapters/latex/parse.d.ts.map +1 -1
  19. package/dist/adapters/latex/parse.js +6 -0
  20. package/dist/adapters/latex/parse.js.map +1 -1
  21. package/dist/adapters/memory/index.d.ts +6 -0
  22. package/dist/adapters/memory/index.d.ts.map +1 -1
  23. package/dist/adapters/memory/index.js +8 -0
  24. package/dist/adapters/memory/index.js.map +1 -1
  25. package/dist/adapters/node/index.d.ts +1 -0
  26. package/dist/adapters/node/index.d.ts.map +1 -1
  27. package/dist/adapters/node/index.js +1 -0
  28. package/dist/adapters/node/index.js.map +1 -1
  29. package/dist/adapters/node/list-entries.io.d.ts +3 -0
  30. package/dist/adapters/node/list-entries.io.d.ts.map +1 -0
  31. package/dist/adapters/node/list-entries.io.js +26 -0
  32. package/dist/adapters/node/list-entries.io.js.map +1 -0
  33. package/dist/adapters/study-registry/index.d.ts +70 -10
  34. package/dist/adapters/study-registry/index.d.ts.map +1 -1
  35. package/dist/adapters/study-registry/index.js +84 -10
  36. package/dist/adapters/study-registry/index.js.map +1 -1
  37. package/dist/cli.d.ts.map +1 -1
  38. package/dist/cli.js +33 -3
  39. package/dist/cli.js.map +1 -1
  40. package/dist/domain/decide-first.d.ts +110 -0
  41. package/dist/domain/decide-first.d.ts.map +1 -0
  42. package/dist/domain/decide-first.js +108 -0
  43. package/dist/domain/decide-first.js.map +1 -0
  44. package/dist/domain/index-columns.d.ts +45 -0
  45. package/dist/domain/index-columns.d.ts.map +1 -0
  46. package/dist/domain/index-columns.js +64 -0
  47. package/dist/domain/index-columns.js.map +1 -0
  48. package/dist/domain/study-mode.d.ts +37 -0
  49. package/dist/domain/study-mode.d.ts.map +1 -0
  50. package/dist/domain/study-mode.js +45 -0
  51. package/dist/domain/study-mode.js.map +1 -0
  52. package/dist/domain/study-registry.d.ts +116 -12
  53. package/dist/domain/study-registry.d.ts.map +1 -1
  54. package/dist/domain/study-registry.js +164 -44
  55. package/dist/domain/study-registry.js.map +1 -1
  56. package/dist/outputs-before-decision-rule.d.ts +17 -0
  57. package/dist/outputs-before-decision-rule.d.ts.map +1 -0
  58. package/dist/outputs-before-decision-rule.js +94 -0
  59. package/dist/outputs-before-decision-rule.js.map +1 -0
  60. package/dist/ports/git-history.d.ts +44 -0
  61. package/dist/ports/git-history.d.ts.map +1 -0
  62. package/dist/ports/git-history.js +2 -0
  63. package/dist/ports/git-history.js.map +1 -0
  64. package/dist/ports/list-entries.d.ts +20 -0
  65. package/dist/ports/list-entries.d.ts.map +1 -0
  66. package/dist/ports/list-entries.js +2 -0
  67. package/dist/ports/list-entries.js.map +1 -0
  68. package/dist/ports/study-deps.d.ts +14 -0
  69. package/dist/ports/study-deps.d.ts.map +1 -0
  70. package/dist/ports/study-deps.js +2 -0
  71. package/dist/ports/study-deps.js.map +1 -0
  72. package/dist/ports/study-folder.d.ts +43 -0
  73. package/dist/ports/study-folder.d.ts.map +1 -0
  74. package/dist/ports/study-folder.js +2 -0
  75. package/dist/ports/study-folder.js.map +1 -0
  76. package/dist/ports/study-registry-codec.d.ts +6 -3
  77. package/dist/ports/study-registry-codec.d.ts.map +1 -1
  78. package/dist/presets.d.ts +1 -2
  79. package/dist/presets.d.ts.map +1 -1
  80. package/dist/presets.js +1 -2
  81. package/dist/presets.js.map +1 -1
  82. package/dist/studies.d.ts +4 -0
  83. package/dist/studies.d.ts.map +1 -1
  84. package/dist/studies.js +52 -21
  85. package/dist/studies.js.map +1 -1
  86. package/dist/study-folder.d.ts +14 -0
  87. package/dist/study-folder.d.ts.map +1 -0
  88. package/dist/study-folder.js +123 -0
  89. package/dist/study-folder.js.map +1 -0
  90. package/dist/study-gate.d.ts +14 -0
  91. package/dist/study-gate.d.ts.map +1 -0
  92. package/dist/study-gate.js +132 -0
  93. package/dist/study-gate.js.map +1 -0
  94. package/dist/study-index-input.d.ts +5 -0
  95. package/dist/study-index-input.d.ts.map +1 -0
  96. package/dist/study-index-input.js +18 -0
  97. package/dist/study-index-input.js.map +1 -0
  98. package/dist/study-modes.d.ts +56 -0
  99. package/dist/study-modes.d.ts.map +1 -0
  100. package/dist/study-modes.js +59 -0
  101. package/dist/study-modes.js.map +1 -0
  102. package/dist/study-registry-rule.d.ts +13 -1
  103. package/dist/study-registry-rule.d.ts.map +1 -1
  104. package/dist/study-registry-rule.js +33 -21
  105. package/dist/study-registry-rule.js.map +1 -1
  106. package/dist/study-run.d.ts +19 -0
  107. package/dist/study-run.d.ts.map +1 -0
  108. package/dist/study-run.js +9 -0
  109. package/dist/study-run.js.map +1 -0
  110. package/docs/design/study-modes.md +265 -0
  111. package/docs/design/study-registry.md +6 -0
  112. package/docs/e2e.md +8 -4
  113. package/docs/layout.md +22 -19
  114. package/docs/rules/repro/outputs-before-decision.md +122 -0
  115. package/docs/rules/repro/study-registered.md +49 -15
  116. package/docs/rules/tex/heading-case.md +1 -1
  117. package/docs/rules.md +54 -53
  118. package/docs/testing.md +1 -0
  119. package/eslint.config.mjs +36 -24
  120. package/package.json +2 -1
  121. package/scripts/check.ts +6 -0
  122. package/skills/camera-ready/SKILL.md +6 -1
  123. package/skills/camera-ready/SKILL.md.spec.ts +5 -0
  124. package/skills/draft-paper/SKILL.md +11 -3
  125. package/skills/draft-paper/SKILL.md.spec.ts +10 -2
  126. package/skills/grade-paper-writing/SKILL.md +4 -2
  127. package/skills/grade-paper-writing/SKILL.md.spec.ts +3 -1
  128. package/skills/grade-paper-writing/__snapshots__/prose-lint.test.ts.snap +16 -3
  129. package/skills/grade-paper-writing/prose-lint.mjs +127 -95
  130. package/skills/grade-paper-writing/prose-lint.test.ts +119 -10
  131. package/skills/harden-paper/SKILL.md +12 -2
  132. package/skills/harden-paper/SKILL.md.spec.ts +11 -1
  133. package/skills/osf-artifact-upload/SKILL.md +7 -1
  134. package/skills/osf-artifact-upload/SKILL.md.spec.ts +6 -0
  135. package/skills/pc-panel-review/SKILL.md +10 -3
  136. package/skills/pc-panel-review/SKILL.md.spec.ts +9 -2
  137. package/skills/tighten-paper/SKILL.md +5 -1
  138. package/skills/tighten-paper/SKILL.md.spec.ts +4 -0
  139. package/src/CLAUDE.md +7 -4
  140. package/src/CLAUDE.md.spec.ts +6 -3
  141. package/src/adapters/git/history.test.ts +121 -0
  142. package/src/adapters/git/index.test.ts +268 -0
  143. package/src/adapters/git/index.ts +227 -0
  144. package/src/adapters/latex/captions.test.ts +57 -0
  145. package/src/adapters/latex/captions.ts +56 -0
  146. package/src/adapters/latex/index.ts +1 -0
  147. package/src/adapters/latex/parse.ts +7 -0
  148. package/src/adapters/memory/index.test.ts +15 -1
  149. package/src/adapters/memory/index.ts +13 -0
  150. package/src/adapters/node/index.ts +1 -0
  151. package/src/adapters/node/list-entries.io.ts +27 -0
  152. package/src/adapters/node/list-entries.test.ts +61 -0
  153. package/src/adapters/study-registry/index.test.ts +12 -9
  154. package/src/adapters/study-registry/index.ts +130 -24
  155. package/src/adapters/study-registry/mode.test.ts +162 -0
  156. package/src/cli.ts +47 -3
  157. package/src/domain/decide-first.test.ts +291 -0
  158. package/src/domain/decide-first.ts +258 -0
  159. package/src/domain/index-columns.test.ts +261 -0
  160. package/src/domain/index-columns.ts +107 -0
  161. package/src/domain/study-measurements.test.ts +16 -8
  162. package/src/domain/study-mode.test.ts +55 -0
  163. package/src/domain/study-mode.ts +78 -0
  164. package/src/domain/study-registry.test.ts +72 -37
  165. package/src/domain/study-registry.ts +281 -50
  166. package/src/new-paper.test.ts +5 -1
  167. package/src/outputs-before-decision-rule.test.ts +251 -0
  168. package/src/outputs-before-decision-rule.ts +133 -0
  169. package/src/ports/git-history.ts +44 -0
  170. package/src/ports/list-entries.ts +21 -0
  171. package/src/ports/study-deps.ts +14 -0
  172. package/src/ports/study-folder.ts +38 -0
  173. package/src/ports/study-registry-codec.ts +10 -3
  174. package/src/presets.ts +1 -2
  175. package/src/rule-cases.test.ts +78 -3
  176. package/src/studies-index-modes.test.ts +129 -0
  177. package/src/studies.test.ts +35 -11
  178. package/src/studies.ts +96 -26
  179. package/src/study-folder.test.ts +237 -0
  180. package/src/study-folder.ts +176 -0
  181. package/src/study-gate.test.ts +467 -0
  182. package/src/study-gate.ts +234 -0
  183. package/src/study-index-input.ts +31 -0
  184. package/src/study-modes.ts +107 -0
  185. package/src/study-registry-rule.test.ts +285 -11
  186. package/src/study-registry-rule.ts +64 -35
  187. package/src/study-run.ts +21 -0
@@ -0,0 +1,467 @@
1
+ /**
2
+ * `paperlint studies gate <paper-dir> <id>` (`src/study-gate.ts`) on papers held in memory, git a
3
+ * stand-in: 0 once the decision is committed as it stands, 1 with the reason otherwise — and 0 for a
4
+ * study whose run waits on nothing, saying why.
5
+ */
6
+ import { expect, it } from "vitest";
7
+ import type { StudyMode } from "./domain/study-mode.ts";
8
+ import { planText, registryText } from "./domain/study-registry.ts";
9
+ import {
10
+ DATE,
11
+ DECIDED,
12
+ DIR,
13
+ ROW,
14
+ S,
15
+ SHA,
16
+ planOf,
17
+ studies,
18
+ tree,
19
+ } from "../test/study-tree.ts";
20
+ const DECISION = `${DIR}/repro/P1-S01-outcome/decision.md`;
21
+ const decided = (mode: StudyMode = DECIDED) => tree({}, planOf(mode));
22
+
23
+ it("open once the decision is committed as it stands: exit 0 with its commit and date", () => {
24
+ const r = studies(
25
+ "gate",
26
+ decided(),
27
+ {},
28
+ {
29
+ git: { [DECISION]: { kind: "committed", commit: SHA, date: DATE } },
30
+ },
31
+ );
32
+ // Guards: the line a runner's log keeps, and the exit code `gate && run` reads.
33
+ expect([r.code, r.log, r.err]).toEqual([
34
+ 0,
35
+ [
36
+ `P1-S01: the decision repro/P1-S01-outcome/decision.md is committed in ${SHA} (${DATE}); the run may start.`,
37
+ ],
38
+ [],
39
+ ]);
40
+ });
41
+
42
+ it("closed while the decision is uncommitted, changed since its commit, or unknown to git", () => {
43
+ const gate = (state: Parameters<typeof studies>[3]) =>
44
+ studies("gate", decided(), {}, state);
45
+ // Guards: each reason the run must wait is said, exit 1.
46
+ expect(
47
+ [
48
+ gate({}),
49
+ gate({ git: { [DECISION]: { kind: "changed", commit: SHA } } }),
50
+ gate({
51
+ git: { [DECISION]: { kind: "unknown", why: "git is not installed" } },
52
+ }),
53
+ ].map((r) => [r.code, r.err]),
54
+ ).toEqual([
55
+ [
56
+ 1,
57
+ [
58
+ "P1-S01: the decision repro/P1-S01-outcome/decision.md is not committed; the run waits for it. Write the decision there, commit it, then run this again.",
59
+ ],
60
+ ],
61
+ [
62
+ 1,
63
+ [
64
+ `P1-S01: the decision repro/P1-S01-outcome/decision.md has changed since its last commit, ${SHA}; the run waits for the decision as committed. Commit it, then run this again.`,
65
+ ],
66
+ ],
67
+ [
68
+ 1,
69
+ [
70
+ "P1-S01: git could not say whether the decision repro/P1-S01-outcome/decision.md is committed: git is not installed.",
71
+ ],
72
+ ],
73
+ ]);
74
+ });
75
+
76
+ it("refuses a plan that names no decision file, or one its own outputs match", () => {
77
+ // Guards: a gate on nothing, or on a file the run itself produces, is no gate.
78
+ expect([
79
+ studies("gate", decided({ ...DECIDED, decision: { file: " ", note: "n" } }))
80
+ .err,
81
+ studies("gate", decided({ ...DECIDED, outputs: ["*.md"] })).err,
82
+ ]).toEqual([
83
+ [
84
+ "P1-S01: its plan.json is a draft — `mode.decision.file` is empty; the run waits for a ready plan. Fill it in, then run this again.",
85
+ ],
86
+ [
87
+ "P1-S01: its decision repro/P1-S01-outcome/decision.md is matched by its output `*.md`; a decision cannot be what its own run produces.",
88
+ ],
89
+ ]);
90
+ });
91
+
92
+ it("an exploratory study's run waits on nothing: exit 0, saying so", () => {
93
+ // Guards: the runner may call gate on every study; the answer names the mode.
94
+ expect(
95
+ studies("gate", decided({ kind: "exploratory", reason: "a first look" })),
96
+ ).toMatchObject({
97
+ code: 0,
98
+ log: [
99
+ "P1-S01 is exploratory (a first look): it has no gate, and its results are reported as exploratory.",
100
+ ],
101
+ });
102
+ });
103
+
104
+ it("refuses a study that declares no mode, and one whose row names a folder outside repro/", () => {
105
+ // Guards: a measurement with no mode is not gated by default — it is refused until it says how;
106
+ // and a row naming `../x` is never read through, so no file outside the paper decides the gate.
107
+ expect(
108
+ [
109
+ studies("gate", tree({}, planOf(undefined))),
110
+ studies(
111
+ "gate",
112
+ tree(
113
+ {
114
+ [`${DIR}/x/plan.json`]: JSON.stringify(planOf(DECIDED)),
115
+ },
116
+ planOf(DECIDED),
117
+ { ...ROW, folder: "../x" },
118
+ ),
119
+ ),
120
+ ].map((r) => [r.code, r.err]),
121
+ ).toEqual([
122
+ [
123
+ 1,
124
+ [
125
+ "P1-S01 declares no mode in papers/example: the plan its row names in papers/example/repro/studies.json is missing, does not parse, or has no `mode`. Lint the paper (`npx paperlint lint`) to see why.",
126
+ ],
127
+ ],
128
+ [
129
+ 1,
130
+ [
131
+ "P1-S01: its row names the folder `../x`, which is not one folder inside papers/example/repro/; nothing is read through it. Give the row its own folder's name.",
132
+ ],
133
+ ],
134
+ ]);
135
+ });
136
+
137
+ it("refuses a command line without the study's id, or with an argument too many", () => {
138
+ // Guards: exit 2 is the command line's, never the paper's state.
139
+ expect(
140
+ [
141
+ studies("gate", decided(), { title: undefined }),
142
+ studies("gate", decided(), { extra: ["P1-S02"] }),
143
+ ].map((r) => [r.code, r.err]),
144
+ ).toEqual([
145
+ [
146
+ 2,
147
+ [
148
+ "studies gate needs the study's id: npx paperlint studies gate <paper-dir> <id>",
149
+ ],
150
+ ],
151
+ [
152
+ 2,
153
+ [
154
+ "studies gate takes the paper's folder and one id — `P1-S02` is one argument too many",
155
+ ],
156
+ ],
157
+ ]);
158
+ });
159
+
160
+ it("a plan it cannot read through declares no mode: no registry, a plan outside its folder, a NUL, not JSON", () => {
161
+ const noMode = (t: Readonly<Record<string, string>>) =>
162
+ studies("gate", t).err[0]?.split(":")[0];
163
+ // Guards: each is refused as declaring no mode — none is read past, none opens the gate.
164
+ expect([
165
+ noMode(tree({}, planOf(DECIDED), { ...ROW, plan: "../plan.json" })),
166
+ noMode(tree({}, planOf(DECIDED), { ...ROW, plan: "a\0b.json" })),
167
+ noMode({ ...decided(), [`${DIR}/repro/P1-S01-outcome/plan.json`]: "{" }),
168
+ ]).toEqual(Array(3).fill("P1-S01 declares no mode in papers/example"));
169
+ });
170
+
171
+ it("refuses when the registry is missing or does not parse, naming the file and the way to see why", () => {
172
+ const withoutRegistry = Object.fromEntries(
173
+ Object.entries(decided()).filter(([k]) => !k.endsWith("studies.json")),
174
+ );
175
+ const unparseable = {
176
+ ...decided(),
177
+ [`${DIR}/repro/studies.json`]: "{ not json",
178
+ };
179
+ // Guards: "no row carries this id" would send a person to the registry that is the problem.
180
+ expect(
181
+ [withoutRegistry, unparseable].map((t) => {
182
+ const r = studies("gate", t);
183
+ return [r.code, r.err];
184
+ }),
185
+ ).toEqual([
186
+ [
187
+ 1,
188
+ [
189
+ "P1-S01: the registry papers/example/repro/studies.json is missing, so no row can carry this id. Run `npx paperlint lint` to see why, then run this again.",
190
+ ],
191
+ ],
192
+ [
193
+ 1,
194
+ [
195
+ expect.stringMatching(
196
+ /^P1-S01: the registry papers\/example\/repro\/studies\.json does not parse \(.+\), so no row can carry this id\. Run `npx paperlint lint` to see why, then run this again\.$/u,
197
+ ),
198
+ ],
199
+ ],
200
+ ]);
201
+ });
202
+
203
+ it("refuses an id no row of the registry carries, saying so and what to do: exit 1", () => {
204
+ const r = studies("gate", decided(), { title: "P1-S09" });
205
+ // Guards: an id that is not in the registry is not a study that "declares no mode".
206
+ expect([r.code, r.log, r.err]).toEqual([
207
+ 1,
208
+ [],
209
+ [
210
+ "P1-S09: no row of papers/example/repro/studies.json carries this id. Use an id from the registry, or register the study with `npx paperlint studies new`, then run this again.",
211
+ ],
212
+ ]);
213
+ });
214
+
215
+ it("refuses a decision that names a directory, whatever git says of it", () => {
216
+ const dir = "repro/P1-S01-outcome/decisions";
217
+ const r = studies(
218
+ "gate",
219
+ tree(
220
+ { [`${DIR}/${dir}/a.md`]: "exclude run 7\n" },
221
+ planOf({ ...DECIDED, decision: { file: dir, note: "n" } }),
222
+ ),
223
+ {},
224
+ {
225
+ git: {
226
+ [`${DIR}/${dir}`]: { kind: "committed", commit: SHA, date: DATE },
227
+ },
228
+ },
229
+ );
230
+ // Guards: git answers for a directory with the last commit under it — never a decision as committed.
231
+ expect([r.code, r.err]).toEqual([
232
+ 1,
233
+ [
234
+ `P1-S01: its decision ${dir} is a directory, not a file; the run cannot be gated on it. Name the decision file in \`mode.decision.file\`.`,
235
+ ],
236
+ ]);
237
+ });
238
+
239
+ it("closed when the decision was committed and then deleted: it is not in HEAD", () => {
240
+ const r = studies(
241
+ "gate",
242
+ decided(),
243
+ {},
244
+ { git: { [DECISION]: { kind: "deleted", commit: SHA } } },
245
+ );
246
+ // Guards: a decision deleted in a later commit leaves a clean tree, and still gates nothing.
247
+ expect([r.code, r.err]).toEqual([
248
+ 1,
249
+ [
250
+ `P1-S01: the decision repro/P1-S01-outcome/decision.md is not in HEAD: it was deleted in ${SHA}; the run waits for a decision that stands. Restore it, commit it, then run this again.`,
251
+ ],
252
+ ]);
253
+ });
254
+
255
+ it("refuses a plan that is still a draft: a mode is no decision until its plan is ready", () => {
256
+ const r = studies(
257
+ "gate",
258
+ decided({ ...DECIDED, outputs: [] }),
259
+ {},
260
+ { git: { [DECISION]: { kind: "committed", commit: SHA, date: DATE } } },
261
+ );
262
+ const blankReason = studies(
263
+ "gate",
264
+ decided({ kind: "exploratory", reason: " " }),
265
+ );
266
+ const twoEmpty = studies(
267
+ "gate",
268
+ decided({
269
+ ...DECIDED,
270
+ outputs: [],
271
+ decision: { ...DECIDED.decision, note: "" },
272
+ }),
273
+ );
274
+ // Guards: a draft names no outputs to keep from the decision, and an exploratory plan with no
275
+ // reason is no declaration; the gate opens on a ready plan only, whatever its mode.
276
+ expect([
277
+ [r.code, r.err],
278
+ [blankReason.code, blankReason.err],
279
+ [twoEmpty.code, twoEmpty.err],
280
+ ]).toEqual([
281
+ [
282
+ 1,
283
+ [
284
+ "P1-S01: its plan.json is a draft — `mode.outputs` is empty; the run waits for a ready plan. Fill it in, then run this again.",
285
+ ],
286
+ ],
287
+ [
288
+ 1,
289
+ [
290
+ "P1-S01: its plan.json is a draft — `mode.reason` is empty; the run waits for a ready plan. Fill it in, then run this again.",
291
+ ],
292
+ ],
293
+ [
294
+ 1,
295
+ [
296
+ "P1-S01: its plan.json is a draft — `mode.outputs`, `mode.decision.note` are empty; the run waits for a ready plan. Fill it in, then run this again.",
297
+ ],
298
+ ],
299
+ ]);
300
+ });
301
+
302
+ it("refuses a decision or a study folder that is a symbolic link", () => {
303
+ const committed = {
304
+ [DECISION]: { kind: "committed", commit: SHA, date: DATE },
305
+ } as const;
306
+ const decisionLink = studies(
307
+ "gate",
308
+ { ...decided(), [DECISION]: "exclude run 7\n" },
309
+ {},
310
+ { git: committed, links: [DECISION] },
311
+ );
312
+ const folderLink = studies(
313
+ "gate",
314
+ decided(),
315
+ {},
316
+ { git: committed, links: [`${DIR}/repro/P1-S01-outcome`] },
317
+ );
318
+ // Guards: git records a link, not the file it points to; a linked folder leads out of `repro/`.
319
+ expect([
320
+ [decisionLink.code, decisionLink.err],
321
+ [folderLink.code, folderLink.err],
322
+ ]).toEqual([
323
+ [
324
+ 1,
325
+ [
326
+ "P1-S01: its decision repro/P1-S01-outcome/decision.md is a symbolic link, not a file; the run cannot be gated on it. Name the decision file in `mode.decision.file`.",
327
+ ],
328
+ ],
329
+ [
330
+ 1,
331
+ [
332
+ "P1-S01: its row names the folder `P1-S01-outcome`, which is a symbolic link; nothing is read through it. Give the row a real folder inside papers/example/repro/.",
333
+ ],
334
+ ],
335
+ ]);
336
+ });
337
+
338
+ it("never reads a plan through a backslash: `..\\x\\plan.json` is outside the folder on Windows", () => {
339
+ const plan = "..\\x\\plan.json";
340
+ const r = studies(
341
+ "gate",
342
+ tree(
343
+ {
344
+ [`${DIR}/repro/P1-S01-outcome/${plan}`]: JSON.stringify(
345
+ planOf(DECIDED),
346
+ ),
347
+ },
348
+ planOf(DECIDED),
349
+ { ...ROW, plan },
350
+ ),
351
+ {},
352
+ { git: { [DECISION]: { kind: "committed", commit: SHA, date: DATE } } },
353
+ );
354
+ // Guards: the containment test is the registry rule's own `isInside`, the same on every platform.
355
+ expect(r.err[0]?.split(":")[0]).toBe(
356
+ "P1-S01 declares no mode in papers/example",
357
+ );
358
+ });
359
+
360
+ it("refuses an id that two rows share, even when the first row is exploratory: exit 1, naming both folders", () => {
361
+ const second = { ...ROW, folder: "P1-S01-again" };
362
+ const t = {
363
+ ...tree(
364
+ {
365
+ [`${DIR}/repro/P1-S01-again/plan.json`]: JSON.stringify(
366
+ planOf(DECIDED),
367
+ ),
368
+ },
369
+ planOf({ kind: "exploratory", reason: "a first look" }),
370
+ ),
371
+ [`${DIR}/repro/studies.json`]: registryText({
372
+ prefix: "P1",
373
+ studies: [ROW, second],
374
+ }),
375
+ };
376
+ const r = studies("gate", t);
377
+ // Guards: `gate && run` must not start on the first row's mode while a later row of the same id
378
+ // decides before its run; lint need not have run first.
379
+ expect([r.code, r.log, r.err]).toEqual([
380
+ 1,
381
+ [],
382
+ [
383
+ "P1-S01: 2 rows of papers/example/repro/studies.json carry this id (folders `P1-S01-outcome`, `P1-S01-again`); the gate answers for exactly one study. Give each other row the next free id and rename its folder to match, then run this again.",
384
+ ],
385
+ ]);
386
+ });
387
+
388
+ it("refuses a plan that is a symbolic link, or sits under a linked directory, naming the link: exit 1", () => {
389
+ const exploratory = planOf({ kind: "exploratory", reason: "a first look" });
390
+ const asLink = studies(
391
+ "gate",
392
+ tree({}, exploratory),
393
+ {},
394
+ { links: [`${S}/plan.json`] },
395
+ );
396
+ const sub = { ...ROW, plan: "sub/plan.json" };
397
+ const underLink = studies(
398
+ "gate",
399
+ tree({ [`${S}/sub/plan.json`]: planText(exploratory) }, exploratory, sub),
400
+ {},
401
+ { links: [`${S}/sub`] },
402
+ );
403
+ // Guards: a plan read through a link would open the gate on a file git does not store as the
404
+ // study's; the refusal names the row, the plan, the link and the fix. The files behind the link
405
+ // are ready exploratory plans, so a read through it would exit 0.
406
+ expect([asLink, underLink].map((r) => [r.code, r.log, r.err])).toEqual([
407
+ [
408
+ 1,
409
+ [],
410
+ [
411
+ "P1-S01: the plan its row names, in papers/example/repro/P1-S01-outcome, passes through the symbolic link `plan.json`; nothing is read through a link. Replace the link with the plan file itself, then run this again.",
412
+ ],
413
+ ],
414
+ [
415
+ 1,
416
+ [],
417
+ [
418
+ "P1-S01: the plan its row names, in papers/example/repro/P1-S01-outcome, passes through the symbolic link `sub`; nothing is read through a link. Replace the link with the plan file itself, then run this again.",
419
+ ],
420
+ ],
421
+ ]);
422
+ });
423
+
424
+ it("refuses a decision under a linked directory, naming the link: git cannot vouch for what lies behind it", () => {
425
+ const file = "repro/P1-S01-outcome/sub/decision.md";
426
+ const r = studies(
427
+ "gate",
428
+ tree(
429
+ { [`${DIR}/${file}`]: "exclude run 7\n" },
430
+ planOf({ ...DECIDED, decision: { file, note: "n" } }),
431
+ ),
432
+ {},
433
+ {
434
+ git: {
435
+ [`${DIR}/${file}`]: { kind: "committed", commit: SHA, date: DATE },
436
+ },
437
+ links: [`${S}/sub`],
438
+ },
439
+ );
440
+ // Guards: only the last component of the decision path was looked at, so a linked directory above
441
+ // it passed and git was asked about a path it stores as a link's target.
442
+ expect([r.code, r.err]).toEqual([
443
+ 1,
444
+ [
445
+ `P1-S01: its decision ${file} passes through the symbolic link \`repro/P1-S01-outcome/sub\`; git records the link, not the files behind it, so the run cannot be gated on it. Name a decision file that lies in real directories in \`mode.decision.file\`.`,
446
+ ],
447
+ ]);
448
+ });
449
+
450
+ it("refuses a decision that is a FIFO or another special file: git tracks no such thing", () => {
451
+ const r = studies(
452
+ "gate",
453
+ { ...decided(), [DECISION]: "" },
454
+ {},
455
+ {
456
+ git: { [DECISION]: { kind: "committed", commit: SHA, date: DATE } },
457
+ others: [DECISION],
458
+ },
459
+ );
460
+ // Guards: a decision the listing called a file passed, and git was asked about it.
461
+ expect([r.code, r.err]).toEqual([
462
+ 1,
463
+ [
464
+ "P1-S01: its decision repro/P1-S01-outcome/decision.md is a special file, not a file; the run cannot be gated on it. Name the decision file in `mode.decision.file`.",
465
+ ],
466
+ ]);
467
+ });
@@ -0,0 +1,234 @@
1
+ /**
2
+ * `paperlint studies gate <paper-dir> <id>` — may the study's run start? For a study decided before
3
+ * its run (the default mode), only once its decision file exists and is committed as it stands: exit 0
4
+ * with the commit and its date, else exit 1 saying why. A runner calls it first:
5
+ *
6
+ * npx paperlint studies gate papers/my-paper P1-S03 && ./run.sh
7
+ *
8
+ * An exploratory study has no gate: it exits 0 and says so. A study that declares no mode, names no
9
+ * decision file, whose row names a folder that is not one inside `repro/`, or whose id two rows
10
+ * share, is refused.
11
+ */
12
+ import { join } from "node:path";
13
+ import { callerPath } from "./caller-path.ts";
14
+ import { decisionEntry, readModeStudy, type ModeStudy } from "./study-modes.ts";
15
+ import { REPRO_DIR } from "./domain/repro-layout.ts";
16
+ import { decisionAsOutput, type DecideFirst } from "./domain/study-mode.ts";
17
+ import {
18
+ STUDIES_FILE,
19
+ studiesOf,
20
+ type RegistryState,
21
+ studyById,
22
+ type Study,
23
+ } from "./domain/study-registry.ts";
24
+ import type { Committed, GitHistory } from "./ports/git-history.ts";
25
+ import type { StudyFolder } from "./ports/study-folder.ts";
26
+ import { folderRefusalText, studyFolder } from "./study-folder.ts";
27
+ import type { StudyDeps } from "./ports/study-deps.ts";
28
+ import { registryOf } from "./study-registry-rule.ts";
29
+ import {
30
+ refusal,
31
+ refused,
32
+ type StudyRefusal,
33
+ type StudyRun,
34
+ } from "./study-run.ts";
35
+
36
+ export interface GateDeps extends StudyDeps {
37
+ readonly git: GitHistory;
38
+ }
39
+
40
+ /** The line of a committed state that holds the run back. */
41
+ const shutLine = (
42
+ id: string,
43
+ file: string,
44
+ c: Exclude<Committed, { readonly kind: "committed" }>,
45
+ ): string => {
46
+ switch (c.kind) {
47
+ case "changed":
48
+ return `${id}: the decision ${file} has changed since its last commit, ${c.commit}; the run waits for the decision as committed. Commit it, then run this again.`;
49
+ case "deleted":
50
+ return `${id}: the decision ${file} is not in HEAD: it was deleted in ${c.commit}; the run waits for a decision that stands. Restore it, commit it, then run this again.`;
51
+ case "not-committed":
52
+ return `${id}: the decision ${file} is not committed; the run waits for it. Write the decision there, commit it, then run this again.`;
53
+ case "unknown":
54
+ return `${id}: git could not say whether the decision ${file} is committed: ${c.why}.`;
55
+ }
56
+ };
57
+
58
+ /** What a decision's committed state means for the run. */
59
+ export const gateLine = (
60
+ id: string,
61
+ file: string,
62
+ c: Committed,
63
+ ): { readonly open: boolean; readonly line: string } =>
64
+ c.kind === "committed"
65
+ ? {
66
+ open: true,
67
+ line: `${id}: the decision ${file} is committed in ${c.commit} (${c.date}); the run may start.`,
68
+ }
69
+ : { open: false, line: shutLine(id, file, c) };
70
+
71
+ /**
72
+ * Why the decision cannot gate the run though it is named: its own outputs match it, or it is a
73
+ * directory (git answers for a directory with the last commit of anything under it); null when usable.
74
+ */
75
+ const unusable = (
76
+ { id, folder: studyDir }: Pick<Study, "id" | "folder">,
77
+ mode: DecideFirst,
78
+ folder: StudyFolder,
79
+ ): StudyRefusal | null => {
80
+ const file = mode.decision.file;
81
+ const glob = decisionAsOutput(studyDir, mode);
82
+ if (glob !== null)
83
+ return refusal(
84
+ `${id}: its decision ${file} is matched by its output \`${glob}\`; a decision cannot be what its own run produces.`,
85
+ );
86
+ const on = decisionEntry(folder, file);
87
+ switch (on.kind) {
88
+ case "not-a-file":
89
+ return refusal(
90
+ `${id}: its decision ${file} is a ${on.what}, not a file; the run cannot be gated on it. Name the decision file in \`mode.decision.file\`.`,
91
+ );
92
+ case "linked":
93
+ return refusal(
94
+ `${id}: its decision ${file} passes through the symbolic link \`${on.link}\`; git records the link, not the files behind it, so the run cannot be gated on it. Name a decision file that lies in real directories in \`mode.decision.file\`.`,
95
+ );
96
+ case "ok":
97
+ return null;
98
+ }
99
+ };
100
+
101
+ /** What one gate run reads with: the paper's folder, how it is shown, the study folders' reader, git. */
102
+ interface GateRun {
103
+ readonly paper: string;
104
+ readonly shown: string;
105
+ readonly folder: StudyFolder;
106
+ readonly deps: GateDeps;
107
+ }
108
+
109
+ /** The gate of a decide-first study. */
110
+ const gateDecided = (
111
+ { paper, folder, deps }: GateRun,
112
+ row: Study,
113
+ mode: DecideFirst,
114
+ ): StudyRun => {
115
+ const { id } = row;
116
+ const file = mode.decision.file;
117
+ const blocked = unusable(row, mode, folder);
118
+ if (blocked !== null) return refused(blocked);
119
+ const g = gateLine(
120
+ id,
121
+ file,
122
+ deps.git.committed(callerPath(paper), callerPath(join(paper, file))),
123
+ );
124
+ return g.open ? { ok: true, lines: [g.line] } : refused(refusal(g.line));
125
+ };
126
+
127
+ /** The refusal when the row names a folder that may not be entered; null when it may. */
128
+ const escapingRow = (
129
+ shown: string,
130
+ row: Study,
131
+ folder: StudyFolder,
132
+ ): StudyRefusal | null => {
133
+ const why = folder.refusal(row.folder);
134
+ if (why === null) return null;
135
+ const { reason, fix } = folderRefusalText(why, shown);
136
+ return refusal(
137
+ `${row.id}: its row names the folder \`${row.folder}\`, which ${reason}; nothing is read through it. ${fix}.`,
138
+ );
139
+ };
140
+
141
+ /** The refusal of an id that two or more rows carry: the gate answers for exactly one study. */
142
+ const ambiguousRefusal = (
143
+ shown: string,
144
+ id: string,
145
+ rows: readonly Study[],
146
+ ): StudyRefusal =>
147
+ refusal(
148
+ `${id}: ${String(rows.length)} rows of ${shown}/${REPRO_DIR}/${STUDIES_FILE} carry this id (folders ${rows.map((x) => `\`${x.folder}\``).join(", ")}); the gate answers for exactly one study. Give each other row the next free id and rename its folder to match, then run this again.`,
149
+ );
150
+
151
+ /** The refusal of a study whose plan is still a draft: a mode decides nothing until its plan is ready. */
152
+ const draftRefusal = (id: string, s: ModeStudy): StudyRefusal => {
153
+ const names = s.empty.map((f) => `\`${f}\``).join(", ");
154
+ const verb = s.empty.length === 1 ? "is" : "are";
155
+ return refusal(
156
+ `${id}: its ${s.plan} is a draft — ${names} ${verb} empty; the run waits for a ready plan. Fill it in, then run this again.`,
157
+ );
158
+ };
159
+
160
+ /** The gate of the one row `row`. */
161
+ const gateRow = (run: GateRun, row: Study): StudyRun => {
162
+ const { shown, folder, deps } = run;
163
+ const escaping = escapingRow(shown, row, folder);
164
+ if (escaping !== null) return refused(escaping);
165
+ const read = readModeStudy(row, folder, deps.codec);
166
+ if (read.kind === "linked")
167
+ return refused(linkedPlanRefusal(shown, row, read.link));
168
+ if (read.kind === "none") return refused(noModeRefusal(shown, row.id));
169
+ const s = read.study;
170
+ if (s.empty.length > 0) return refused(draftRefusal(row.id, s));
171
+ return s.mode.kind === "decide_before_run"
172
+ ? gateDecided(run, s.row, s.mode)
173
+ : {
174
+ ok: true,
175
+ lines: [
176
+ `${row.id} is exploratory (${s.mode.reason}): it has no gate, and its results are reported as exploratory.`,
177
+ ],
178
+ };
179
+ };
180
+
181
+ /** The refusal of a plan whose path passes through a symbolic link. */
182
+ const linkedPlanRefusal = (
183
+ shown: string,
184
+ row: Study,
185
+ link: string,
186
+ ): StudyRefusal =>
187
+ refusal(
188
+ `${row.id}: the plan its row names, in ${shown}/${REPRO_DIR}/${row.folder}, passes through the symbolic link \`${link}\`; nothing is read through a link. Replace the link with the plan file itself, then run this again.`,
189
+ );
190
+
191
+ /** The refusal of an id no row declares a mode for. */
192
+ const noModeRefusal = (shown: string, id: string): StudyRefusal =>
193
+ refusal(
194
+ `${id} declares no mode in ${shown}: the plan its row names in ${shown}/${REPRO_DIR}/${STUDIES_FILE} is missing, does not parse, or has no \`mode\`. Lint the paper (\`npx paperlint lint\`) to see why.`,
195
+ );
196
+
197
+ /** The refusal of an id that no row of the registry carries. */
198
+ const unknownIdRefusal = (shown: string, id: string): StudyRefusal =>
199
+ refusal(
200
+ `${id}: no row of ${shown}/${REPRO_DIR}/${STUDIES_FILE} carries this id. Use an id from the registry, or register the study with \`npx paperlint studies new\`, then run this again.`,
201
+ );
202
+
203
+ /** The refusal of a registry that is not there or does not parse: no row can carry the id. */
204
+ const registryRefusal = (
205
+ shown: string,
206
+ id: string,
207
+ r: Exclude<RegistryState, { readonly kind: "read" }>,
208
+ ): StudyRefusal =>
209
+ refusal(
210
+ `${id}: the registry ${shown}/${REPRO_DIR}/${STUDIES_FILE} ${r.kind === "absent" ? "is missing" : `does not parse (${r.why})`}, so no row can carry this id. Run \`npx paperlint lint\` to see why, then run this again.`,
211
+ );
212
+
213
+ /** `studies gate`: 0 when the run may start, 1 with the reason when not. */
214
+ export function runGate(
215
+ paper: string,
216
+ shown: string,
217
+ id: string,
218
+ deps: GateDeps,
219
+ ): StudyRun {
220
+ const r = registryOf(paper, deps);
221
+ if (r.kind !== "read") return refused(registryRefusal(shown, id, r));
222
+ const found = studyById(studiesOf(r), id);
223
+ switch (found.kind) {
224
+ case "none":
225
+ return refused(unknownIdRefusal(shown, id));
226
+ case "ambiguous":
227
+ return refused(ambiguousRefusal(shown, id, found.rows));
228
+ case "one":
229
+ return gateRow(
230
+ { paper, shown, folder: studyFolder(paper, deps), deps },
231
+ found.row,
232
+ );
233
+ }
234
+ }