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
@@ -0,0 +1,227 @@
1
+ /**
2
+ * The git ports over the git CLI, through `RunProcess`. Run from a folder of the paper (which exists),
3
+ * with absolute pathspecs, so it answers for whichever repository holds the paper.
4
+ *
5
+ * committed the last commit that touched a file (`git log -1`), and whether the file changed since
6
+ * (`git status --porcelain`)
7
+ * order whether the clone is shallow; the decision file's first commit; every file's first
8
+ * commit under the study's folder (`git log --reverse --name-only`); and for each, whether
9
+ * the decision's first commit is its strict ancestor (`git merge-base --is-ancestor`)
10
+ */
11
+ import type { AbsolutePath } from "../../domain/paths.ts";
12
+ import type {
13
+ CommitAt,
14
+ Committed,
15
+ FirstCommit,
16
+ GitHistory,
17
+ GitOrder,
18
+ Order,
19
+ OrderFacts,
20
+ } from "../../ports/git-history.ts";
21
+ import type { ProcessExit, RunProcess } from "../../ports/process.ts";
22
+
23
+ const TIMEOUT_MS = 60_000;
24
+
25
+ /** git's stdout, or why it gave none; `allowed` lists the exit statuses that are answers. */
26
+ const stdoutOf = (
27
+ x: ProcessExit,
28
+ allowed: readonly number[] = [0],
29
+ ):
30
+ | { readonly out: string; readonly status: number }
31
+ | { readonly why: string } => {
32
+ switch (x.kind) {
33
+ case "exited":
34
+ return allowed.includes(x.status)
35
+ ? { out: x.stdout, status: x.status }
36
+ : { why: x.stderr.trim() || `git exited ${String(x.status)}` };
37
+ case "not-found":
38
+ return { why: "git is not installed" };
39
+ case "spawn-failed":
40
+ return { why: x.message };
41
+ case "signalled":
42
+ case "timed-out":
43
+ return { why: `git did not finish (${x.kind})` };
44
+ }
45
+ };
46
+
47
+ /** git, run through `run` in `cwd` with `env` as its whole environment. */
48
+ const gitIn =
49
+ (run: RunProcess, env: Readonly<Record<string, string>>) =>
50
+ (cwd: AbsolutePath, args: readonly string[], allowed?: readonly number[]) =>
51
+ stdoutOf(
52
+ run.run({
53
+ file: "git",
54
+ args: ["-c", "core.quotepath=false", ...args],
55
+ env,
56
+ timeoutMs: TIMEOUT_MS,
57
+ cwd,
58
+ maxOutputBytes: 64 * 1024 * 1024,
59
+ }),
60
+ allowed,
61
+ );
62
+
63
+ /** `<sha>\t<date>` as a commit. */
64
+ const commitAt = (line: string): CommitAt => {
65
+ const parts = line.split("\t");
66
+ return { commit: nth(parts, 0), date: nth(parts, 1) };
67
+ };
68
+
69
+ /** The `i`th of `parts`, "" when there are fewer. */
70
+ const nth = (parts: readonly string[], i: number): string =>
71
+ parts.slice(i, i + 1).join("");
72
+
73
+ /**
74
+ * The first commit of each path in `git log --reverse --format=%x01%H%x09%cI --name-only` output:
75
+ * a line starting with \x01 opens a commit, the lines after it name its files.
76
+ */
77
+ export const firstCommitsOf = (log: string): ReadonlyMap<string, CommitAt> =>
78
+ log
79
+ .split("\x01")
80
+ .slice(1)
81
+ .reduce((seen, block) => {
82
+ const lines = block.split("\n");
83
+ const at = commitAt(nth(lines, 0));
84
+ const names = lines.slice(1);
85
+ return names
86
+ .filter((n) => n !== "" && !seen.has(n))
87
+ .reduce((m, n) => new Map([...m, [n, at]]), seen);
88
+ }, new Map<string, CommitAt>());
89
+
90
+ /** The committed state of files. */
91
+ export function gitHistory(
92
+ run: RunProcess,
93
+ env: Readonly<Record<string, string>>,
94
+ ): GitHistory & GitOrder {
95
+ const git = gitIn(run, env);
96
+ return {
97
+ committed: (paper, file): Committed => {
98
+ const log = git(paper, ["log", "-1", "--format=%H%x09%cI", "--", file]);
99
+ if ("why" in log) return { kind: "unknown", why: log.why };
100
+ const { commit, date } = commitAt(log.out.trim());
101
+ if (commit === "") return { kind: "not-committed" };
102
+ // `git log -1` names a deleting commit too, and a deleted file leaves a clean status.
103
+ const head = inHead(git, paper, file);
104
+ if ("why" in head) return { kind: "unknown", why: head.why };
105
+ if (!head.present) return { kind: "deleted", commit };
106
+ const status = git(paper, ["status", "--porcelain=v1", "--", file]);
107
+ if ("why" in status) return { kind: "unknown", why: status.why };
108
+ return status.out.trim() === ""
109
+ ? { kind: "committed", commit, date }
110
+ : { kind: "changed", commit };
111
+ },
112
+ order: (studyDir, decision) => orderIn(git, studyDir, decision),
113
+ };
114
+ }
115
+
116
+ type Git = ReturnType<typeof gitIn>;
117
+
118
+ /** Whether `file` is in HEAD's tree (`git ls-tree`, which takes an absolute path in the worktree). */
119
+ const inHead = (
120
+ git: Git,
121
+ cwd: AbsolutePath,
122
+ file: AbsolutePath,
123
+ ): { readonly present: boolean } | { readonly why: string } => {
124
+ const t = git(cwd, ["ls-tree", "--name-only", "HEAD", "--", file]);
125
+ return "why" in t ? t : { present: t.out.trim() !== "" };
126
+ };
127
+
128
+ /** The decision's first commit (null: never committed), and whether HEAD's tree still holds it. */
129
+ const decisionIn = (
130
+ git: Git,
131
+ studyDir: AbsolutePath,
132
+ decision: AbsolutePath,
133
+ ):
134
+ | { readonly at: CommitAt | null; readonly deleted: boolean }
135
+ | { readonly why: string } => {
136
+ const d = git(studyDir, ["log", "--format=%H%x09%cI", "--", decision]);
137
+ if ("why" in d) return d;
138
+ const lines = d.out.trim().split("\n");
139
+ const first = commitAt(nth(lines, lines.length - 1));
140
+ if (first.commit === "") return { at: null, deleted: false };
141
+ const head = inHead(git, studyDir, decision);
142
+ return "why" in head ? head : { at: first, deleted: !head.present };
143
+ };
144
+
145
+ /** The order facts of the study in `studyDir`, decided by `decision`. */
146
+ function orderIn(
147
+ git: Git,
148
+ studyDir: AbsolutePath,
149
+ decision: AbsolutePath,
150
+ ): OrderFacts {
151
+ const shallow = git(studyDir, ["rev-parse", "--is-shallow-repository"]);
152
+ if ("why" in shallow) return { kind: "unknown", why: shallow.why };
153
+ if (shallow.out.trim() === "true") return { kind: "shallow" };
154
+ const d = decisionIn(git, studyDir, decision);
155
+ if ("why" in d) return { kind: "unknown", why: d.why };
156
+ const files = git(studyDir, [
157
+ "log",
158
+ "--reverse",
159
+ "--format=%x01%H%x09%cI",
160
+ "--name-only",
161
+ "--relative",
162
+ "--",
163
+ ".",
164
+ ]);
165
+ if ("why" in files) return { kind: "unknown", why: files.why };
166
+ const ordered = ordersOf(git, studyDir, d.at, firstCommitsOf(files.out));
167
+ return "why" in ordered
168
+ ? { kind: "unknown", why: ordered.why }
169
+ : {
170
+ kind: "read",
171
+ decision: d.at,
172
+ decisionDeleted: d.deleted,
173
+ files: ordered.files,
174
+ };
175
+ }
176
+
177
+ /** git's answer for one commit: its order, or why git could not say. */
178
+ type Answer =
179
+ | { readonly commit: string; readonly order: Order }
180
+ | { readonly commit: string; readonly why: string };
181
+ const isFailed = (
182
+ a: Answer,
183
+ ): a is { readonly commit: string; readonly why: string } => "why" in a;
184
+ const isOrdered = (
185
+ a: Answer,
186
+ ): a is { readonly commit: string; readonly order: Order } => "order" in a;
187
+
188
+ /** The order of `commit` against `decision`'s first commit. */
189
+ const answerFor = (
190
+ git: Git,
191
+ cwd: AbsolutePath,
192
+ decision: CommitAt | null,
193
+ commit: string,
194
+ ): Answer => {
195
+ if (decision === null) return { commit, order: "not-after" };
196
+ if (commit === decision.commit) return { commit, order: "same" };
197
+ const a = git(
198
+ cwd,
199
+ ["merge-base", "--is-ancestor", decision.commit, commit],
200
+ [0, 1],
201
+ );
202
+ if ("why" in a) return { commit, why: a.why };
203
+ return { commit, order: a.status === 0 ? "after" : "not-after" };
204
+ };
205
+
206
+ /** Each file's first commit with its order against `decision`'s; git asked once per distinct commit. */
207
+ function ordersOf(
208
+ git: Git,
209
+ cwd: AbsolutePath,
210
+ decision: CommitAt | null,
211
+ firsts: ReadonlyMap<string, CommitAt>,
212
+ ): { readonly files: readonly FirstCommit[] } | { readonly why: string } {
213
+ const answers = [...new Set([...firsts.values()].map((c) => c.commit))].map(
214
+ (c) => answerFor(git, cwd, decision, c),
215
+ );
216
+ const failed = answers.find(isFailed);
217
+ if (failed !== undefined) return { why: failed.why };
218
+ return {
219
+ files: answers
220
+ .filter(isOrdered)
221
+ .flatMap(({ commit, order }) =>
222
+ [...firsts]
223
+ .filter(([, c]) => c.commit === commit)
224
+ .map(([path, c]) => ({ path, ...c, order })),
225
+ ),
226
+ };
227
+ }
@@ -1,7 +1,13 @@
1
1
  /** The in-memory test doubles keep the contracts of the ports they stand in for. */
