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,31 @@
1
+ /**
2
+ * What `paperlint studies index` and `studies new` generate the index from beyond the registry: each
3
+ * row's plan, and each decide-first study's history as git gives it now (src/domain/index-columns.ts).
4
+ */
5
+ import { indexHistory } from "./domain/index-columns.ts";
6
+ import {
7
+ folderKey,
8
+ type IndexInput,
9
+ type RegistryState,
10
+ } from "./domain/study-registry.ts";
11
+ import {
12
+ decideFirstStudies,
13
+ type OrderDeps,
14
+ } from "./outputs-before-decision-rule.ts";
15
+ import { studyFolder } from "./study-folder.ts";
16
+ import { plansOf } from "./study-registry-rule.ts";
17
+
18
+ /** The plans and the histories of the paper folder `paper`, whose registry is `r`. */
19
+ export const indexInputOf = (
20
+ paper: string,
21
+ r: RegistryState,
22
+ deps: OrderDeps,
23
+ ): IndexInput => ({
24
+ plans: plansOf(r, studyFolder(paper, deps), deps.codec),
25
+ history: new Map(
26
+ decideFirstStudies(paper, deps).map((s) => [
27
+ folderKey(s.folder, s.plan),
28
+ indexHistory(s),
29
+ ]),
30
+ ),
31
+ });
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Reading a paper's studies with their modes: each registered study whose `plan.json` parses with a
3
+ * `mode`, and how a decision's path stands. What `repro/outputs-before-decision`, `studies gate`
4
+ * and the index's mode columns read with (docs/design/study-modes.md). A row whose folder is not one
5
+ * folder inside `repro/` is never read through.
6
+ */
7
+ import { posix } from "node:path";
8
+ import { filter, map, pipe } from "remeda";
9
+ import type { NotAFile } from "./domain/decide-first.ts";
10
+ import type { StudyMode } from "./domain/study-mode.ts";
11
+ import {
12
+ studiesOf,
13
+ type PlanTextField,
14
+ type RegistryState,
15
+ type Study,
16
+ } from "./domain/study-registry.ts";
17
+ import type { StudyRegistryCodec } from "./ports/study-registry-codec.ts";
18
+ import type { StudyFolder } from "./ports/study-folder.ts";
19
+
20
+ /** Whether a decision path is one git can vouch for: a file or nothing yet; else why not. */
21
+ export type DecisionEntry =
22
+ | { readonly kind: "ok" }
23
+ | { readonly kind: "not-a-file"; readonly what: NotAFile }
24
+ | { readonly kind: "linked"; readonly link: string };
25
+
26
+ /**
27
+ * What the decision file `file` (relative to the paper) is, read through `folder` without following
28
+ * a link: a decision must be a regular file in real directories — git answers for a directory with
29
+ * the commits of anything under it, for a link with the link and not the file it points to, and for a
30
+ * path through a linked directory with what lies behind the link.
31
+ */
32
+ export const decisionEntry = (
33
+ folder: StudyFolder,
34
+ file: string,
35
+ ): DecisionEntry => {
36
+ const at = folder.entryAt(file);
37
+ if (at.kind === "dir") return { kind: "not-a-file", what: "directory" };
38
+ if (at.kind === "other") return { kind: "not-a-file", what: "special file" };
39
+ if (at.kind !== "linked") return { kind: "ok" };
40
+ return at.link === posix.normalize(file)
41
+ ? { kind: "not-a-file", what: "symbolic link" }
42
+ : at;
43
+ };
44
+
45
+ /**
46
+ * A study whose plan parsed with a mode: its row, the plan's path in its folder, the mode, and the
47
+ * plan's empty texts — none when it is ready. Only a ready plan's mode decides anything: a draft may
48
+ * name no outputs, or an empty decision file.
49
+ */
50
+ export interface ModeStudy {
51
+ readonly row: Study;
52
+ readonly plan: string;
53
+ readonly mode: StudyMode;
54
+ readonly empty: readonly PlanTextField[];
55
+ }
56
+
57
+ /** What reading the plan of one row gave: its mode study, nothing, or a link on the way to it. */
58
+ export type ModeRead =
59
+ | { readonly kind: "mode"; readonly study: ModeStudy }
60
+ | { readonly kind: "none" }
61
+ | { readonly kind: "linked"; readonly link: string };
62
+
63
+ /**
64
+ * The plan of the row `s`, read through `folder` (`src/study-folder.ts`, which follows no link and
65
+ * enters no folder that is not one inside `repro/`): its mode study when the plan parsed with a
66
+ * mode; a link on the way to it; else nothing.
67
+ */
68
+ export const readModeStudy = (
69
+ s: Study,
70
+ folder: StudyFolder,
71
+ codec: StudyRegistryCodec,
72
+ ): ModeRead => {
73
+ const plan = s.plan;
74
+ if (plan === undefined) return { kind: "none" };
75
+ const read = folder.read(s.folder, plan);
76
+ if (read.kind === "linked") return read;
77
+ if (read.kind === "absent") return { kind: "none" };
78
+ const parsed = codec.parsePlan(read.text, s.kind);
79
+ if (parsed.kind === "unreadable" || parsed.plan.mode === undefined)
80
+ return { kind: "none" };
81
+ const empty = parsed.kind === "draft" ? parsed.empty : [];
82
+ return {
83
+ kind: "mode",
84
+ study: { row: s, plan, mode: parsed.plan.mode, empty },
85
+ };
86
+ };
87
+
88
+ /**
89
+ * Every study of the registry `r` whose plan declares a mode, in registry order. A plan that does
90
+ * not parse, or is reached through a link, is `repro/study-registered`'s to report; here it declares
91
+ * nothing.
92
+ */
93
+ export const modeStudies = (
94
+ r: RegistryState,
95
+ folder: StudyFolder,
96
+ codec: StudyRegistryCodec,
97
+ ): readonly ModeStudy[] =>
98
+ pipe(
99
+ studiesOf(r),
100
+ map((s) => readModeStudy(s, folder, codec)),
101
+ filter(isMode),
102
+ map((m) => m.study),
103
+ );
104
+
105
+ /** A plan read that declared a mode. */
106
+ const isMode = (m: ModeRead): m is Extract<ModeRead, { kind: "mode" }> =>
107
+ m.kind === "mode";
@@ -6,7 +6,11 @@
6
6
  * on the first line of `PIPELINE-STATUS.md`, with the domain's text.
