paperlint 10.3.0 → 10.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (139) hide show
  1. package/.github/workflows/ci.yml +15 -4
  2. package/README.md +1 -0
  3. package/dist/adapters/git/index.d.ts +10 -0
  4. package/dist/adapters/git/index.d.ts.map +1 -0
  5. package/dist/adapters/git/index.js +151 -0
  6. package/dist/adapters/git/index.js.map +1 -0
  7. package/dist/adapters/memory/index.d.ts +6 -0
  8. package/dist/adapters/memory/index.d.ts.map +1 -1
  9. package/dist/adapters/memory/index.js +8 -0
  10. package/dist/adapters/memory/index.js.map +1 -1
  11. package/dist/adapters/node/index.d.ts +1 -0
  12. package/dist/adapters/node/index.d.ts.map +1 -1
  13. package/dist/adapters/node/index.js +1 -0
  14. package/dist/adapters/node/index.js.map +1 -1
  15. package/dist/adapters/node/list-entries.io.d.ts +3 -0
  16. package/dist/adapters/node/list-entries.io.d.ts.map +1 -0
  17. package/dist/adapters/node/list-entries.io.js +24 -0
  18. package/dist/adapters/node/list-entries.io.js.map +1 -0
  19. package/dist/adapters/study-registry/index.d.ts +70 -10
  20. package/dist/adapters/study-registry/index.d.ts.map +1 -1
  21. package/dist/adapters/study-registry/index.js +84 -10
  22. package/dist/adapters/study-registry/index.js.map +1 -1
  23. package/dist/cli.d.ts.map +1 -1
  24. package/dist/cli.js +33 -3
  25. package/dist/cli.js.map +1 -1
  26. package/dist/domain/decide-first.d.ts +100 -0
  27. package/dist/domain/decide-first.d.ts.map +1 -0
  28. package/dist/domain/decide-first.js +102 -0
  29. package/dist/domain/decide-first.js.map +1 -0
  30. package/dist/domain/index-columns.d.ts +45 -0
  31. package/dist/domain/index-columns.d.ts.map +1 -0
  32. package/dist/domain/index-columns.js +62 -0
  33. package/dist/domain/index-columns.js.map +1 -0
  34. package/dist/domain/study-mode.d.ts +37 -0
  35. package/dist/domain/study-mode.d.ts.map +1 -0
  36. package/dist/domain/study-mode.js +45 -0
  37. package/dist/domain/study-mode.js.map +1 -0
  38. package/dist/domain/study-registry.d.ts +57 -10
  39. package/dist/domain/study-registry.d.ts.map +1 -1
  40. package/dist/domain/study-registry.js +106 -30
  41. package/dist/domain/study-registry.js.map +1 -1
  42. package/dist/outputs-before-decision-rule.d.ts +17 -0
  43. package/dist/outputs-before-decision-rule.d.ts.map +1 -0
  44. package/dist/outputs-before-decision-rule.js +90 -0
  45. package/dist/outputs-before-decision-rule.js.map +1 -0
  46. package/dist/ports/git-history.d.ts +44 -0
  47. package/dist/ports/git-history.d.ts.map +1 -0
  48. package/dist/ports/git-history.js +2 -0
  49. package/dist/ports/git-history.js.map +1 -0
  50. package/dist/ports/list-entries.d.ts +17 -0
  51. package/dist/ports/list-entries.d.ts.map +1 -0
  52. package/dist/ports/list-entries.js +2 -0
  53. package/dist/ports/list-entries.js.map +1 -0
  54. package/dist/ports/study-registry-codec.d.ts +6 -3
  55. package/dist/ports/study-registry-codec.d.ts.map +1 -1
  56. package/dist/studies.d.ts +4 -0
  57. package/dist/studies.d.ts.map +1 -1
  58. package/dist/studies.js +50 -22
  59. package/dist/studies.js.map +1 -1
  60. package/dist/study-gate.d.ts +14 -0
  61. package/dist/study-gate.d.ts.map +1 -0
  62. package/dist/study-gate.js +95 -0
  63. package/dist/study-gate.js.map +1 -0
  64. package/dist/study-index-input.d.ts +5 -0
  65. package/dist/study-index-input.d.ts.map +1 -0
  66. package/dist/study-index-input.js +13 -0
  67. package/dist/study-index-input.js.map +1 -0
  68. package/dist/study-modes.d.ts +45 -0
  69. package/dist/study-modes.d.ts.map +1 -0
  70. package/dist/study-modes.js +70 -0
  71. package/dist/study-modes.js.map +1 -0
  72. package/dist/study-registry-rule.d.ts +17 -1
  73. package/dist/study-registry-rule.d.ts.map +1 -1
  74. package/dist/study-registry-rule.js +34 -13
  75. package/dist/study-registry-rule.js.map +1 -1
  76. package/dist/study-run.d.ts +19 -0
  77. package/dist/study-run.d.ts.map +1 -0
  78. package/dist/study-run.js +9 -0
  79. package/dist/study-run.js.map +1 -0
  80. package/docs/design/study-modes.md +247 -0
  81. package/docs/design/study-registry.md +6 -0
  82. package/docs/e2e.md +8 -4
  83. package/docs/layout.md +22 -19
  84. package/docs/rules/repro/outputs-before-decision.md +119 -0
  85. package/docs/rules/repro/study-registered.md +47 -14
  86. package/docs/rules.md +54 -53
  87. package/docs/testing.md +1 -0
  88. package/package.json +2 -1
  89. package/scripts/check.ts +6 -0
  90. package/skills/camera-ready/SKILL.md +6 -1
  91. package/skills/camera-ready/SKILL.md.spec.ts +5 -0
  92. package/skills/draft-paper/SKILL.md +11 -3
  93. package/skills/draft-paper/SKILL.md.spec.ts +10 -2
  94. package/skills/harden-paper/SKILL.md +12 -2
  95. package/skills/harden-paper/SKILL.md.spec.ts +11 -1
  96. package/skills/osf-artifact-upload/SKILL.md +7 -1
  97. package/skills/osf-artifact-upload/SKILL.md.spec.ts +6 -0
  98. package/skills/pc-panel-review/SKILL.md +10 -3
  99. package/skills/pc-panel-review/SKILL.md.spec.ts +9 -2
  100. package/skills/tighten-paper/SKILL.md +5 -1
  101. package/skills/tighten-paper/SKILL.md.spec.ts +4 -0
  102. package/src/adapters/git/history.test.ts +121 -0
  103. package/src/adapters/git/index.test.ts +268 -0
  104. package/src/adapters/git/index.ts +227 -0
  105. package/src/adapters/memory/index.test.ts +15 -1
  106. package/src/adapters/memory/index.ts +13 -0
  107. package/src/adapters/node/index.ts +1 -0
  108. package/src/adapters/node/list-entries.io.ts +26 -0
  109. package/src/adapters/node/list-entries.test.ts +43 -0
  110. package/src/adapters/study-registry/index.test.ts +12 -9
  111. package/src/adapters/study-registry/index.ts +130 -24
  112. package/src/adapters/study-registry/mode.test.ts +162 -0
  113. package/src/cli.ts +47 -3
  114. package/src/domain/decide-first.test.ts +258 -0
  115. package/src/domain/decide-first.ts +243 -0
  116. package/src/domain/index-columns.test.ts +216 -0
  117. package/src/domain/index-columns.ts +105 -0
  118. package/src/domain/study-measurements.test.ts +9 -5
  119. package/src/domain/study-mode.test.ts +55 -0
  120. package/src/domain/study-mode.ts +78 -0
  121. package/src/domain/study-registry.test.ts +38 -31
  122. package/src/domain/study-registry.ts +170 -31
  123. package/src/new-paper.test.ts +5 -1
  124. package/src/outputs-before-decision-rule.test.ts +206 -0
  125. package/src/outputs-before-decision-rule.ts +124 -0
  126. package/src/ports/git-history.ts +44 -0
  127. package/src/ports/list-entries.ts +18 -0
  128. package/src/ports/study-registry-codec.ts +10 -3
  129. package/src/rule-cases.test.ts +78 -3
  130. package/src/studies-index-modes.test.ts +129 -0
  131. package/src/studies.test.ts +35 -11
  132. package/src/studies.ts +88 -27
  133. package/src/study-gate.test.ts +316 -0
  134. package/src/study-gate.ts +161 -0
  135. package/src/study-index-input.ts +23 -0
  136. package/src/study-modes.ts +120 -0
  137. package/src/study-registry-rule.test.ts +198 -11
  138. package/src/study-registry-rule.ts +51 -14
  139. package/src/study-run.ts +21 -0
