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,129 @@
1
+ /**
2
+ * `paperlint studies index` and `studies new` (`src/studies.ts`) writing the index's mode columns from
3
+ * the plans and git's history (a stand-in), and refusing a registry whose row names a folder outside
4
+ * `repro/` before reading or writing through it.
5
+ */
6
+ import { expect, it } from "vitest";
7
+ import {
8
+ DECIDED,
9
+ DIR,
10
+ ROW,
11
+ planOf,
12
+ studies,
13
+ tree,
14
+ } from "../test/study-tree.ts";
15
+
16
+ const D = { commit: "d".repeat(40), date: "2026-10-14T09:12:00+05:00" };
17
+ const A = { commit: "a".repeat(40), date: "2026-10-16T11:00:00+05:00" };
18
+ const README = `${DIR}/repro/README.md`;
19
+
20
+ /** The row of P1-S01 in the README `studies index` wrote, its cells trimmed. */
21
+ const rowOf = (after: Readonly<Record<string, string>>): string[] =>
22
+ (after[README] ?? "")
23
+ .split("\n")
24
+ .filter((l) => l.startsWith("| P1-S01"))
25
+ .flatMap((l) => l.split("|").map((c) => c.trim()))
26
+ .filter((c) => c !== "");
27
+
28
+ it("writes the decision's first commit and the first output's, read from git", () => {
29
+ const r = studies(
30
+ "index",
31
+ tree({ [`${DIR}/repro/P1-S01-outcome/RESULTS.md`]: "41 of 60\n" }),
32
+ { title: undefined },
33
+ {
34
+ order: {
35
+ kind: "read",
36
+ decision: D,
37
+ decisionDeleted: false,
38
+ files: [
39
+ { path: "decision.md", ...D, order: "same" },
40
+ { path: "RESULTS.md", ...A, order: "after" },
41
+ ],
42
+ },
43
+ },
44
+ );
45
+ // Guards: the cells the review skills read the Methods sentence's facts from.
46
+ expect([r.code, r.log, rowOf(r.after)]).toEqual([
47
+ 0,
48
+ ["wrote the index of 1 study in papers/example/repro/README.md"],
49
+ [
50
+ "P1-S01",
51
+ "Outcome",
52
+ "running",
53
+ "—",
54
+ "no",
55
+ "decide_before_run",
56
+ "dddddddddddd 2026-10-14",
57
+ "aaaaaaaaaaaa 2026-10-16",
58
+ "[P1-S01-outcome/](P1-S01-outcome/)",
59
+ ],
60
+ ]);
61
+ });
62
+
63
+ it("rewrites the index when only git's columns moved, and leaves it when nothing did", () => {
64
+ const before = studies("index", tree(), { title: undefined }).after;
65
+ const order = {
66
+ kind: "read",
67
+ decision: D,
68
+ decisionDeleted: false,
69
+ files: [],
70
+ } as const;
71
+ const moved = studies("index", before, { title: undefined }, { order });
72
+ const again = studies("index", moved.after, { title: undefined }, { order });
73
+ // Guards: the lint ignores those two columns, so the command is what keeps them current.
74
+ expect([rowOf(before)[6], moved.log, again.log]).toEqual([
75
+ "unknown (git could not answer)",
76
+ ["wrote the index of 1 study in papers/example/repro/README.md"],
77
+ ["the index in papers/example/repro/README.md is in sync with 1 study"],
78
+ ]);
79
+ });
80
+
81
+ it("an exploratory study's mode carries its reason, and no commit", () => {
82
+ const r = studies(
83
+ "index",
84
+ tree({}, planOf({ kind: "exploratory", reason: "a first look" })),
85
+ { title: undefined },
86
+ );
87
+ expect(rowOf(r.after).slice(5, 8)).toEqual([
88
+ "exploratory: a first look",
89
+ "—",
90
+ "—",
91
+ ]);
92
+ });
93
+
94
+ it("refuses a registry whose row names a folder outside repro/, for index and for new", () => {
95
+ const escaping = tree(
96
+ { [`${DIR}/x/plan.json`]: JSON.stringify(planOf(DECIDED)) },
97
+ planOf(DECIDED),
98
+ { ...ROW, folder: "../x" },
99
+ );
100
+ const line =
101
+ "P1-S01 in papers/example/repro/studies.json names the folder `../x`, which is not one folder inside repro/: nothing is read or written through it. Give the row its own folder's name, then run this again.";
102
+ const index = studies("index", escaping, { title: undefined });
103
+ const added = studies("new", escaping, { title: "Second" });
104
+ // Guards: nothing is written — no README, no new folder — when a row could lead outside the paper.
105
+ expect([
106
+ [index.code, index.err, README in index.after],
107
+ [added.code, added.err, Object.keys(added.after).length],
108
+ ]).toEqual([
109
+ [1, [line], false],
110
+ [1, [line], Object.keys(escaping).length],
111
+ ]);
112
+ });
113
+
114
+ it("refuses a registry whose study folder is a symbolic link, for index", () => {
115
+ const r = studies(
116
+ "index",
117
+ tree(),
118
+ { title: undefined },
119
+ { links: [`${DIR}/repro/P1-S01-outcome`] },
120
+ );
121
+ // Guards: a linked folder leads the reads out of `repro/`, like a row naming `../x`.
122
+ expect([r.code, r.err, README in r.after]).toEqual([
123
+ 1,
124
+ [
125
+ "P1-S01 in papers/example/repro/studies.json names the folder `P1-S01-outcome`, which is not one folder inside repro/: nothing is read or written through it. Give the row its own folder's name, then run this again.",
126
+ ],
127
+ false,
128
+ ]);
129
+ });
@@ -5,18 +5,28 @@
5
5
  * state).