7
7
  */
8
8
  import { describe, expect, it } from "vitest";
9
- import { memoryFiles, memoryListDir } from "./adapters/memory/index.ts";
9
+ import {
10
+ memoryFiles,
11
+ memoryListDir,
12
+ memoryListEntries,
13
+ } from "./adapters/memory/index.ts";
10
14
  import { studyRegistryCodec } from "./adapters/study-registry/index.ts";
11
15
  import {
12
16
  planTemplate,
@@ -15,8 +19,11 @@ import {
15
19
  studyMessage,
16
20
  withIndex,
17
21
  type Study,
22
+ type StudyPlan,
18
23
  type StudyRegistry,
24
+ type IndexInput,
19
25
  } from "./domain/study-registry.ts";
26
+ import { planOf } from "../test/study-tree.ts";
20
27
  import {
21
28
  registryOf,
22
29
  STUDY_REGISTRY_RULE_LEVELS,
@@ -24,6 +31,9 @@ import {
24
31
  studyRegistryRules,
25
32
  } from "./study-registry-rule.ts";
26
33
 
34
+ /** No plan and no history: the index with every mode column `—`. */
35
+ const NO_FACTS: IndexInput = { plans: new Map(), history: new Map() };
36
+
27
37
  const DIR = "/work/papers/example";
28
38
  const SCAFFOLD = {
29
39
  [`${DIR}/PIPELINE-STATUS.md`]: "# status\n",
@@ -45,7 +55,7 @@ const study = (over: Partial<Study> = {}): Study => ({
45
55
  /** A registered `repro/`: the registry, its index, and a README.md in each row's folder. */
46
56
  const registered = (
47
57
  r: StudyRegistry,
48
- readme: string | null = withIndex("# Reproduction index\n", r),
58
+ readme: string | null = withIndex("# Reproduction index\n", r, NO_FACTS),
49
59
  ): Record<string, string> => ({
50
60
  ...SCAFFOLD,
51
61
  [`${DIR}/repro/studies.json`]: registryText(r),
@@ -73,6 +83,7 @@ function lint(
73
83
  const rule = studyRegistryRules({
74
84
  files,
75
85
  listDir: memoryListDir(files),
86
+ entries: memoryListEntries(files),
76
87
  codec: studyRegistryCodec,
77
88
  })[name];
78
89
  const out: Reported[] = [];
@@ -222,9 +233,9 @@ describe("repro/study-registered — reports a row's status and the index", () =
222
233
 
223
234
  it("a stale index", () => {
224
235
  const r = P1(study());
225
- expect(messages(registered(r, withIndex("# x\n", P1())))).toEqual([
226
- studyMessage({ problem: "stale-index", why: "differs" }),
227
- ]);
236
+ expect(messages(registered(r, withIndex("# x\n", P1(), NO_FACTS)))).toEqual(
237
+ [studyMessage({ problem: "stale-index", why: "differs" })],
238
+ );
228
239
  });
229
240
  });
230
241
 
@@ -300,7 +311,7 @@ describe("repro/study-registered — a result_file the disk cannot hold", () =>
300
311
  const files = memoryFiles({
301
312
  ...SCAFFOLD,
302
313
  [`${DIR}/repro/studies.json`]: registryText(r),
303
- [`${DIR}/repro/README.md`]: withIndex("# index\n", r),
314
+ [`${DIR}/repro/README.md`]: withIndex("# index\n", r, NO_FACTS),
304
315
  [`${DIR}/repro/P1-S01-baseline/README.md`]: "# s\n",
305
316
  });
306
317
  const probe = {
@@ -314,14 +325,187 @@ describe("repro/study-registered — a result_file the disk cannot hold", () =>
314
325
  studyFindings(DIR, {
315
326
  files: probe,
316
327
  listDir: memoryListDir(files),
328
+ entries: memoryListEntries(files),
317
329
  codec: studyRegistryCodec,
318
330
  }).map((f) => f.problem),
319
331
  ).toEqual(["result-file"]);
320
332
  });
321
333
  });
322
334
 
335
+ describe("repro/study-registered — a study folder that is a symbolic link", () => {
336
+ it("never reads a plan or a result through it", () => {
337
+ const r = P1(
338
+ study({
339
+ status: "done",
340
+ result: "41 of 60",
341
+ result_file: "r.txt",
342
+ plan: "plan.json",
343
+ }),
344
+ );
345
+ const files = memoryFiles({
346
+ ...registered(r),
347
+ [`${DIR}/repro/P1-S01-baseline/plan.json`]: "not JSON",
348
+ [`${DIR}/repro/P1-S01-baseline/r.txt`]: "41 of 60\n",
349
+ });
350
+ const found = studyFindings(DIR, {
351
+ files,
352
+ listDir: memoryListDir(files),
353
+ entries: (dir) =>
354
+ memoryListEntries(files)(dir).map((e) =>
355
+ e.name === "P1-S01-baseline" ? { ...e, kind: "symlink" as const } : e,
356
+ ),
357
+ codec: studyRegistryCodec,
358
+ });
359
+ // Guards: the files behind the link are not read — the plan and the result read as missing.
360
+ expect(
361
+ found.map((f) =>
362
+ f.problem === "bad-plan" || f.problem === "result-file"
363
+ ? `${f.problem}:${f.why}`
364
+ : f.problem,
365
+ ),
366
+ ).toEqual(["result-file:missing", "bad-plan:missing"]);
367
+ });
368
+ });
369
+
370
+ describe("repro/study-registered — a plan or a result file reached through a link inside a study folder", () => {
371
+ const r = P1(
372
+ study({
373
+ kind: "spike",
374
+ status: "done",
375
+ result: "41 of 60",
376
+ result_file: "out/r.txt",
377
+ plan: "sub/plan.json",
378
+ }),
379
+ );
380
+ const S = `${DIR}/repro/P1-S01-baseline`;
381
+ const found = (links: readonly string[]) => {
382
+ const files = memoryFiles({
383
+ ...registered(r),
384
+ [`${S}/sub/plan.json`]: planText(planOf(undefined)),
385
+ [`${S}/out/r.txt`]: "41 of 60\n",
386
+ });
387
+ return studyFindings(DIR, {
388
+ files,
389
+ listDir: memoryListDir(files),
390
+ entries: (dir) =>
391
+ memoryListEntries(files)(dir).map((e) =>
392
+ links.includes(`${dir}/${e.name}`)
393
+ ? { ...e, kind: "symlink" as const }
394
+ : e,
395
+ ),
396
+ codec: studyRegistryCodec,
397
+ });
398
+ };
399
+
400
+ it("silent while no link is on the way; each link is reported with its name, and the file behind it is not read", () => {
401
+ const linked = found([`${S}/sub`, `${S}/out`]).map((f) =>
402
+ f.problem === "bad-plan" || f.problem === "result-file"
403
+ ? studyMessage(f).split("\n")[0]
404
+ : f.problem,
405
+ );
406
+ // Guards: both files exist and parse, so only the link can make a finding. The text names the
407
+ // file, the link and the fix.
408
+ expect([found([]).map((f) => f.problem), linked]).toEqual([
409
+ [],
410
+ [
411
+ "`repro/studies.json`: the `result_file` of `P1-S01`, `out/r.txt`, passes through the symbolic link `out` in its folder; nothing is read through a link. What to do: name a regular file inside the study's folder, or replace the link with the file itself.",
412
+ "`repro/studies.json`: the `plan` of `P1-S01`, `sub/plan.json`, passes through the symbolic link `sub` in its folder; nothing is read through a link. What to do: replace the link with the plan file itself.",
413
+ ],
414
+ ]);
415
+ });
416
+ });
417
+
418
+ describe("repro/study-registered — a result file or a plan that is a FIFO", () => {
419
+ it("is not a file in its folder: reported as missing, never opened", () => {
420
+ const r = P1(
421
+ study({
422
+ status: "done",
423
+ result: "41 of 60",
424
+ result_file: "r.txt",
425
+ plan: "plan.json",
426
+ }),
427
+ );
428
+ const S = `${DIR}/repro/P1-S01-baseline`;
429
+ const files = memoryFiles({
430
+ ...registered(r),
431
+ [`${S}/plan.json`]: "{}",
432
+ [`${S}/r.txt`]: "41 of 60\n",
433
+ });
434
+ const found = studyFindings(DIR, {
435
+ files,
436
+ listDir: memoryListDir(files),
437
+ entries: (dir) =>
438
+ memoryListEntries(files)(dir).map((e) =>
439
+ [`${S}/plan.json`, `${S}/r.txt`].includes(`${dir}/${e.name}`)
440
+ ? { ...e, kind: "other" as const }
441
+ : e,
442
+ ),
443
+ codec: studyRegistryCodec,
444
+ });
445
+ // Guards: `existingResults` used `Files.isFile`; the listing said "file" for a FIFO and the result passed.
446
+ expect(
447
+ found.map((f) =>
448
+ f.problem === "bad-plan" || f.problem === "result-file"
449
+ ? `${f.problem}:${f.why}`
450
+ : f.problem,
451
+ ),
452
+ ).toEqual(["result-file:missing", "bad-plan:missing"]);
453
+ });
454
+ });
455
+
456
+ describe("repro/study-registered — a row whose folder leads outside repro/", () => {
457
+ it("is reported by name, and nothing is read through it: its plan and result read as missing", () => {
458
+ const r = P1(
459
+ study({
460
+ folder: "../outside",
461
+ status: "done",
462
+ result: "41 of 60",
463
+ result_file: "r.txt",
464
+ plan: "plan.json",
465
+ }),
466
+ );
467
+ const files = memoryFiles({
468
+ ...SCAFFOLD,
469
+ [`${DIR}/repro/studies.json`]: registryText(r),
470
+ [`${DIR}/repro/README.md`]: withIndex("# index\n", r, NO_FACTS),
471
+ [`${DIR}/outside/plan.json`]: "not JSON",
472
+ [`${DIR}/outside/r.txt`]: "41 of 60\n",
473
+ });
474
+ const read: string[] = [];
475
+ const spy = {
476
+ ...files,
477
+ readBytes: (p: Parameters<typeof files.readBytes>[0]) => {
478
+ read.push(p);
479
+ return files.readBytes(p);
480
+ },
481
+ isFile: (p: Parameters<typeof files.isFile>[0]): boolean => {
482
+ read.push(p);
483
+ return files.isFile(p);
484
+ },
485
+ };
486
+ const found = studyFindings(DIR, {
487
+ files: spy,
488
+ listDir: memoryListDir(files),
489
+ entries: memoryListEntries(files),
490
+ codec: studyRegistryCodec,
491
+ });
492
+ // Guards: a row naming `../outside` never has a file outside `repro/` read for it.
493
+ expect([
494
+ found.map((f) =>
495
+ f.problem === "bad-plan" || f.problem === "result-file"
496
+ ? `${f.problem}:${f.why}`
497
+ : f.problem,
498
+ ),
499
+ read.filter((p) => p.includes("/outside/")),
500
+ ]).toEqual([
501
+ ["folder-name", "no-folder", "result-file:missing", "bad-plan:missing"],
502
+ [],
503
+ ]);
504
+ });
505
+ });
506
+
323
507
  /** A plan as a study would hold it once the author has checked its drawn units. */
324
- const FILLED = planText({
508
+ const FILLED_PLAN: StudyPlan = {
325
509
  question: "How many rules can a linter check?",
326
510
  population: {
327
511
  source: "the crawl of 2026-10-01",
@@ -330,7 +514,28 @@ const FILLED = planText({
330
514
  arms: [],
331
515
  primary_outcome: "the share of rules a linter can check",
332
516
  author_check: { by: "A. Author", date: "2026-10-14", quote: "Go." },
333
- });
517
+ mode: {
518
+ kind: "decide_before_run",
519
+ outputs: ["results.tsv"],
520
+ decision: {
521
+ file: "repro/P1-S01-baseline/decision.md",
522
+ note: "the sample approved",
523
+ },
524
+ },
525
+ };
526
+ const FILLED = planText(FILLED_PLAN);
527
+
528
+ /** The index of `r` as `studies index` writes it, FILLED_PLAN as its study's plan and `history` read. */
529
+ const FILLED_INDEX = (
530
+ r: StudyRegistry,
531
+ history: IndexInput["history"] = new Map(),
532
+ ): string =>
533
+ withIndex("# Reproduction index\n", r, {
534
+ plans: new Map([
535
+ ["P1-S01-baseline/plan.json", { kind: "ready", plan: FILLED_PLAN }],
536
+ ]),
537
+ history,
538
+ });
334
539
 
335
540
  /** A registry with a headline measurement of one running study whose row names `plan`. */
336
541
  const HEADLINE = (plan: string): StudyRegistry => ({
@@ -348,10 +553,11 @@ const HEADLINE = (plan: string): StudyRegistry => ({
348
553
  });
349
554
 
350
555
  describe("repro/study-registered — the plan a row names, read from its folder", () => {
351
- it("is silent on a plan.json the author checked", () => {
556
+ it("is silent on a plan.json the author checked, its mode in the index", () => {
557
+ const r = HEADLINE("plan.json");
352
558
  expect(
353
559
  lint({
354
- ...registered(HEADLINE("plan.json")),
560
+ ...registered(r, FILLED_INDEX(r)),
355
561
  [`${DIR}/repro/P1-S01-baseline/plan.json`]: FILLED,
356
562
  }),
357
563
  ).toEqual([]);
@@ -389,7 +595,9 @@ describe("repro/study-registered — the plan a row names, read from its folder"
389
595
  expect(
390
596
  lint({
391
597
  ...registered(HEADLINE("plan.json")),
392
- [`${DIR}/repro/P1-S01-baseline/plan.json`]: planText(planTemplate()),
598
+ [`${DIR}/repro/P1-S01-baseline/plan.json`]: planText(
599
+ planTemplate("spike", "x"),
600
+ ),
393
601
  }).map((r) => [r.messageId, r.line]),
394
602
  ).toEqual([
395
603
  ["bad-plan", 1],
@@ -413,6 +621,7 @@ describe("repro/study-registered — a plan the disk cannot hold", () => {
413
621
  studyFindings(DIR, {
414
622
  files: probe,
415
623
  listDir: memoryListDir(files),
624
+ entries: memoryListEntries(files),
416
625
  codec: studyRegistryCodec,
417
626
  }).map((f) => f.problem),
418
627
  ).toEqual(["bad-plan"]);
@@ -454,3 +663,68 @@ describe("repro/study-plan-name — a plan under its old name", () => {
454
663
  ]).toEqual([[], []]);
455
664
  });
456
665
  });
666
+
667
+ /** A plan whose texts are filled and which declares no mode; checked by the author when `check`. */
668
+ const noMode = (check: boolean): string =>
669
+ planText({
670
+ question: "q",
671
+ population: { source: "s", draw: { kind: "all", method: "census" } },
672
+ arms: [],
673
+ primary_outcome: "o",
674
+ author_check: check
675
+ ? { by: "A. Author", date: "2026-10-14", quote: "Go." }
676
+ : null,
677
+ });
678
+
679
+ /** The first line of each finding over the headline study with `plan` as its plan.json. */
680
+ const firstLines = (plan: string): readonly string[] =>
681
+ lint({
682
+ ...registered(HEADLINE("plan.json")),
683
+ [`${DIR}/repro/P1-S01-baseline/plan.json`]: plan,
684
+ }).map((r) => r.message.split("\n")[0] ?? "");
685
+
686
+ it("repro/study-registered — a measurement's plan that has run without a mode is reported; approved without one, it is no plan", () => {
687
+ // Guards: decide-first is the default — a measurement that ran with no mode is reported, never silent.
688
+ expect([firstLines(noMode(false)), firstLines(noMode(true))]).toEqual([
689
+ [
690
+ "The plan of `P1-S01`, `plan.json` in its folder, leaves `mode` empty, and the study is `running`: a plan is written before the runs. What to do: fill them in, or set the study back to `designing`.",
691
+ "`P1-S01` is `running` in the headline measurement `M1`, and its plan has no `author_check`. What to do: show the author the drawn units, then record their answer in the plan's `author_check` — `by`, `date`, and their words in `quote`.",
692
+ ],
693
+ [
694
+ "The plan of `P1-S01`, `plan.json` in its folder, is not a study plan: author_check: is set, and the plan leaves `mode` empty — the author approves a complete plan: fill them, or set author_check to null. What to do: give it the fields on the rule's page — `question`, `population`, `arms`, `primary_outcome`, and for a measurement its `mode`.",
695
+ ],
696
+ ]);
697
+ });
698
+
699
+ describe("repro/study-registered — the index's mode columns", () => {
700
+ it("reports an index whose mode column is not the plan's, and never compares the two read from git", () => {
701
+ const r = HEADLINE("plan.json");
702
+ const at = { commit: "a".repeat(40), date: "2026-10-14T09:00:00+05:00" };
703
+ const lintWith = (readme: string): string[] =>
704
+ lint({
705
+ ...registered(r, readme),
706
+ [`${DIR}/repro/P1-S01-baseline/plan.json`]: FILLED,
707
+ }).map((x) => x.messageId);
708
+ // Guards: the mode is in the tree, so a stale one is reported; the commits are what git said when
709
+ // `studies index` ran — a squash merge renames them — so whatever they say is in sync.
710
+ expect([
711
+ lintWith(withIndex("# Reproduction index\n", r, NO_FACTS)),
712
+ lintWith(
713
+ FILLED_INDEX(
714
+ r,
715
+ new Map([
716
+ [
717
+ "P1-S01",
718
+ {
719
+ kind: "read",
720
+ decision: at,
721
+ decisionDeleted: false,
722
+ firstOutput: at,
723
+ },
724
+ ],
725
+ ]),
726
+ ),
727
+ ),
728
+ ]).toEqual([["stale-index"], []]);
729
+ });
730
+ });