@@ -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
@@ -23,7 +25,7 @@ import {
23
25
  STUDIES_FILE,
24
26
  STUDY_KINDS,
25
27
  addStudy,
26
- indexInSync,
28
+ indexCurrent,
27
29
  isStudyKind,
28
30
  planTemplate,
29
31
  planText,
@@ -36,8 +38,12 @@ import {
36
38
  } from "./domain/study-registry.ts";
37
39
  import type { Files } from "./ports/files.ts";
38
40
  import type { StudyRegistryCodec } from "./ports/study-registry-codec.ts";
41
+ import type { ListEntries } from "./ports/list-entries.ts";
39
42
  import type { ListDir } from "./ports/talk-media.ts";
40
- 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";
41
47
 
42
48
  /** What one `studies` run was asked, after the command line. */
43
49
  export interface StudiesArgs {
@@ -52,8 +58,10 @@ export interface StudiesArgs {
52
58
  }
53
59
 
54
60
  export interface StudiesDeps {
61
+ readonly git: GitHistory & GitOrder;
55
62
  readonly files: Files;
56
63
  readonly listDir: ListDir;
64
+ readonly entries: ListEntries;
57
65
  readonly codec: StudyRegistryCodec;
58
66
  readonly cwd: string;
59
67
  /** Today, `YYYY-MM-DD`: the root reads the clock. */
@@ -65,7 +73,8 @@ export interface StudiesDeps {
65
73
  const NEW_USAGE =
66
74
  'npx paperlint studies new <paper-dir> "<title>" [--kind <kind>]';
67
75
  const INDEX_USAGE = "npx paperlint studies index <paper-dir>";
68
- 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(" · ")}`;
69
78
 
70
79
  const STATUS_FILE = "PIPELINE-STATUS.md";
71
80
 
@@ -93,10 +102,26 @@ function registryIn(p: Paper, deps: StudiesDeps): StudyRegistry | Refusal {
93
102
  case "unreadable":
94
103
  return refuse(1, `${file} is not a study registry: ${r.why}`);
95
104
  case "read":
96
- return r.registry;
105
+ return escaping(p, r.registry, deps) ?? r.registry;
97
106
  }
98
107
  }
99
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
+
100
125
  const isRefusal = (v: unknown): v is Refusal =>
101
126
  typeof v === "object" && v !== null && "code" in v && "line" in v;
102
127
 
@@ -115,12 +140,13 @@ const studiesCount = (r: StudyRegistry): string =>
115
140
  * Write the index of `r` into the README; whether it wrote. A block already in sync is left as it
116
141
  * is, so the padding a formatter gave the table is never undone.
117
142
  */
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(
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(
122
148
  callerPath(join(dir, REPRO_DIR, README)),
123
- text(withIndex(before, r)),
149
+ text(withIndex(before, r, at)),
124
150
  );
125
151
  return true;
126
152
  }
@@ -155,7 +181,7 @@ function runIndex(
155
181
  if (blocked !== null) return blocked;
156
182
  const readme = `${p.shown}/${REPRO_DIR}/${README}`;
157
183
  return [
158
- writeIndex(p.dir, r, deps.files)
184
+ writeIndex(p.dir, r, deps)
159
185
  ? `wrote the index of ${studiesCount(r)} in ${readme}`
160
186
  : `the index in ${readme} is in sync with ${studiesCount(r)}`,
161
187
  ];
@@ -186,18 +212,19 @@ function newArgs(
186
212
  function writeStudy(
187
213
  p: Paper,
188
214
  added: { readonly registry: StudyRegistry; readonly study: Study },
189
- files: Files,
215
+ deps: StudiesDeps,
190
216
  ): readonly string[] {
191
217
  const { study, registry } = added;
218
+ const { files } = deps;
192
219
  const at = (...names: readonly string[]): AbsolutePath =>
193
220
  callerPath(join(p.dir, REPRO_DIR, ...names));
194
221
  files.writeAtomic(at(study.folder, README), text(studyReadme(study)));
195
222
  files.writeAtomic(
196
223
  at(study.folder, PLAN_FILE),
197
- text(planText(planTemplate())),
224
+ text(planText(planTemplate(study.kind, study.folder))),
198
225
  );
199
226
  files.writeAtomic(at(STUDIES_FILE), text(registryText(registry)));
200
- writeIndex(p.dir, registry, files);
227
+ writeIndex(p.dir, registry, deps);
201
228
  const repro = `${p.shown}/${REPRO_DIR}`;
202
229
  return [
203
230
  `created ${repro}/${study.folder}/${README}`,
@@ -228,35 +255,69 @@ function runNew(
228
255
  1,
229
256
  `${p.shown}/${REPRO_DIR}/${folder}/ already exists; it is never overwritten. Register it by hand, or remove it and run this again.`,
230
257
  )
231
- : writeStudy(p, added, deps.files);
258
+ : writeStudy(p, added, deps);
232
259
  }
233
260
 
234
261
  /** The paper folder the command line names, or the refusal. */
235
- function paperOf(a: StudiesArgs, deps: StudiesDeps): Refusal | Paper {
262
+ function paperOf(a: StudiesArgs, deps: StudiesDeps): Refusal | Chosen {
236
263
  if (a.sub === undefined)
237
264
  return refuse(2, `studies needs a subcommand: ${SUBS}`);
238
- if (a.sub !== "new" && a.sub !== "index")
265
+ const sub = COMMANDS.get(a.sub);
266
+ if (sub === undefined)
239
267
  return refuse(2, `unknown subcommand \`studies ${a.sub}\`: ${SUBS}`);
240
268
  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
- );
269
+ return refuse(2, `studies ${a.sub} needs the paper's folder: ${sub.usage}`);
245
270
  const dir = resolve(deps.cwd, a.paperDir);
246
271
  const shown = relative(deps.cwd, dir) || ".";
247
272
  return deps.files.isFile(callerPath(join(dir, STATUS_FILE)))
248
- ? { dir, shown }
273
+ ? { dir, shown, run: sub.run }
249
274
  : refuse(2, `${shown} is not a paper folder: it has no ${STATUS_FILE}`);
250
275
  }