2
2
  import assert from "node:assert/strict";
3
3
  import { test } from "vitest";
4
- import { fixedDownload, memoryWorkspace } from "./index.ts";
4
+ import { absolutePath } from "../../domain/paths.ts";
5
+ import {
6
+ fixedDownload,
7
+ memoryFiles,
8
+ memoryListEntries,
9
+ memoryWorkspace,
10
+ } from "./index.ts";
5
11
 
6
12
  test("memoryWorkspace: a `within` that throws is recorded as `threw`, and the error goes on", () => {
7
13
  const ws = memoryWorkspace();
@@ -23,3 +29,11 @@ test("fixedDownload: bytes are handed back as they are, and every URL is recorde
23
29
  assert.deepEqual(d.fetch("https://a", 1), { ok: true, value: bytes });
24
30
  assert.deepEqual(d.urls, ["https://a"]);
25
31
  });
32
+
33
+ test("memoryListEntries: each name directly under a directory, a file or a directory", () => {
34
+ const files = memoryFiles({ "/s/a.md": "", "/s/out/b.csv": "" });
35
+ assert.deepEqual(memoryListEntries(files)(absolutePath("/s")), [
36
+ { name: "a.md", kind: "file" },
37
+ { name: "out", kind: "dir" },
38
+ ]);
39
+ });
@@ -6,6 +6,7 @@ import { absolutePath } from "../../domain/paths.ts";
6
6
  import type { Download } from "../../ports/download.ts";
7
7
  import type { Files } from "../../ports/files.ts";
8
8
  import type { ListDir } from "../../ports/talk-media.ts";
9
+ import type { ListEntries } from "../../ports/list-entries.ts";
9
10
  import type { Command, ProcessExit, RunProcess } from "../../ports/process.ts";
10
11
  import type { NotAPromise, Scratch, Workspace } from "../../ports/workspace.ts";
11
12
  import { err, ok } from "../../domain/result.ts";
@@ -72,6 +73,18 @@ export const memoryListDir =
72
73
  ),