6
6
  */
7
7
  import { describe, expect, it } from "vitest";
8
- import { memoryFiles, memoryListDir } from "./adapters/memory/index.ts";
8
+ import {
9
+ memoryFiles,
10
+ memoryListDir,
11
+ memoryListEntries,
12
+ } from "./adapters/memory/index.ts";
9
13
  import { studyRegistryCodec } from "./adapters/study-registry/index.ts";
10
14
  import {
11
15
  indexBlock,
16
+ planTemplate,
17
+ planText,
12
18
  registryText,
13
19
  studyReadme,
14
20
  withIndex,
15
21
  type Study,
16
22
  type StudyRegistry,
23
+ type IndexInput,
17
24
  } from "./domain/study-registry.ts";
18
25
  import { runStudies, type StudiesArgs } from "./studies.ts";
19
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
+
20
30
  const CWD = "/work";
21
31
  const DIR = "/work/papers/example";
22
32
  const REPRO = `${DIR}/repro`;
@@ -66,9 +76,24 @@ function studies(
66
76
  {
67
77
  files,
68
78
  listDir: memoryListDir(files),
79
+ entries: memoryListEntries(files),
69
80
  codec: studyRegistryCodec,
70
81
  cwd,
71
82
  today: "2026-10-15",
83
+ git: {
84
+ committed: () => {
85
+ throw new Error(
86
+ "new and index never ask whether a file is committed",
87
+ );
88
+ },
89
+ // A history with no decision and no output committed yet.
90
+ order: () => ({
91
+ kind: "read",
92
+ decision: null,
93
+ decisionDeleted: false,
94
+ files: [],
95
+ }),
96
+ },
72
97
  log: (l) => log.push(l),
73
98
  err: (l) => err.push(l),
74
99
  },
@@ -101,17 +126,22 @@ describe("studies new", () => {
101
126
  status: "designing",
102
127
  date: "2026-10-15",
103
128
  in_paper: "no",
129
+ plan: "plan.json",
104
130
  };
105
131
  const r = P1(study(), added);
106
132
  expect({ code, err }).toEqual({ code: 0, err: [] });
107
133
  expect(after).toEqual({
108
134
  ...before,
109
135
  [`${REPRO}/P1-S02-ablation-of-the-guard/README.md`]: studyReadme(added),
136
+ [`${REPRO}/P1-S02-ablation-of-the-guard/plan.json`]: planText(
137
+ planTemplate("spike", "x"),
138
+ ),
110
139
  [`${REPRO}/studies.json`]: registryText(r),
111
- [`${REPRO}/README.md`]: `# Reproduction index\n\nIntro.\n\n${indexBlock(r)}\n`,
140
+ [`${REPRO}/README.md`]: `# Reproduction index\n\nIntro.\n\n${indexBlock(r, NO_FACTS)}\n`,
112
141
  });
113
142
  expect(log).toEqual([
114
143
  "created papers/example/repro/P1-S02-ablation-of-the-guard/README.md",
144
+ "created papers/example/repro/P1-S02-ablation-of-the-guard/plan.json, the study's plan: its fields are there to fill in before the first run",
115
145
  "added P1-S02 to papers/example/repro/studies.json: kind spike, status designing, 2026-10-15",
116
146
  "wrote the index of 2 studies in papers/example/repro/README.md",
117
147
  "Next: write the study's one-sentence question in its row (`question`) and in its README.md. When it ends, set `status`, `result`, `result_file` and `in_paper` in its row, then run `npx paperlint studies index papers/example`.",
@@ -215,7 +245,7 @@ describe("studies index", () => {
215
245
  it("regenerates the block in place", () => {
216
246
  const tree = paper({
217
247
  [`${REPRO}/studies.json`]: registryText(r),
218
- [`${REPRO}/README.md`]: `# I\n\n${indexBlock(P1())}\n\nTail.\n`,
248
+ [`${REPRO}/README.md`]: `# I\n\n${indexBlock(P1(), NO_FACTS)}\n\nTail.\n`,
219
249
  });
220
250
  const { code, log, err, after } = studies(
221
251
  { sub: "index", paperDir: "papers/example" },
@@ -228,14 +258,14 @@ describe("studies index", () => {
228
258
  });
229
259
  expect(after).toEqual({
230
260
  ...tree,
231
- [`${REPRO}/README.md`]: `# I\n\n${indexBlock(r)}\n\nTail.\n`,
261
+ [`${REPRO}/README.md`]: `# I\n\n${indexBlock(r, NO_FACTS)}\n\nTail.\n`,
232
262
  });
233
263
  });
234
264
 
235
265
  it("says so and writes nothing when the index is in sync", () => {
236
266
  const tree = paper({
237
267
  [`${REPRO}/studies.json`]: registryText(r),
238
- [`${REPRO}/README.md`]: withIndex("# I\n", r),
268
+ [`${REPRO}/README.md`]: withIndex("# I\n", r, NO_FACTS),
239
269
  });
240
270
  const out = studies({ sub: "index" }, tree);
241
271
  expect([out.code, out.log, out.after]).toEqual([
@@ -260,7 +290,7 @@ describe("studies index", () => {
260
290
  describe("studies index — a table a formatter padded", () => {
261
291
  it("leaves an index in sync as it is, whatever its padding", () => {
262
292
  const r = P1(study({ title: "測定 A" }));
263
- const generated = withIndex("# I\n", r);
293
+ const generated = withIndex("# I\n", r, NO_FACTS);
264
294
  const padded = generated.replace("測定 A", "測定 A ");
265
295
  expect(padded).not.toBe(generated);
266
296
  const tree = paper({
@@ -309,7 +339,7 @@ describe("studies — run from inside the paper folder", () => {
309
339
  it("names the folder `.`", () => {
310
340
  const tree = paper({
311
341
  [`${REPRO}/studies.json`]: registryText(P1()),
312
- [`${REPRO}/README.md`]: withIndex("# I\n", P1()),
342
+ [`${REPRO}/README.md`]: withIndex("# I\n", P1(), NO_FACTS),
313
343
  });
314
344
  const out = studies({ sub: "index", paperDir: "." }, tree, DIR);
315
345
  expect([out.code, out.log]).toEqual([
@@ -324,12 +354,12 @@ describe("studies — the command line", () => {
324
354
  [
325
355
  "no subcommand",
326
356
  { sub: undefined },
327
- 'studies needs a subcommand: new or index — npx paperlint studies new <paper-dir> "<title>" [--kind <kind>] · npx paperlint studies index <paper-dir>',
357
+ 'studies needs a subcommand: new, index or gate — npx paperlint studies new <paper-dir> "<title>" [--kind <kind>] · npx paperlint studies index <paper-dir> · npx paperlint studies gate <paper-dir> <id>',
328
358
  ],
329
359
  [
330
360
  "an unknown subcommand",
331
361
  { sub: "add" },
332
- 'unknown subcommand `studies add`: new or index — npx paperlint studies new <paper-dir> "<title>" [--kind <kind>] · npx paperlint studies index <paper-dir>',
362
+ 'unknown subcommand `studies add`: new, index or gate — npx paperlint studies new <paper-dir> "<title>" [--kind <kind>] · npx paperlint studies index <paper-dir> · npx paperlint studies gate <paper-dir> <id>',
333
363
  ],
334
364
  [
335
365
  "new with no paper folder",
package/src/studies.ts CHANGED
@@ -1,10 +1,13 @@
1
1
  /**
2
2
  * `paperlint studies new|index <paper-dir>` — the two commands that write a paper's study registry,
3
- * `repro/studies.json`, and its index in `repro/README.md` (docs/design/study-registry.md).
3
+ * `repro/studies.json`, and its index in `repro/README.md` (docs/design/study-registry.md) — and
4
+ * `studies gate <paper-dir> <id>` (src/study-gate.ts), over the studies' modes
5
+ * (docs/design/study-modes.md).
4
6
  *
5
7
  * new <paper-dir> "<title>" [--kind <kind>]
6
- * the next id; `repro/<id>-<slug>/README.md` with the id and title in its frontmatter; the
7
- * row, `status: designing`, today's date; the index regenerated
8
+ * the next id; `repro/<id>-<slug>/README.md` with the id and title in its frontmatter, and
9
+ * `plan.json`, the plan's template; the row, `status: designing`, today's date, its `plan`;
10
+ * the index regenerated
8
11
  * index <paper-dir>
9
12
  * the index block of `repro/README.md`, regenerated from the registry
10
13
  *
@@ -18,11 +21,14 @@ import type { AbsolutePath } from "./domain/paths.ts";
18
21
  import { README, REPRO_DIR } from "./domain/repro-layout.ts";
19
22
  import {
20
23
  DEFAULT_KIND,
24
+ PLAN_FILE,
21
25
  STUDIES_FILE,
22
26
  STUDY_KINDS,
23
27
  addStudy,
24
- indexInSync,
28
+ indexCurrent,
25
29
  isStudyKind,
30
+ planTemplate,
31
+ planText,
26
32
  registryText,
27
33
  studyReadme,
28
34
  withIndex,
@@ -32,8 +38,12 @@ import {
32
38
  } from "./domain/study-registry.ts";
33
39
  import type { Files } from "./ports/files.ts";
34
40
  import type { StudyRegistryCodec } from "./ports/study-registry-codec.ts";
41
+ import type { ListEntries } from "./ports/list-entries.ts";
35
42
  import type { ListDir } from "./ports/talk-media.ts";
36
- import { registryOf } from "./study-registry-rule.ts";
43
+ import { readableFolders, registryOf } from "./study-registry-rule.ts";
44
+ import { runGate } from "./study-gate.ts";
45
+ import { indexInputOf } from "./study-index-input.ts";
46
+ import type { GitHistory, GitOrder } from "./ports/git-history.ts";
37
47
 
38
48
  /** What one `studies` run was asked, after the command line. */
39
49
  export interface StudiesArgs {
@@ -48,8 +58,10 @@ export interface StudiesArgs {
48
58
  }
49
59
 
50
60
  export interface StudiesDeps {
61
+ readonly git: GitHistory & GitOrder;
51
62
  readonly files: Files;
52
63
  readonly listDir: ListDir;
64
+ readonly entries: ListEntries;
53
65
  readonly codec: StudyRegistryCodec;
54
66
  readonly cwd: string;
55
67
  /** Today, `YYYY-MM-DD`: the root reads the clock. */
@@ -61,7 +73,8 @@ export interface StudiesDeps {
61
73
  const NEW_USAGE =
62
74
  'npx paperlint studies new <paper-dir> "<title>" [--kind <kind>]';
63
75
  const INDEX_USAGE = "npx paperlint studies index <paper-dir>";
64
- const SUBS = `new or index — ${NEW_USAGE} · ${INDEX_USAGE}`;
76
+ const GATE_USAGE = "npx paperlint studies gate <paper-dir> <id>";
77
+ const SUBS = `new, index or gate — ${[NEW_USAGE, INDEX_USAGE, GATE_USAGE].join(" · ")}`;
65
78
 
66
79
  const STATUS_FILE = "PIPELINE-STATUS.md";
67
80
 
@@ -89,10 +102,26 @@ function registryIn(p: Paper, deps: StudiesDeps): StudyRegistry | Refusal {
89
102
  case "unreadable":
90
103
  return refuse(1, `${file} is not a study registry: ${r.why}`);
91
104
  case "read":
92
- return r.registry;
105
+ return escaping(p, r.registry, deps) ?? r.registry;
93
106
  }
94
107
  }
95
108
 
109
+ /** The refusal when a row names a folder that is not one inside `repro/`; null when none does. */
110
+ function escaping(
111
+ p: Paper,
112
+ r: StudyRegistry,
113
+ deps: StudiesDeps,
114
+ ): Refusal | null {
115
+ const readable = readableFolders(p.dir, deps);
116
+ const s = r.studies.find((row) => !readable(row.folder));
117
+ return s === undefined
118
+ ? null
119
+ : refuse(
120
+ 1,
121
+ `${s.id} in ${p.shown}/${REPRO_DIR}/${STUDIES_FILE} names the folder \`${s.folder}\`, which is not one folder inside ${REPRO_DIR}/: nothing is read or written through it. Give the row its own folder's name, then run this again.`,
122
+ );
123
+ }
124
+
96
125
  const isRefusal = (v: unknown): v is Refusal =>
97
126
  typeof v === "object" && v !== null && "code" in v && "line" in v;
98
127
 
@@ -111,12 +140,13 @@ const studiesCount = (r: StudyRegistry): string =>
111
140
  * Write the index of `r` into the README; whether it wrote. A block already in sync is left as it
112
141
  * is, so the padding a formatter gave the table is never undone.
113
142
  */
114
- function writeIndex(dir: string, r: StudyRegistry, files: Files): boolean {
115
- const before = readmeOf(dir, files);
116
- if (indexInSync(before, r)) return false;
117
- files.writeAtomic(
143
+ function writeIndex(dir: string, r: StudyRegistry, deps: StudiesDeps): boolean {
144
+ const before = readmeOf(dir, deps.files);
145
+ const at = indexInputOf(dir, { kind: "read", registry: r }, deps);
146
+ if (indexCurrent(before, r, at)) return false;
147
+ deps.files.writeAtomic(
118
148
  callerPath(join(dir, REPRO_DIR, README)),
119
- text(withIndex(before, r)),
149
+ text(withIndex(before, r, at)),
120
150
  );
121
151
  return true;
122
152
  }
@@ -151,7 +181,7 @@ function runIndex(
151
181
  if (blocked !== null) return blocked;
152
182
  const readme = `${p.shown}/${REPRO_DIR}/${README}`;
153
183
  return [
154
- writeIndex(p.dir, r, deps.files)
184
+ writeIndex(p.dir, r, deps)
155
185
  ? `wrote the index of ${studiesCount(r)} in ${readme}`
156
186
  : `the index in ${readme} is in sync with ${studiesCount(r)}`,
157
187
  ];
@@ -178,21 +208,27 @@ function newArgs(
178
208
  );
179
209
  }
180
210
 
181
- /** Write a new study's README.md, the registry, and the index; the lines that say so. */
211
+ /** Write a new study's README.md and plan.json, the registry, and the index; the lines that say so. */
182
212
  function writeStudy(
183
213
  p: Paper,
184
214
  added: { readonly registry: StudyRegistry; readonly study: Study },
185
- files: Files,
215
+ deps: StudiesDeps,
186
216
  ): readonly string[] {
187
217
  const { study, registry } = added;
218
+ const { files } = deps;
188
219
  const at = (...names: readonly string[]): AbsolutePath =>
189
220
  callerPath(join(p.dir, REPRO_DIR, ...names));
190
221
  files.writeAtomic(at(study.folder, README), text(studyReadme(study)));
222
+ files.writeAtomic(
223
+ at(study.folder, PLAN_FILE),
224
+ text(planText(planTemplate(study.kind, study.folder))),
225
+ );
191
226
  files.writeAtomic(at(STUDIES_FILE), text(registryText(registry)));
192
- writeIndex(p.dir, registry, files);
227
+ writeIndex(p.dir, registry, deps);
193
228
  const repro = `${p.shown}/${REPRO_DIR}`;
194
229
  return [
195
230
  `created ${repro}/${study.folder}/${README}`,
231
+ `created ${repro}/${study.folder}/${PLAN_FILE}, the study's plan: its fields are there to fill in before the first run`,
196
232
  `added ${study.id} to ${repro}/${STUDIES_FILE}: kind ${study.kind}, status ${study.status}, ${study.date}`,
197
233
  `wrote the index of ${studiesCount(registry)} in ${repro}/${README}`,
198
234
  `Next: write the study's one-sentence question in its row (\`question\`) and in its README.md. When it ends, set \`status\`, \`result\`, \`result_file\` and \`in_paper\` in its row, then run \`npx paperlint studies index ${p.shown}\`.`,
@@ -219,35 +255,69 @@ function runNew(
219
255
  1,
220
256
  `${p.shown}/${REPRO_DIR}/${folder}/ already exists; it is never overwritten. Register it by hand, or remove it and run this again.`,
221
257
  )
222
- : writeStudy(p, added, deps.files);
258
+ : writeStudy(p, added, deps);
223
259
  }
224
260
 
225
261
  /** The paper folder the command line names, or the refusal. */
226
- function paperOf(a: StudiesArgs, deps: StudiesDeps): Refusal | Paper {
262
+ function paperOf(a: StudiesArgs, deps: StudiesDeps): Refusal | Chosen {
227
263
  if (a.sub === undefined)
228
264
  return refuse(2, `studies needs a subcommand: ${SUBS}`);
229
- if (a.sub !== "new" && a.sub !== "index")
265
+ const sub = COMMANDS.get(a.sub);
266
+ if (sub === undefined)
230
267
  return refuse(2, `unknown subcommand \`studies ${a.sub}\`: ${SUBS}`);
231
268
  if (a.paperDir === undefined)
232
- return refuse(
233
- 2,
234
- `studies ${a.sub} needs the paper's folder: ${a.sub === "new" ? NEW_USAGE : INDEX_USAGE}`,
235
- );
269
+ return refuse(2, `studies ${a.sub} needs the paper's folder: ${sub.usage}`);
236
270
  const dir = resolve(deps.cwd, a.paperDir);
237
271
  const shown = relative(deps.cwd, dir) || ".";
238
272
  return deps.files.isFile(callerPath(join(dir, STATUS_FILE)))
239
- ? { dir, shown }
273
+ ? { dir, shown, run: sub.run }
240
274
  : refuse(2, `${shown} is not a paper folder: it has no ${STATUS_FILE}`);
241
275
  }
242
276
 
277
+ /** `gate`: the study's id, one argument, then the gate. */
278
+ const gateRun = (
279
+ p: Paper,
280
+ a: StudiesArgs,
281
+ deps: StudiesDeps,
282
+ ): Refusal | readonly string[] => {
283
+ if (a.title === undefined)
284
+ return refuse(2, `studies gate needs the study's id: ${GATE_USAGE}`);
285
+ const [surplus] = a.extra;
286
+ if (surplus !== undefined)
287
+ return refuse(
288
+ 2,
289
+ `studies gate takes the paper's folder and one id — \`${surplus}\` is one argument too many`,
290
+ );
291
+ const out = runGate(p.dir, p.shown, a.title, deps);
292
+ return out.ok ? out.lines : out.refusal;
293
+ };
294
+
295
+ /** A run of a subcommand over a paper folder. */
296
+ type Run = (
297
+ p: Paper,
298
+ a: StudiesArgs,
299
+ d: StudiesDeps,
300
+ ) => Refusal | readonly string[];
301
+
302
+ /** The paper folder, and the run the subcommand chose. */
303
+ interface Chosen extends Paper {
304
+ readonly run: Run;
305
+ }
306
+
307
+ /** Each subcommand: its usage line and its run. */
308
+ const COMMANDS: ReadonlyMap<
309
+ string,
310
+ { readonly usage: string; readonly run: Run }
311
+ > = new Map([
312
+ ["new", { usage: NEW_USAGE, run: runNew }],
313
+ ["index", { usage: INDEX_USAGE, run: runIndex }],
314
+ ["gate", { usage: GATE_USAGE, run: gateRun }],
315
+ ]);
316
+
243
317
  /** The run: 0 done, 1 refused by the paper's state, 2 refused by the command line. */
244
318
  export function runStudies(a: StudiesArgs, deps: StudiesDeps): number {
245
319
  const p = paperOf(a, deps);
246
- const out = isRefusal(p)
247
- ? p
248
- : a.sub === "new"
249
- ? runNew(p, a, deps)
250
- : runIndex(p, a, deps);
320
+ const out = isRefusal(p) ? p : p.run(p, a, deps);
251
321
  if (isRefusal(out)) {
252
322
  deps.err(out.line);
253
323
  return out.code;