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,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 papers/example/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 a symbolic link: nothing is read or written through it. Give the row a real folder inside papers/example/repro/, then run this again.",
126
+ ],
127
+ false,
128
+ ]);
129
+ });
@@ -5,7 +5,11 @@
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,9 +20,13 @@ import {
16
20
  withIndex,
17
21
  type Study,
18
22
  type StudyRegistry,
23
+ type IndexInput,
19
24
  } from "./domain/study-registry.ts";
20
25
  import { runStudies, type StudiesArgs } from "./studies.ts";
21
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
+
22
30
  const CWD = "/work";
23
31
  const DIR = "/work/papers/example";
24
32
  const REPRO = `${DIR}/repro`;
@@ -68,9 +76,24 @@ function studies(
68
76
  {
69
77
  files,
70
78
  listDir: memoryListDir(files),
79
+ entries: memoryListEntries(files),
71
80
  codec: studyRegistryCodec,
72
81
  cwd,
73
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
+ },
74
97
  log: (l) => log.push(l),
75
98
  err: (l) => err.push(l),
76
99
  },
@@ -110,10 +133,11 @@ describe("studies new", () => {
110
133
  expect(after).toEqual({
111
134
  ...before,
112
135
  [`${REPRO}/P1-S02-ablation-of-the-guard/README.md`]: studyReadme(added),
113
- [`${REPRO}/P1-S02-ablation-of-the-guard/plan.json`]:
114
- planText(planTemplate()),
136
+ [`${REPRO}/P1-S02-ablation-of-the-guard/plan.json`]: planText(
137
+ planTemplate("spike", "x"),
138
+ ),
115
139
  [`${REPRO}/studies.json`]: registryText(r),
116
- [`${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`,
117
141
  });
118
142
  expect(log).toEqual([
119
143
  "created papers/example/repro/P1-S02-ablation-of-the-guard/README.md",
@@ -221,7 +245,7 @@ describe("studies index", () => {
221
245
  it("regenerates the block in place", () => {
222
246
  const tree = paper({
223
247
  [`${REPRO}/studies.json`]: registryText(r),
224
- [`${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`,
225
249
  });
226
250
  const { code, log, err, after } = studies(
227
251
  { sub: "index", paperDir: "papers/example" },
@@ -234,14 +258,14 @@ describe("studies index", () => {
234
258
  });
235
259
  expect(after).toEqual({
236
260
  ...tree,
237
- [`${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`,
238
262
  });
239
263
  });
240
264
 
241
265
  it("says so and writes nothing when the index is in sync", () => {
242
266
  const tree = paper({
243
267
  [`${REPRO}/studies.json`]: registryText(r),
244
- [`${REPRO}/README.md`]: withIndex("# I\n", r),
268
+ [`${REPRO}/README.md`]: withIndex("# I\n", r, NO_FACTS),
245
269
  });
246
270
  const out = studies({ sub: "index" }, tree);
247
271
  expect([out.code, out.log, out.after]).toEqual([
@@ -266,7 +290,7 @@ describe("studies index", () => {
266
290
  describe("studies index — a table a formatter padded", () => {
267
291
  it("leaves an index in sync as it is, whatever its padding", () => {
268
292
  const r = P1(study({ title: "測定 A" }));
269
- const generated = withIndex("# I\n", r);
293
+ const generated = withIndex("# I\n", r, NO_FACTS);
270
294
  const padded = generated.replace("測定 A", "測定 A ");
271
295
  expect(padded).not.toBe(generated);
272
296
  const tree = paper({
@@ -315,7 +339,7 @@ describe("studies — run from inside the paper folder", () => {
315
339
  it("names the folder `.`", () => {
316
340
  const tree = paper({
317
341
  [`${REPRO}/studies.json`]: registryText(P1()),
318
- [`${REPRO}/README.md`]: withIndex("# I\n", P1()),
342
+ [`${REPRO}/README.md`]: withIndex("# I\n", P1(), NO_FACTS),
319
343
  });
320
344
  const out = studies({ sub: "index", paperDir: "." }, tree, DIR);
321
345
  expect([out.code, out.log]).toEqual([
@@ -330,12 +354,12 @@ describe("studies — the command line", () => {
330
354
  [
331
355
  "no subcommand",
332
356
  { sub: undefined },
333
- '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>',
334
358
  ],
335
359
  [
336
360
  "an unknown subcommand",
337
361
  { sub: "add" },
338
- '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>',
339
363
  ],
340
364
  [
341
365
  "new with no paper folder",
package/src/studies.ts CHANGED
@@ -1,6 +1,8 @@
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
8
  * the next id; `repro/<id>-<slug>/README.md` with the id and title in its frontmatter, and
@@ -14,6 +16,7 @@
14
16
  * registry is refused with the file to write rather than given a guessed one.
15
17
  */
16
18
  import { join, relative, resolve } from "node:path";
19
+ import { find, map, pipe } from "remeda";
17
20
  import { callerPath } from "./caller-path.ts";
18
21
  import type { AbsolutePath } from "./domain/paths.ts";
19
22
  import { README, REPRO_DIR } from "./domain/repro-layout.ts";
@@ -23,7 +26,7 @@ import {
23
26
  STUDIES_FILE,
24
27
  STUDY_KINDS,
25
28
  addStudy,
26
- indexInSync,
29
+ indexCurrent,
27
30
  isStudyKind,
28
31
  planTemplate,
29
32
  planText,
@@ -35,9 +38,15 @@ import {
35
38
  type StudyRegistry,
36
39
  } from "./domain/study-registry.ts";
37
40
  import type { Files } from "./ports/files.ts";
41
+ import type { FolderRefusal } from "./ports/study-folder.ts";
38
42
  import type { StudyRegistryCodec } from "./ports/study-registry-codec.ts";
43
+ import type { ListEntries } from "./ports/list-entries.ts";
39
44
  import type { ListDir } from "./ports/talk-media.ts";
40
45
  import { registryOf } from "./study-registry-rule.ts";
46
+ import { folderRefusalText, studyFolder } from "./study-folder.ts";
47
+ import { runGate } from "./study-gate.ts";
48
+ import { indexInputOf } from "./study-index-input.ts";
49
+ import type { GitHistory, GitOrder } from "./ports/git-history.ts";
41
50
 
42
51
  /** What one `studies` run was asked, after the command line. */
43
52
  export interface StudiesArgs {
@@ -52,8 +61,10 @@ export interface StudiesArgs {
52
61
  }
53
62
 
54
63
  export interface StudiesDeps {
64
+ readonly git: GitHistory & GitOrder;
55
65
  readonly files: Files;
56
66
  readonly listDir: ListDir;
67
+ readonly entries: ListEntries;
57
68
  readonly codec: StudyRegistryCodec;
58
69
  readonly cwd: string;
59
70
  /** Today, `YYYY-MM-DD`: the root reads the clock. */
@@ -65,7 +76,8 @@ export interface StudiesDeps {
65
76
  const NEW_USAGE =
66
77
  'npx paperlint studies new <paper-dir> "<title>" [--kind <kind>]';
67
78
  const INDEX_USAGE = "npx paperlint studies index <paper-dir>";
68
- const SUBS = `new or index — ${NEW_USAGE} · ${INDEX_USAGE}`;
79
+ const GATE_USAGE = "npx paperlint studies gate <paper-dir> <id>";
80
+ const SUBS = `new, index or gate — ${[NEW_USAGE, INDEX_USAGE, GATE_USAGE].join(" · ")}`;
69
81
 
70
82
  const STATUS_FILE = "PIPELINE-STATUS.md";
71
83
 
@@ -93,10 +105,32 @@ function registryIn(p: Paper, deps: StudiesDeps): StudyRegistry | Refusal {
93
105
  case "unreadable":
94
106
  return refuse(1, `${file} is not a study registry: ${r.why}`);
95
107
  case "read":
96
- return r.registry;
108
+ return escaping(p, r.registry, deps) ?? r.registry;
97
109
  }
98
110
  }
99
111
 
112
+ /** The refusal when a row names a folder that may not be entered; null when none does. */
113
+ function escaping(
114
+ p: Paper,
115
+ r: StudyRegistry,
116
+ deps: StudiesDeps,
117
+ ): Refusal | null {
118
+ const folder = studyFolder(p.dir, deps);
119
+ const found = pipe(
120
+ r.studies,
121
+ map((row) => ({ row, why: folder.refusal(row.folder) })),
122
+ find(
123
+ (x): x is typeof x & { readonly why: FolderRefusal } => x.why !== null,
124
+ ),
125
+ );
126
+ if (found === undefined) return null;
127
+ const { reason, fix } = folderRefusalText(found.why, p.shown);
128
+ return refuse(
129
+ 1,
130
+ `${found.row.id} in ${p.shown}/${REPRO_DIR}/${STUDIES_FILE} names the folder \`${found.row.folder}\`, which ${reason}: nothing is read or written through it. ${fix}, then run this again.`,
131
+ );
132
+ }
133
+
100
134
  const isRefusal = (v: unknown): v is Refusal =>
101
135
  typeof v === "object" && v !== null && "code" in v && "line" in v;
102
136
 
@@ -115,12 +149,13 @@ const studiesCount = (r: StudyRegistry): string =>
115
149
  * Write the index of `r` into the README; whether it wrote. A block already in sync is left as it
116
150
  * is, so the padding a formatter gave the table is never undone.
117
151
  */
118
- function writeIndex(dir: string, r: StudyRegistry, files: Files): boolean {
119
- const before = readmeOf(dir, files);
120
- if (indexInSync(before, r)) return false;
121
- files.writeAtomic(
152
+ function writeIndex(dir: string, r: StudyRegistry, deps: StudiesDeps): boolean {
153
+ const before = readmeOf(dir, deps.files);
154
+ const at = indexInputOf(dir, { kind: "read", registry: r }, deps);
155
+ if (indexCurrent(before, r, at)) return false;
156
+ deps.files.writeAtomic(
122
157
  callerPath(join(dir, REPRO_DIR, README)),
123
- text(withIndex(before, r)),
158
+ text(withIndex(before, r, at)),
124
159
  );
125
160
  return true;
126
161
  }
@@ -155,7 +190,7 @@ function runIndex(
155
190
  if (blocked !== null) return blocked;
156
191
  const readme = `${p.shown}/${REPRO_DIR}/${README}`;
157
192
  return [
158
- writeIndex(p.dir, r, deps.files)
193
+ writeIndex(p.dir, r, deps)
159
194
  ? `wrote the index of ${studiesCount(r)} in ${readme}`
160
195
  : `the index in ${readme} is in sync with ${studiesCount(r)}`,
161
196
  ];
@@ -186,18 +221,19 @@ function newArgs(
186
221
  function writeStudy(
187
222
  p: Paper,
188
223
  added: { readonly registry: StudyRegistry; readonly study: Study },
189
- files: Files,
224
+ deps: StudiesDeps,
190
225
  ): readonly string[] {
191
226
  const { study, registry } = added;
227
+ const { files } = deps;
192
228
  const at = (...names: readonly string[]): AbsolutePath =>
193
229
  callerPath(join(p.dir, REPRO_DIR, ...names));
194
230
  files.writeAtomic(at(study.folder, README), text(studyReadme(study)));
195
231
  files.writeAtomic(
196
232
  at(study.folder, PLAN_FILE),
197
- text(planText(planTemplate())),
233
+ text(planText(planTemplate(study.kind, study.folder))),
198
234
  );
199
235
  files.writeAtomic(at(STUDIES_FILE), text(registryText(registry)));
200
- writeIndex(p.dir, registry, files);
236
+ writeIndex(p.dir, registry, deps);
201
237
  const repro = `${p.shown}/${REPRO_DIR}`;
202
238
  return [
203
239
  `created ${repro}/${study.folder}/${README}`,
@@ -228,35 +264,69 @@ function runNew(
228
264
  1,
229
265
  `${p.shown}/${REPRO_DIR}/${folder}/ already exists; it is never overwritten. Register it by hand, or remove it and run this again.`,
230
266
  )
231
- : writeStudy(p, added, deps.files);
267
+ : writeStudy(p, added, deps);
232
268
  }
233
269
 
234
270
  /** The paper folder the command line names, or the refusal. */
235
- function paperOf(a: StudiesArgs, deps: StudiesDeps): Refusal | Paper {
271
+ function paperOf(a: StudiesArgs, deps: StudiesDeps): Refusal | Chosen {
236
272
  if (a.sub === undefined)
237
273
  return refuse(2, `studies needs a subcommand: ${SUBS}`);
238
- if (a.sub !== "new" && a.sub !== "index")
274
+ const sub = COMMANDS.get(a.sub);
275
+ if (sub === undefined)
239
276
  return refuse(2, `unknown subcommand \`studies ${a.sub}\`: ${SUBS}`);
240
277
  if (a.paperDir === undefined)
241
- return refuse(
242
- 2,
243
- `studies ${a.sub} needs the paper's folder: ${a.sub === "new" ? NEW_USAGE : INDEX_USAGE}`,
244
- );
278
+ return refuse(2, `studies ${a.sub} needs the paper's folder: ${sub.usage}`);
245
279
  const dir = resolve(deps.cwd, a.paperDir);
246
280
  const shown = relative(deps.cwd, dir) || ".";
247
281
  return deps.files.isFile(callerPath(join(dir, STATUS_FILE)))
248
- ? { dir, shown }
282
+ ? { dir, shown, run: sub.run }
249
283
  : refuse(2, `${shown} is not a paper folder: it has no ${STATUS_FILE}`);
250
284
  }
251
285
 
286
+ /** `gate`: the study's id, one argument, then the gate. */
287
+ const gateRun = (
288
+ p: Paper,
289
+ a: StudiesArgs,
290
+ deps: StudiesDeps,
291
+ ): Refusal | readonly string[] => {
292
+ if (a.title === undefined)
293
+ return refuse(2, `studies gate needs the study's id: ${GATE_USAGE}`);
294
+ const [surplus] = a.extra;
295
+ if (surplus !== undefined)
296
+ return refuse(
297
+ 2,
298
+ `studies gate takes the paper's folder and one id — \`${surplus}\` is one argument too many`,
299
+ );
300
+ const out = runGate(p.dir, p.shown, a.title, deps);
301
+ return out.ok ? out.lines : out.refusal;
302
+ };
303
+
304
+ /** A run of a subcommand over a paper folder. */
305
+ type Run = (
306
+ p: Paper,
307
+ a: StudiesArgs,
308
+ d: StudiesDeps,
309
+ ) => Refusal | readonly string[];
310
+
311
+ /** The paper folder, and the run the subcommand chose. */
312
+ interface Chosen extends Paper {
313
+ readonly run: Run;
314
+ }
315
+
316
+ /** Each subcommand: its usage line and its run. */
317
+ const COMMANDS: ReadonlyMap<
318
+ string,
319
+ { readonly usage: string; readonly run: Run }
320
+ > = new Map([
321
+ ["new", { usage: NEW_USAGE, run: runNew }],
322
+ ["index", { usage: INDEX_USAGE, run: runIndex }],
323
+ ["gate", { usage: GATE_USAGE, run: gateRun }],
324
+ ]);
325
+
252
326
  /** The run: 0 done, 1 refused by the paper's state, 2 refused by the command line. */
253
327
  export function runStudies(a: StudiesArgs, deps: StudiesDeps): number {
254
328
  const p = paperOf(a, deps);
255
- const out = isRefusal(p)
256
- ? p
257
- : a.sub === "new"
258
- ? runNew(p, a, deps)
259
- : runIndex(p, a, deps);
329
+ const out = isRefusal(p) ? p : p.run(p, a, deps);
260
330
  if (isRefusal(out)) {
261
331
  deps.err(out.line);
262
332
  return out.code;