73
74
  ];
74
75
 
76
+ /**
77
+ * `ListEntries` over `memoryFiles`: each name directly under a directory, a file when it is one, else a
78
+ * directory. Memory holds no links: the walk's refusal to follow one is the node adapter's to show.
79
+ */
80
+ export const memoryListEntries =
81
+ (files: MemoryFiles): ListEntries =>
82
+ (dir) =>
83
+ memoryListDir(files)(dir).map((name) => ({
84
+ name,
85
+ kind: files.isFile(absolutePath(`${dir}/${name}`)) ? "file" : "dir",
86
+ }));
87
+
75
88
  export interface MemoryWorkspace extends Workspace {
76
89
  /** Every file written into a scratch directory, in order. */
77
90
  readonly written: {
@@ -35,3 +35,4 @@ export { nodeFiles } from "./files.io.ts";
35
35
  export { spawnProcess, type SpawnSync } from "./process.io.ts";
36
36
  export { hostDirs } from "./host.io.ts";
37
37
  export { nodeListDir } from "./list-dir.io.ts";
38
+ export { nodeListEntries } from "./list-entries.io.ts";
@@ -0,0 +1,26 @@
1
+ /** `ListEntries` over `node:fs`: `readdir` with its file types, which `lstat` each entry, never following a link. */
2
+ import { readdirSync, type Dirent } from "node:fs";
3
+ import type { AbsolutePath } from "../../domain/paths.ts";
4
+ import { codeOf } from "../../domain/text.ts";
5
+ import type { DirEntry, ListEntries } from "../../ports/list-entries.ts";
6
+
7
+ /** What a directory entry is, its link never followed. */
8
+ const kindOf = (d: Readonly<Dirent>): DirEntry["kind"] => {
9
+ if (d.isSymbolicLink()) return "symlink";
10
+ return d.isDirectory() ? "dir" : "file";
11
+ };
12
+
13
+ export const nodeListEntries: ListEntries = (
14
+ dir: AbsolutePath,
15
+ ): readonly DirEntry[] => {
16
+ try {
17
+ return readdirSync(dir, { withFileTypes: true }).map((d) => ({
18
+ name: d.name,
19
+ kind: kindOf(d),
20
+ }));
21
+ } catch (e) {
22
+ const code = codeOf(e);
23
+ if (code === "ENOENT" || code === "ENOTDIR") return [];
24
+ throw e;
25
+ }
26
+ };
@@ -0,0 +1,43 @@
1
+ /**
2
+ * `nodeListEntries`: a directory's entries with their kinds, a symbolic link reported as one and never
3
+ * followed; none for a missing directory or a file; other errors thrown.
4
+ */
5
+ import { mkdirSync, symlinkSync, writeFileSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { describe, expect, it } from "vitest";
8
+ import { absolutePath } from "../../domain/paths.ts";
9
+ import { useTempDir } from "../../../test/support.ts";
10
+ import { nodeListEntries } from "./list-entries.io.ts";
11
+
12
+ const root = useTempDir("paperlint-list-entries-");
13
+
14
+ const sorted = (dir: string) =>
15
+ [...nodeListEntries(absolutePath(dir))].sort((a, b) =>
16
+ a.name.localeCompare(b.name),
17
+ );
18
+
19
+ describe("nodeListEntries", () => {
20
+ it("names each entry's kind, a link to a directory as a link", () => {
21
+ const d = join(root, "study");
22
+ mkdirSync(join(d, "out"), { recursive: true });
23
+ writeFileSync(join(d, "a.md"), "");
24
+ symlinkSync(".", join(d, "loop"));
25
+ expect(sorted(d)).toEqual([
26
+ { name: "a.md", kind: "file" },
27
+ { name: "loop", kind: "symlink" },
28
+ { name: "out", kind: "dir" },
29
+ ]);
30
+ });
31
+
32
+ it("is empty for a directory that does not exist, and for a file", () => {
33
+ writeFileSync(join(root, "f"), "");
34
+ expect([
35
+ nodeListEntries(absolutePath(join(root, "none"))),
36
+ nodeListEntries(absolutePath(join(root, "f"))),
37
+ ]).toEqual([[], []]);
38
+ });
39
+
40
+ it("throws any other error", () => {
41
+ expect(() => nodeListEntries(absolutePath(`${root}/a\0b`))).toThrow();
42
+ });
43
+ });
@@ -240,7 +240,7 @@ const planWith = (over: Readonly<Record<string, unknown>>): string =>
240
240
 
241
241
  describe("parsing a study's plan: ready or draft", () => {
242
242
  it("a filled plan is ready, its `extra` kept as it is", () => {
243
- expect(parsePlan(JSON.stringify(PLAN))).toEqual({
243
+ expect(parsePlan(JSON.stringify(PLAN), "spike")).toEqual({
244
244
  kind: "ready",
245
245
  plan: PLAN,
246
246
  });
@@ -255,17 +255,20 @@ describe("parsing a study's plan: ready or draft", () => {
255
255
  },
256
256
  arms: [],
257
257
  };
258
- expect(parsePlan(JSON.stringify(census))).toEqual({
258
+ expect(parsePlan(JSON.stringify(census), "spike")).toEqual({
259
259
  kind: "ready",
260
260
  plan: census,
261
261
  });
262
262
  });
263
263
 
264
264
  it("the template studies new writes is a draft of a census, naming every empty text", () => {
265
- expect(planTemplate().population.draw).toEqual({ kind: "all", method: "" });
266
- expect(parsePlan(planText(planTemplate()))).toEqual({
265
+ expect(planTemplate("spike", "x").population.draw).toEqual({
266
+ kind: "all",
267
+ method: "",
268
+ });
269
+ expect(parsePlan(planText(planTemplate("spike", "x")), "spike")).toEqual({
267
270
  kind: "draft",
268
- plan: planTemplate(),
271
+ plan: planTemplate("spike", "x"),
269
272
  empty: [
270
273
  "question",
271
274
  "population.source",
@@ -277,7 +280,7 @@ describe("parsing a study's plan: ready or draft", () => {
277
280
 
278
281
  it("a text of white space only is empty", () => {
279
282
  const plan = { ...PLAN, primary_outcome: " ", author_check: null };
280
- expect(parsePlan(JSON.stringify(plan))).toEqual({
283
+ expect(parsePlan(JSON.stringify(plan), "spike")).toEqual({
281
284
  kind: "draft",
282
285
  plan,
283
286
  empty: ["primary_outcome"],
@@ -330,7 +333,7 @@ describe("parsing a study's plan: no contradictory state", () => {
330
333
  "population.draw.kind: Invalid discriminator value. Expected 'seeded' | 'all'",
331
334
  ],
332
335
  ])("refuses %s", (_why, t, why) => {
333
- expect(parsePlan(t)).toEqual({ kind: "unreadable", why });
336
+ expect(parsePlan(t, "spike")).toEqual({ kind: "unreadable", why });
334
337
  });
335
338
  });
336
339
 
@@ -352,7 +355,7 @@ describe("parsing a study's plan: no name or fact said twice", () => {
352
355
  'population: Unrecognized key: "size"',
353
356
  ],
354
357
  ])("refuses %s", (_why, t, why) => {
355
- expect(parsePlan(t)).toEqual({ kind: "unreadable", why });
358
+ expect(parsePlan(t, "spike")).toEqual({ kind: "unreadable", why });
356
359
  });
357
360
  });
358
361
 
@@ -395,7 +398,7 @@ describe("parsing a study's plan: strict at every level", () => {
395
398
  'arms.0: Unrecognized key: "model"',
396
399
  ],
397
400
  ])("refuses %s", (_why, t, why) => {
398
- expect(parsePlan(t)).toEqual({ kind: "unreadable", why });
401
+ expect(parsePlan(t, "spike")).toEqual({ kind: "unreadable", why });
399
402
  });
400
403
  });
401
404
 
@@ -12,8 +12,13 @@
12
12
  * Its texts may be empty — the template's are — and the parser says which: a plan comes out ready or
13
13
  * a draft naming its empty texts, so nothing downstream looks at a text again. A plan still named
14
14
  * `prereg.json` has a loose schema of its own.
15
+ *
16
+ * A plan's `mode` (docs/design/study-modes.md) is parsed here too, strictly, a union on its `kind`:
17
+ * one mode at most, a glob that leaves the study's folder or a decision outside the paper refused,
18
+ * and a measurement's plan ready only with a mode.
15
19
  */
16
20
  import { z } from "zod";
21
+ import { README } from "../../domain/repro-layout.ts";
17
22
  import {
18
23
  IN_PAPER,
19
24
  MEASUREMENT_ROLES,
@@ -29,9 +34,16 @@ import {
29
34
  type ParsedRegistry,
30
35
  type Requirement,
31
36
  type Study,
37
+ type PlanTextField,
38
+ type StudyKind,
32
39
  type StudyPlan,
33
40
  type StudyRegistry,
34
41
  } from "../../domain/study-registry.ts";
42
+ import {
43
+ needsMode,
44
+ type Decision,
45
+ type StudyMode,
46
+ } from "../../domain/study-mode.ts";
35
47
  import type { StudyRegistryCodec } from "../../ports/study-registry-codec.ts";
36
48
 
37
49
  const study = z.looseObject({
@@ -128,6 +140,66 @@ const extra = z.record(z.string(), z.unknown()).superRefine((e, ctx) => {
128
140
  });
129
141
  });
130
142
 
143
+ /** Every value of `values` once: a repeat is an error naming it. */
144
+ const once =
145
+ (what: string) =>
146
+ (values: readonly string[], ctx: Readonly<z.core.$RefinementCtx>): void => {
147
+ repeated(values).forEach((v) => {
148
+ ctx.addIssue({
149
+ code: "custom",
150
+ message: `${what} «${v}» is listed more than once`,
151
+ });
152
+ });
153
+ };
154
+
155
+ /** Whether `p` is a relative path that stays where it starts: no backslash, not absolute, no `..`. */
156
+ const staysInside = (p: string): boolean =>
157
+ !p.includes("\\") && !p.startsWith("/") && !p.split("/").includes("..");
158
+
159
+ /** An output glob, relative to the study's folder: inside it, and never the study's README. */
160
+ const inFolder = z
161
+ .string()
162
+ .min(1)
163
+ .refine(
164
+ (p) => staysInside(p) && p !== README,
165
+ `is not a path inside the study's folder, or names its ${README}`,
166
+ );
167
+
168
+ /**
169
+ * Whether `p` names a file by one or more names joined by `/`, none empty, `.` or `..`: never the
170
+ * paper's folder itself (`.`) nor a path ending in `/`. Whether a name is a directory on disk is the
171
+ * gate's and the order rule's to see.
172
+ */
173
+ const namesAFile = (p: string): boolean =>
174
+ p.split("/").every((n) => n !== "" && n !== "." && n !== "..");
175
+
176
+ /**
177
+ * A decision file, relative to the PAPER's folder: inside it, and a file's path. Empty in a draft (the
178
+ * parser names it).
179
+ */
180
+ const inPaper = z
181
+ .string()
182
+ .refine(staysInside, "is not a path inside the paper's folder")
183
+ .refine(
184
+ (p) => p === "" || !staysInside(p) || namesAFile(p),
185
+ "is not a path to a file inside the paper's folder: one or more names joined by /, none empty, . or ..",
186
+ );
187
+
188
+ const decisionShape = z.strictObject({ file: inPaper, note: z.string() });
189
+
190
+ /** The output globs: each once; none yet in a draft. */
191
+ const outputs = z.array(inFolder).superRefine(once("the output"));
192
+
193
+ /** How the outcomes are kept from the decisions: exactly one mode, by its `kind`. */
194
+ const mode = z.discriminatedUnion("kind", [
195
+ z.strictObject({
196
+ kind: z.literal("decide_before_run"),
197
+ outputs,
198
+ decision: decisionShape,
199
+ }),
200
+ z.strictObject({ kind: z.literal("exploratory"), reason: z.string() }),
201
+ ]);
202
+
131
203
  /** A plan's shape: closed at every level; the paper's own keys go in `extra`. Null is the one "none". */
132
204
  const planShape = z.strictObject({
133
205
  question: z.string(),
@@ -136,6 +208,7 @@ const planShape = z.strictObject({
136
208
  primary_outcome: z.string(),
137
209
  author_check: authorCheck.nullable(),
138
210
  extra: extra.optional(),
211
+ mode: mode.optional(),
139
212
  });
140
213
 
141
214
  /**
@@ -196,6 +269,28 @@ const readyTexts = z.object({
196
269
  primary_outcome: filled,
197
270
  });
198
271
 
272
+ /** Whether `t` says something. */
273
+ const says = (t: string): boolean => /\S/u.test(t);
274
+
275
+ /**
276
+ * The mode's texts that are still empty, by their field: the mode itself when a study of `kind` must
277
+ * declare one and the plan does not, a decide-first mode's outputs and decision, an exploratory reason.
278
+ */
279
+ const modeEmpties = (
280
+ p: StudyPlan,
281
+ kind: StudyKind,
282
+ ): readonly PlanTextField[] => {
283
+ const m = p.mode;
284
+ if (m === undefined) return needsMode(kind) ? ["mode"] : [];
285
+ if (m.kind === "exploratory") return says(m.reason) ? [] : ["mode.reason"];
286
+ return PLAN_TEXT_FIELDS.filter(
287
+ (f) =>
288
+ (f === "mode.outputs" && m.outputs.length === 0) ||
289
+ (f === "mode.decision.file" && !says(m.decision.file)) ||
290
+ (f === "mode.decision.note" && !says(m.decision.note)),
291
+ );
292
+ };
293
+
199
294
  /** A plan that parsed: ready, or a draft. */
200
295
  type ReadyOrDraft = Exclude<ParsedPlan, { readonly kind: "unreadable" }>;
201
296
 
@@ -203,29 +298,37 @@ type ReadyOrDraft = Exclude<ParsedPlan, { readonly kind: "unreadable" }>;
203
298
  * A parsed plan, ready when every required text is filled, else a draft naming the empty ones. A
204
299
  * draft that carries the author's check is no plan: the author approved one with empty texts.
205
300
  */
206
- const readyOrDraft = (
207
- p: StudyPlan,
208
- ctx: Readonly<z.core.$RefinementCtx>,
209
- ): ReadyOrDraft => {
210
- const checked = readyTexts.safeParse(p);
211
- if (checked.success) return { kind: "ready", plan: p };
212
- const paths = checked.error.issues.map((i) => i.path.join("."));
213
- const empty = PLAN_TEXT_FIELDS.filter((f) => paths.includes(f));
214
- if (p.author_check === null)
215
- return { kind: "draft", plan: { ...p, author_check: null }, empty };
216
- ctx.addIssue({
217
- code: "custom",
218
- path: ["author_check"],
219
- message: `is set, and the plan leaves ${empty.map((f) => `\`${f}\``).join(", ")} empty — the author approves a complete plan: fill them, or set author_check to null`,
220
- });
221
- return z.NEVER;
222
- };
301
+ const readyOrDraft =
302
+ (kind: StudyKind) =>
303
+ (p: StudyPlan, ctx: Readonly<z.core.$RefinementCtx>): ReadyOrDraft => {
304
+ const checked = readyTexts.safeParse(p);
305
+ const paths = checked.success
306
+ ? []
307
+ : checked.error.issues.map((i) => i.path.join("."));
308
+ const empty = [
309
+ ...PLAN_TEXT_FIELDS.filter((f) => paths.includes(f)),
310
+ ...modeEmpties(p, kind),
311
+ ];
312
+ if (empty.length === 0) return { kind: "ready", plan: p };
313
+ if (p.author_check === null)
314
+ return { kind: "draft", plan: { ...p, author_check: null }, empty };
315
+ ctx.addIssue({
316
+ code: "custom",
317
+ path: ["author_check"],
318
+ message: `is set, and the plan leaves ${empty.map((f) => `\`${f}\``).join(", ")} empty — the author approves a complete plan: fill them, or set author_check to null`,
319
+ });
320
+ return z.NEVER;
321
+ };
223
322
 
224
- /** A `plan.json`: strict, then ready or draft. */
225
- const plan = planShape.transform(readyOrDraft);
323
+ /** A `plan.json` of a study of `kind`: strict, then ready or draft — a measurement's ready only with its mode. */
324
+ const planFor = (kind: StudyKind) => planShape.transform(readyOrDraft(kind));
226
325
 
227
- /** A `prereg.json`: loose, mapped onto the plan, then ready or draft. */
228
- const legacyPlan = legacyAsPlan.transform(readyOrDraft);
326
+ /**
327
+ * A `prereg.json`: loose, mapped onto the plan, then ready or draft. Written before modes existed, it
328
+ * is never asked for one; `repro/study-plan-name` already asks to move it to `plan.json`, where the
329
+ * strict parser does.
330
+ */
331
+ const legacyPlan = legacyAsPlan.transform(readyOrDraft("spike"));
229
332
 
230
333
  // ── the schemas ARE the domain's types ─────────────────────────────────────────
231
334
  // Each schema's output and its hand-written domain type are the same type, both ways, once both are
@@ -261,8 +364,10 @@ export type SchemasAreTheDomain = [
261
364
  Same<z.output<typeof authorCheck>, AuthorCheck>,
262
365
  Same<z.output<typeof planShape>, StudyPlan>,
263
366
  Same<z.output<typeof legacyAsPlan>, StudyPlan>,
264
- Same<z.output<typeof plan>, ReadyOrDraft>,
367
+ Same<z.output<ReturnType<typeof planFor>>, ReadyOrDraft>,
265
368
  Same<z.output<typeof legacyPlan>, ReadyOrDraft>,
369
+ Same<z.output<typeof mode>, StudyMode>,
370
+ Same<z.output<typeof decisionShape>, Decision>,
266
371
  ] extends true[]
267
372
  ? true
268
373
  : never;
@@ -315,8 +420,9 @@ const planParser =
315
420
  : { kind: "unreadable", why: reasons(parsed.error.issues) };
316
421
  };
317
422
 
318
- /** A `plan.json`: strict. */
319
- export const parseStudyPlan = planParser(plan);
423
+ /** A `plan.json` of a study of `kind`: strict. */
424
+ export const parseStudyPlan = (text: string, kind: StudyKind): ParsedPlan =>
425
+ planParser(planFor(kind))(text);
320
426
 
321
427
  /** A plan still named `prereg.json`: loose, read only when a row names it. */
322
428
  export const parseLegacyPlan = planParser(legacyPlan);