251
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
+
252
317
  /** The run: 0 done, 1 refused by the paper's state, 2 refused by the command line. */
253
318
  export function runStudies(a: StudiesArgs, deps: StudiesDeps): number {
254
319
  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);
320
+ const out = isRefusal(p) ? p : p.run(p, a, deps);
260
321
  if (isRefusal(out)) {
261
322
  deps.err(out.line);
262
323
  return out.code;
@@ -0,0 +1,316 @@
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 {
9
+ DATE,
10
+ DECIDED,
11
+ DIR,
12
+ ROW,
13
+ SHA,
14
+ planOf,
15
+ studies,
16
+ tree,
17
+ } from "../test/study-tree.ts";
18
+ const DECISION = `${DIR}/repro/P1-S01-outcome/decision.md`;
19
+ const decided = (mode: StudyMode = DECIDED) => tree({}, planOf(mode));
20
+
21
+ it("open once the decision is committed as it stands: exit 0 with its commit and date", () => {
22
+ const r = studies(
23
+ "gate",
24
+ decided(),
25
+ {},
26
+ {
27
+ git: { [DECISION]: { kind: "committed", commit: SHA, date: DATE } },
28
+ },
29
+ );
30
+ // Guards: the line a runner's log keeps, and the exit code `gate && run` reads.
31
+ expect([r.code, r.log, r.err]).toEqual([
32
+ 0,
33
+ [
34
+ `P1-S01: the decision repro/P1-S01-outcome/decision.md is committed in ${SHA} (${DATE}); the run may start.`,
35
+ ],
36
+ [],
37
+ ]);
38
+ });
39
+
40
+ it("closed while the decision is uncommitted, changed since its commit, or unknown to git", () => {
41
+ const gate = (state: Parameters<typeof studies>[3]) =>
42
+ studies("gate", decided(), {}, state);
43
+ // Guards: each reason the run must wait is said, exit 1.
44
+ expect(
45
+ [
46
+ gate({}),
47
+ gate({ git: { [DECISION]: { kind: "changed", commit: SHA } } }),
48
+ gate({
49
+ git: { [DECISION]: { kind: "unknown", why: "git is not installed" } },
50
+ }),
51
+ ].map((r) => [r.code, r.err]),
52
+ ).toEqual([
53
+ [
54
+ 1,
55
+ [
56
+ "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.",
57
+ ],
58
+ ],
59
+ [
60
+ 1,
61
+ [
62
+ `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.`,
63
+ ],
64
+ ],
65
+ [
66
+ 1,
67
+ [
68
+ "P1-S01: git could not say whether the decision repro/P1-S01-outcome/decision.md is committed: git is not installed.",
69
+ ],
70
+ ],
71
+ ]);
72
+ });
73
+
74
+ it("refuses a plan that names no decision file, or one its own outputs match", () => {
75
+ // Guards: a gate on nothing, or on a file the run itself produces, is no gate.
76
+ expect([
77
+ studies("gate", decided({ ...DECIDED, decision: { file: " ", note: "n" } }))
78
+ .err,
79
+ studies("gate", decided({ ...DECIDED, outputs: ["*.md"] })).err,
80
+ ]).toEqual([
81
+ [
82
+ "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.",
83
+ ],
84
+ [
85
+ "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.",
86
+ ],
87
+ ]);
88
+ });
89
+
90
+ it("an exploratory study's run waits on nothing: exit 0, saying so", () => {
91
+ // Guards: the runner may call gate on every study; the answer names the mode.
92
+ expect(
93
+ studies("gate", decided({ kind: "exploratory", reason: "a first look" })),
94
+ ).toMatchObject({
95
+ code: 0,
96
+ log: [
97
+ "P1-S01 is exploratory (a first look): it has no gate, and its results are reported as exploratory.",
98
+ ],
99
+ });
100
+ });
101
+
102
+ it("refuses a study that declares no mode, and one whose row names a folder outside repro/", () => {
103
+ // Guards: a measurement with no mode is not gated by default — it is refused until it says how;
104
+ // and a row naming `../x` is never read through, so no file outside the paper decides the gate.
105
+ expect(
106
+ [
107
+ studies("gate", tree({}, planOf(undefined))),
108
+ studies(
109
+ "gate",
110
+ tree(
111
+ {
112
+ [`${DIR}/x/plan.json`]: JSON.stringify(planOf(DECIDED)),
113
+ },
114
+ planOf(DECIDED),
115
+ { ...ROW, folder: "../x" },
116
+ ),
117
+ ),
118
+ ].map((r) => [r.code, r.err]),
119
+ ).toEqual([
120
+ [
121
+ 1,
122
+ [
123
+ "P1-S01 declares no mode in papers/example: no row of papers/example/repro/studies.json with this id names a plan.json that parses with `mode`. Lint the paper (`npx paperlint lint`) to see why.",
124
+ ],
125
+ ],
126
+ [
127
+ 1,
128
+ [
129
+ "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.",
130
+ ],
131
+ ],
132
+ ]);
133
+ });
134
+
135
+ it("refuses a command line without the study's id, or with an argument too many", () => {
136
+ // Guards: exit 2 is the command line's, never the paper's state.
137
+ expect(
138
+ [
139
+ studies("gate", decided(), { title: undefined }),
140
+ studies("gate", decided(), { extra: ["P1-S02"] }),
141
+ ].map((r) => [r.code, r.err]),
142
+ ).toEqual([
143
+ [
144
+ 2,
145
+ [
146
+ "studies gate needs the study's id: npx paperlint studies gate <paper-dir> <id>",
147
+ ],
148
+ ],
149
+ [
150
+ 2,
151
+ [
152
+ "studies gate takes the paper's folder and one id — `P1-S02` is one argument too many",
153
+ ],
154
+ ],
155
+ ]);
156
+ });
157
+
158
+ it("a plan it cannot read through declares no mode: no registry, a plan outside its folder, a NUL, not JSON", () => {
159
+ const noMode = (t: Readonly<Record<string, string>>) =>
160
+ studies("gate", t).err[0]?.split(":")[0];
161
+ const withoutRegistry = Object.fromEntries(
162
+ Object.entries(decided()).filter(([k]) => !k.endsWith("studies.json")),
163
+ );
164
+ // Guards: each is refused as declaring no mode — none is read past, none opens the gate.
165
+ expect([
166
+ noMode(withoutRegistry),
167
+ noMode(tree({}, planOf(DECIDED), { ...ROW, plan: "../plan.json" })),
168
+ noMode(tree({}, planOf(DECIDED), { ...ROW, plan: "a\0b.json" })),
169
+ noMode({ ...decided(), [`${DIR}/repro/P1-S01-outcome/plan.json`]: "{" }),
170
+ ]).toEqual(Array(4).fill("P1-S01 declares no mode in papers/example"));
171
+ });
172
+
173
+ it("refuses a decision that names a directory, whatever git says of it", () => {
174
+ const dir = "repro/P1-S01-outcome/decisions";
175
+ const r = studies(
176
+ "gate",
177
+ tree(
178
+ { [`${DIR}/${dir}/a.md`]: "exclude run 7\n" },
179
+ planOf({ ...DECIDED, decision: { file: dir, note: "n" } }),
180
+ ),
181
+ {},
182
+ {
183
+ git: {
184
+ [`${DIR}/${dir}`]: { kind: "committed", commit: SHA, date: DATE },
185
+ },
186
+ },
187
+ );
188
+ // Guards: git answers for a directory with the last commit under it — never a decision as committed.
189
+ expect([r.code, r.err]).toEqual([
190
+ 1,
191
+ [
192
+ `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\`.`,
193
+ ],
194
+ ]);
195
+ });
196
+
197
+ it("closed when the decision was committed and then deleted: it is not in HEAD", () => {
198
+ const r = studies(
199
+ "gate",
200
+ decided(),
201
+ {},
202
+ { git: { [DECISION]: { kind: "deleted", commit: SHA } } },
203
+ );
204
+ // Guards: a decision deleted in a later commit leaves a clean tree, and still gates nothing.
205
+ expect([r.code, r.err]).toEqual([
206
+ 1,
207
+ [
208
+ `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.`,
209
+ ],
210
+ ]);
211
+ });
212
+
213
+ it("refuses a plan that is still a draft: a mode is no decision until its plan is ready", () => {
214
+ const r = studies(
215
+ "gate",
216
+ decided({ ...DECIDED, outputs: [] }),
217
+ {},
218
+ { git: { [DECISION]: { kind: "committed", commit: SHA, date: DATE } } },
219
+ );
220
+ const blankReason = studies(
221
+ "gate",
222
+ decided({ kind: "exploratory", reason: " " }),
223
+ );
224
+ const twoEmpty = studies(
225
+ "gate",
226
+ decided({
227
+ ...DECIDED,
228
+ outputs: [],
229
+ decision: { ...DECIDED.decision, note: "" },
230
+ }),
231
+ );
232
+ // Guards: a draft names no outputs to keep from the decision, and an exploratory plan with no
233
+ // reason is no declaration; the gate opens on a ready plan only, whatever its mode.
234
+ expect([
235
+ [r.code, r.err],
236
+ [blankReason.code, blankReason.err],
237
+ [twoEmpty.code, twoEmpty.err],
238
+ ]).toEqual([
239
+ [
240
+ 1,
241
+ [
242
+ "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.",
243
+ ],
244
+ ],
245
+ [
246
+ 1,
247
+ [
248
+ "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.",
249
+ ],
250
+ ],
251
+ [
252
+ 1,
253
+ [
254
+ "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.",
255
+ ],
256
+ ],
257
+ ]);
258
+ });
259
+
260
+ it("refuses a decision or a study folder that is a symbolic link", () => {
261
+ const committed = {
262
+ [DECISION]: { kind: "committed", commit: SHA, date: DATE },
263
+ } as const;
264
+ const decisionLink = studies(
265
+ "gate",
266
+ { ...decided(), [DECISION]: "exclude run 7\n" },
267
+ {},
268
+ { git: committed, links: [DECISION] },
269
+ );
270
+ const folderLink = studies(
271
+ "gate",
272
+ decided(),
273
+ {},
274
+ { git: committed, links: [`${DIR}/repro/P1-S01-outcome`] },
275
+ );
276
+ // Guards: git records a link, not the file it points to; a linked folder leads out of `repro/`.
277
+ expect([
278
+ [decisionLink.code, decisionLink.err],
279
+ [folderLink.code, folderLink.err],
280
+ ]).toEqual([
281
+ [
282
+ 1,
283
+ [
284
+ "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`.",
285
+ ],
286
+ ],
287
+ [
288
+ 1,
289
+ [
290
+ "P1-S01: its row names the folder `P1-S01-outcome`, which is not one folder inside papers/example/repro/; nothing is read through it. Give the row its own folder's name.",
291
+ ],
292
+ ],
293
+ ]);
294
+ });
295
+
296
+ it("never reads a plan through a backslash: `..\\x\\plan.json` is outside the folder on Windows", () => {
297
+ const plan = "..\\x\\plan.json";
298
+ const r = studies(
299
+ "gate",
300
+ tree(
301
+ {
302
+ [`${DIR}/repro/P1-S01-outcome/${plan}`]: JSON.stringify(
303
+ planOf(DECIDED),
304
+ ),
305
+ },
306
+ planOf(DECIDED),
307
+ { ...ROW, plan },
308
+ ),
309
+ {},
310
+ { git: { [DECISION]: { kind: "committed", commit: SHA, date: DATE } } },
311
+ );
312
+ // Guards: the containment test is the registry rule's own `isInside`, the same on every platform.
313
+ expect(r.err[0]?.split(":")[0]).toBe(
314
+ "P1-S01 declares no mode in papers/example",
315
+ );
316
+ });