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,161 @@
1
+ /**
2
+ * `paperlint studies gate <paper-dir> <id>` — may the study's run start? For a study decided before
3
+ * its run (the default mode), only once its decision file exists and is committed as it stands: exit 0
4
+ * with the commit and its date, else exit 1 saying why. A runner calls it first:
5
+ *
6
+ * npx paperlint studies gate papers/my-paper P1-S03 && ./run.sh
7
+ *
8
+ * An exploratory study has no gate: it exits 0 and says so. A study that declares no mode, names no
9
+ * decision file, or whose row names a folder that is not one inside `repro/`, is refused.
10
+ */
11
+ import { join } from "node:path";
12
+ import { callerPath } from "./caller-path.ts";
13
+ import {
14
+ modeStudies,
15
+ onDisk,
16
+ type ModeStudy,
17
+ type StudyDeps,
18
+ } from "./study-modes.ts";
19
+ import { REPRO_DIR } from "./domain/repro-layout.ts";
20
+ import { decisionAsOutput, type DecideFirst } from "./domain/study-mode.ts";
21
+ import type { Study } from "./domain/study-registry.ts";
22
+ import type { Committed, GitHistory } from "./ports/git-history.ts";
23
+ import { readableFolders, registryOf } from "./study-registry-rule.ts";
24
+ import {
25
+ refusal,
26
+ refused,
27
+ type StudyRefusal,
28
+ type StudyRun,
29
+ } from "./study-run.ts";
30
+
31
+ export interface GateDeps extends StudyDeps {
32
+ readonly git: GitHistory;
33
+ }
34
+
35
+ /** The line of a committed state that holds the run back. */
36
+ const shutLine = (
37
+ id: string,
38
+ file: string,
39
+ c: Exclude<Committed, { readonly kind: "committed" }>,
40
+ ): string => {
41
+ switch (c.kind) {
42
+ case "changed":
43
+ return `${id}: the decision ${file} has changed since its last commit, ${c.commit}; the run waits for the decision as committed. Commit it, then run this again.`;
44
+ case "deleted":
45
+ return `${id}: the decision ${file} is not in HEAD: it was deleted in ${c.commit}; the run waits for a decision that stands. Restore it, commit it, then run this again.`;
46
+ case "not-committed":
47
+ return `${id}: the decision ${file} is not committed; the run waits for it. Write the decision there, commit it, then run this again.`;
48
+ case "unknown":
49
+ return `${id}: git could not say whether the decision ${file} is committed: ${c.why}.`;
50
+ }
51
+ };
52
+
53
+ /** What a decision's committed state means for the run. */
54
+ export const gateLine = (
55
+ id: string,
56
+ file: string,
57
+ c: Committed,
58
+ ): { readonly open: boolean; readonly line: string } =>
59
+ c.kind === "committed"
60
+ ? {
61
+ open: true,
62
+ line: `${id}: the decision ${file} is committed in ${c.commit} (${c.date}); the run may start.`,
63
+ }
64
+ : { open: false, line: shutLine(id, file, c) };
65
+
66
+ /**
67
+ * Why the decision cannot gate the run though it is named: its own outputs match it, or it is a
68
+ * directory (git answers for a directory with the last commit of anything under it); null when usable.
69
+ */
70
+ const unusable = (
71
+ paper: string,
72
+ { id, folder }: Pick<Study, "id" | "folder">,
73
+ mode: DecideFirst,
74
+ deps: GateDeps,
75
+ ): StudyRefusal | null => {
76
+ const file = mode.decision.file;
77
+ const glob = decisionAsOutput(folder, mode);
78
+ if (glob !== null)
79
+ return refusal(
80
+ `${id}: its decision ${file} is matched by its output \`${glob}\`; a decision cannot be what its own run produces.`,
81
+ );
82
+ const on = onDisk(join(paper, file), deps);
83
+ return on === "directory" || on === "symbolic link"
84
+ ? refusal(
85
+ `${id}: its decision ${file} is a ${on}, not a file; the run cannot be gated on it. Name the decision file in \`mode.decision.file\`.`,
86
+ )
87
+ : null;
88
+ };
89
+
90
+ /** The gate of a decide-first study. */
91
+ const gateDecided = (
92
+ paper: string,
93
+ { id, folder }: Study,
94
+ mode: DecideFirst,
95
+ deps: GateDeps,
96
+ ): StudyRun => {
97
+ const file = mode.decision.file;
98
+ const blocked = unusable(paper, { id, folder }, mode, deps);
99
+ if (blocked !== null) return refused(blocked);
100
+ const g = gateLine(
101
+ id,
102
+ file,
103
+ deps.git.committed(callerPath(paper), callerPath(join(paper, file))),
104
+ );
105
+ return g.open ? { ok: true, lines: [g.line] } : refused(refusal(g.line));
106
+ };
107
+
108
+ /** The refusal when the row of `id` names a folder that is not one inside `repro/`; null when none does. */
109
+ const escapingRow = (
110
+ paper: string,
111
+ shown: string,
112
+ id: string,
113
+ deps: GateDeps,
114
+ ): StudyRefusal | null => {
115
+ const r = registryOf(paper, deps);
116
+ const readable = readableFolders(paper, deps);
117
+ const row = (r.kind === "read" ? r.registry.studies : []).find(
118
+ (x) => x.id === id && !readable(x.folder),
119
+ );
120
+ return row === undefined
121
+ ? null
122
+ : refusal(
123
+ `${id}: its row names the folder \`${row.folder}\`, which is not one folder inside ${shown}/${REPRO_DIR}/; nothing is read through it. Give the row its own folder's name.`,
124
+ );
125
+ };
126
+
127
+ /** The refusal of a study whose plan is still a draft: a mode decides nothing until its plan is ready. */
128
+ const draftRefusal = (id: string, s: ModeStudy): StudyRefusal => {
129
+ const names = s.empty.map((f) => `\`${f}\``).join(", ");
130
+ const verb = s.empty.length === 1 ? "is" : "are";
131
+ return refusal(
132
+ `${id}: its ${s.plan} is a draft — ${names} ${verb} empty; the run waits for a ready plan. Fill it in, then run this again.`,
133
+ );
134
+ };
135
+
136
+ /** `studies gate`: 0 when the run may start, 1 with the reason when not. */
137
+ export function runGate(
138
+ paper: string,
139
+ shown: string,
140
+ id: string,
141
+ deps: GateDeps,
142
+ ): StudyRun {
143
+ const escaping = escapingRow(paper, shown, id, deps);
144
+ if (escaping !== null) return refused(escaping);
145
+ const s = modeStudies(paper, deps).find((m) => m.row.id === id);
146
+ if (s === undefined)
147
+ return refused(
148
+ refusal(
149
+ `${id} declares no mode in ${shown}: no row of ${shown}/${REPRO_DIR}/studies.json with this id names a plan.json that parses with \`mode\`. Lint the paper (\`npx paperlint lint\`) to see why.`,
150
+ ),
151
+ );
152
+ if (s.empty.length > 0) return refused(draftRefusal(id, s));
153
+ return s.mode.kind === "decide_before_run"
154
+ ? gateDecided(paper, s.row, s.mode, deps)
155
+ : {
156
+ ok: true,
157
+ lines: [
158
+ `${id} is exploratory (${s.mode.reason}): it has no gate, and its results are reported as exploratory.`,
159
+ ],
160
+ };
161
+ }
@@ -0,0 +1,23 @@
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 type { IndexInput, RegistryState } from "./domain/study-registry.ts";
7
+ import {
8
+ decideFirstStudies,
9
+ type OrderDeps,
10
+ } from "./outputs-before-decision-rule.ts";
11
+ import { plansOf, readableFolders } from "./study-registry-rule.ts";
12
+
13
+ /** The plans and the histories of the paper folder `paper`, whose registry is `r`. */
14
+ export const indexInputOf = (
15
+ paper: string,
16
+ r: RegistryState,
17
+ deps: OrderDeps,
18
+ ): IndexInput => ({
19
+ plans: plansOf(paper, r, deps, readableFolders(paper, deps)),
20
+ history: new Map(
21
+ decideFirstStudies(paper, deps).map((s) => [s.id, indexHistory(s)]),
22
+ ),
23
+ });
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Reading a paper's studies with their modes: each registered study whose `plan.json` parses with a
3
+ * `mode`, and the files under a study's folder. 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 { basename, dirname, join } from "node:path";
8
+ import { callerPath } from "./caller-path.ts";
9
+ import { REPRO_DIR } from "./domain/repro-layout.ts";
10
+ import type { StudyMode } from "./domain/study-mode.ts";
11
+ import type { NotAFile } from "./domain/decide-first.ts";
12
+ import {
13
+ isInside,
14
+ type PlanTextField,
15
+ type Study,
16
+ } from "./domain/study-registry.ts";
17
+ import type { Files } from "./ports/files.ts";
18
+ import type { StudyRegistryCodec } from "./ports/study-registry-codec.ts";
19
+ import type { DirEntry, ListEntries } from "./ports/list-entries.ts";
20
+ import type { ListDir } from "./ports/talk-media.ts";
21
+ import { readableFolders, registryOf, textAt } from "./study-registry-rule.ts";
22
+
23
+ /** What reading a paper's studies needs: the disk, its listings with and without kinds, and the parsers. */
24
+ export interface StudyDeps {
25
+ readonly files: Files;
26
+ readonly listDir: ListDir;
27
+ readonly entries: ListEntries;
28
+ readonly codec: StudyRegistryCodec;
29
+ }
30
+
31
+ /** What a decision path names on disk: a file, nothing, or something git cannot vouch for. */
32
+ export type OnDisk = "file" | "absent" | NotAFile;
33
+
34
+ const ON_DISK: Readonly<Record<DirEntry["kind"], OnDisk>> = {
35
+ file: "file",
36
+ dir: "directory",
37
+ symlink: "symbolic link",
38
+ };
39
+
40
+ /**
41
+ * What `p` is on disk, read from its directory's entries without following a link: a decision must
42
+ * be a regular file — git answers for a directory with the commits of anything under it, and for a
43
+ * link with the link, not the file it points to.
44
+ */
45
+ export const onDisk = (p: string, deps: Pick<StudyDeps, "entries">): OnDisk => {
46
+ const e = deps
47
+ .entries(callerPath(dirname(p)))
48
+ .find((x) => x.name === basename(p));
49
+ return e === undefined ? "absent" : ON_DISK[e.kind];
50
+ };
51
+
52
+ /**
53
+ * Every file under `dir`, relative to it, `/`-separated; none when it is not a directory. A symbolic
54
+ * link is listed as a file — git stores it as one — and never followed: a link to the folder itself
55
+ * would recurse forever, and one to a tree outside would bring its files into the study.
56
+ */
57
+ export const filesUnder = (
58
+ dir: string,
59
+ deps: Pick<StudyDeps, "entries">,
60
+ prefix = "",
61
+ ): readonly string[] =>
62
+ deps.entries(callerPath(dir)).flatMap(({ name, kind }) => {
63
+ const rel = prefix === "" ? name : `${prefix}/${name}`;
64
+ return kind === "dir" ? filesUnder(join(dir, name), deps, rel) : [rel];
65
+ });
66
+
67
+ /**
68
+ * A study whose plan parsed with a mode: its row, the plan's path in its folder, the mode, and the
69
+ * plan's empty texts — none when it is ready. Only a ready plan's mode decides anything: a draft may
70
+ * name no outputs, or an empty decision file.
71
+ */
72
+ export interface ModeStudy {
73
+ readonly row: Study;
74
+ readonly plan: string;
75
+ readonly mode: StudyMode;
76
+ readonly empty: readonly PlanTextField[];
77
+ }
78
+
79
+ /**
80
+ * The study `s`'s plan, when it is a plan inside a folder every reader may read through
81
+ * (`readableFolders`), by the registry rule's own containment test (`isInside`), that parsed with a
82
+ * mode; else null.
83
+ */
84
+ const modeStudyOf = (
85
+ paper: string,
86
+ s: Study,
87
+ deps: Pick<StudyDeps, "files" | "codec">,
88
+ readable: (folder: string) => boolean,
89
+ ): ModeStudy | null => {
90
+ const plan = s.plan;
91
+ const text =
92
+ plan === undefined ||
93
+ !readable(s.folder) ||
94
+ !isInside(plan) ||
95
+ plan.includes("\0")
96
+ ? null
97
+ : textAt(deps.files, join(paper, REPRO_DIR, s.folder, plan));
98
+ if (plan === undefined || text === null) return null;
99
+ const parsed = deps.codec.parsePlan(text, s.kind);
100
+ if (parsed.kind === "unreadable" || parsed.plan.mode === undefined)
101
+ return null;
102
+ const empty = parsed.kind === "draft" ? parsed.empty : [];
103
+ return { row: s, plan, mode: parsed.plan.mode, empty };
104
+ };
105
+
106
+ /**
107
+ * Every study of the paper folder `paper` whose plan declares a mode, in registry order. A plan that
108
+ * does not parse is `repro/study-registered`'s to report; here it declares nothing.
109
+ */
110
+ export const modeStudies = (
111
+ paper: string,
112
+ deps: Pick<StudyDeps, "files" | "codec" | "entries">,
113
+ ): readonly ModeStudy[] => {
114
+ const r = registryOf(paper, deps);
115
+ const readable = readableFolders(paper, deps);
116
+ return (r.kind === "read" ? r.registry.studies : []).flatMap((s) => {
117
+ const m = modeStudyOf(paper, s, deps, readable);
118
+ return m === null ? [] : [m];
119
+ });
120
+ };
@@ -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,7 +19,9 @@ 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";
20
26
  import {
21
27
  registryOf,
@@ -24,6 +30,9 @@ import {
24
30
  studyRegistryRules,
25
31
  } from "./study-registry-rule.ts";
26
32
 
33
+ /** No plan and no history: the index with every mode column `—`. */
34
+ const NO_FACTS: IndexInput = { plans: new Map(), history: new Map() };
35
+
27
36
  const DIR = "/work/papers/example";
28
37
  const SCAFFOLD = {
29
38
  [`${DIR}/PIPELINE-STATUS.md`]: "# status\n",
@@ -45,7 +54,7 @@ const study = (over: Partial<Study> = {}): Study => ({
45
54
  /** A registered `repro/`: the registry, its index, and a README.md in each row's folder. */
46
55
  const registered = (
47
56
  r: StudyRegistry,
48
- readme: string | null = withIndex("# Reproduction index\n", r),
57
+ readme: string | null = withIndex("# Reproduction index\n", r, NO_FACTS),
49
58
  ): Record<string, string> => ({
50
59
  ...SCAFFOLD,
51
60
  [`${DIR}/repro/studies.json`]: registryText(r),
@@ -73,6 +82,7 @@ function lint(
73
82
  const rule = studyRegistryRules({
74
83
  files,
75
84
  listDir: memoryListDir(files),
85
+ entries: memoryListEntries(files),
76
86
  codec: studyRegistryCodec,
77
87
  })[name];
78
88
  const out: Reported[] = [];
@@ -222,9 +232,9 @@ describe("repro/study-registered — reports a row's status and the index", () =
222
232
 
223
233
  it("a stale index", () => {
224
234
  const r = P1(study());
225
- expect(messages(registered(r, withIndex("# x\n", P1())))).toEqual([
226
- studyMessage({ problem: "stale-index", why: "differs" }),
227
- ]);
235
+ expect(messages(registered(r, withIndex("# x\n", P1(), NO_FACTS)))).toEqual(
236
+ [studyMessage({ problem: "stale-index", why: "differs" })],
237
+ );
228
238
  });
229
239
  });
230
240
 
@@ -300,7 +310,7 @@ describe("repro/study-registered — a result_file the disk cannot hold", () =>
300
310
  const files = memoryFiles({
301
311
  ...SCAFFOLD,
302
312
  [`${DIR}/repro/studies.json`]: registryText(r),
303
- [`${DIR}/repro/README.md`]: withIndex("# index\n", r),
313
+ [`${DIR}/repro/README.md`]: withIndex("# index\n", r, NO_FACTS),
304
314
  [`${DIR}/repro/P1-S01-baseline/README.md`]: "# s\n",
305
315
  });
306
316
  const probe = {
@@ -314,14 +324,101 @@ describe("repro/study-registered — a result_file the disk cannot hold", () =>
314
324
  studyFindings(DIR, {
315
325
  files: probe,
316
326
  listDir: memoryListDir(files),
327
+ entries: memoryListEntries(files),
317
328
  codec: studyRegistryCodec,
318
329
  }).map((f) => f.problem),
319
330
  ).toEqual(["result-file"]);
320
331
  });
321
332
  });
322
333
 
334
+ describe("repro/study-registered — a study folder that is a symbolic link", () => {
335
+ it("never reads a plan or a result through it", () => {
336
+ const r = P1(
337
+ study({
338
+ status: "done",
339
+ result: "41 of 60",
340
+ result_file: "r.txt",
341
+ plan: "plan.json",
342
+ }),
343
+ );
344
+ const files = memoryFiles({
345
+ ...registered(r),
346
+ [`${DIR}/repro/P1-S01-baseline/plan.json`]: "not JSON",
347
+ [`${DIR}/repro/P1-S01-baseline/r.txt`]: "41 of 60\n",
348
+ });
349
+ const found = studyFindings(DIR, {
350
+ files,
351
+ listDir: memoryListDir(files),
352
+ entries: (dir) =>
353
+ memoryListEntries(files)(dir).map((e) =>
354
+ e.name === "P1-S01-baseline" ? { ...e, kind: "symlink" as const } : e,
355
+ ),
356
+ codec: studyRegistryCodec,
357
+ });
358
+ // Guards: the files behind the link are not read — the plan and the result read as missing.
359
+ expect(
360
+ found.map((f) =>
361
+ f.problem === "bad-plan" || f.problem === "result-file"
362
+ ? `${f.problem}:${f.why}`
363
+ : f.problem,
364
+ ),
365
+ ).toEqual(["result-file:missing", "bad-plan:missing"]);
366
+ });
367
+ });
368
+
369
+ describe("repro/study-registered — a row whose folder leads outside repro/", () => {
370
+ it("is reported by name, and nothing is read through it: its plan and result read as missing", () => {
371
+ const r = P1(
372
+ study({
373
+ folder: "../outside",
374
+ status: "done",
375
+ result: "41 of 60",
376
+ result_file: "r.txt",
377
+ plan: "plan.json",
378
+ }),
379
+ );
380
+ const files = memoryFiles({
381
+ ...SCAFFOLD,
382
+ [`${DIR}/repro/studies.json`]: registryText(r),
383
+ [`${DIR}/repro/README.md`]: withIndex("# index\n", r, NO_FACTS),
384
+ [`${DIR}/outside/plan.json`]: "not JSON",
385
+ [`${DIR}/outside/r.txt`]: "41 of 60\n",
386
+ });
387
+ const read: string[] = [];
388
+ const spy = {
389
+ ...files,
390
+ readBytes: (p: Parameters<typeof files.readBytes>[0]) => {
391
+ read.push(p);
392
+ return files.readBytes(p);
393
+ },
394
+ isFile: (p: Parameters<typeof files.isFile>[0]): boolean => {
395
+ read.push(p);
396
+ return files.isFile(p);
397
+ },
398
+ };
399
+ const found = studyFindings(DIR, {
400
+ files: spy,
401
+ listDir: memoryListDir(files),
402
+ entries: memoryListEntries(files),
403
+ codec: studyRegistryCodec,
404
+ });
405
+ // Guards: a row naming `../outside` never has a file outside `repro/` read for it.
406
+ expect([
407
+ found.map((f) =>
408
+ f.problem === "bad-plan" || f.problem === "result-file"
409
+ ? `${f.problem}:${f.why}`
410
+ : f.problem,
411
+ ),
412
+ read.filter((p) => p.includes("/outside/")),
413
+ ]).toEqual([
414
+ ["folder-name", "no-folder", "result-file:missing", "bad-plan:missing"],
415
+ [],
416
+ ]);
417
+ });
418
+ });
419
+
323
420
  /** A plan as a study would hold it once the author has checked its drawn units. */
324
- const FILLED = planText({
421
+ const FILLED_PLAN: StudyPlan = {
325
422
  question: "How many rules can a linter check?",
326
423
  population: {
327
424
  source: "the crawl of 2026-10-01",
@@ -330,7 +427,28 @@ const FILLED = planText({
330
427
  arms: [],
331
428
  primary_outcome: "the share of rules a linter can check",
332
429
  author_check: { by: "A. Author", date: "2026-10-14", quote: "Go." },
333
- });
430
+ mode: {
431
+ kind: "decide_before_run",
432
+ outputs: ["results.tsv"],
433
+ decision: {
434
+ file: "repro/P1-S01-baseline/decision.md",
435
+ note: "the sample approved",
436
+ },
437
+ },
438
+ };
439
+ const FILLED = planText(FILLED_PLAN);
440
+
441
+ /** The index of `r` as `studies index` writes it, FILLED_PLAN as its study's plan and `history` read. */
442
+ const FILLED_INDEX = (
443
+ r: StudyRegistry,
444
+ history: IndexInput["history"] = new Map(),
445
+ ): string =>
446
+ withIndex("# Reproduction index\n", r, {
447
+ plans: new Map([
448
+ ["P1-S01-baseline/plan.json", { kind: "ready", plan: FILLED_PLAN }],
449
+ ]),
450
+ history,
451
+ });
334
452
 
335
453
  /** A registry with a headline measurement of one running study whose row names `plan`. */
336
454
  const HEADLINE = (plan: string): StudyRegistry => ({
@@ -348,10 +466,11 @@ const HEADLINE = (plan: string): StudyRegistry => ({
348
466
  });
349
467
 
350
468
  describe("repro/study-registered — the plan a row names, read from its folder", () => {
351
- it("is silent on a plan.json the author checked", () => {
469
+ it("is silent on a plan.json the author checked, its mode in the index", () => {
470
+ const r = HEADLINE("plan.json");
352
471
  expect(
353
472
  lint({
354
- ...registered(HEADLINE("plan.json")),
473
+ ...registered(r, FILLED_INDEX(r)),
355
474
  [`${DIR}/repro/P1-S01-baseline/plan.json`]: FILLED,
356
475
  }),
357
476
  ).toEqual([]);
@@ -389,7 +508,9 @@ describe("repro/study-registered — the plan a row names, read from its folder"
389
508
  expect(
390
509
  lint({
391
510
  ...registered(HEADLINE("plan.json")),
392
- [`${DIR}/repro/P1-S01-baseline/plan.json`]: planText(planTemplate()),
511
+ [`${DIR}/repro/P1-S01-baseline/plan.json`]: planText(
512
+ planTemplate("spike", "x"),
513
+ ),
393
514
  }).map((r) => [r.messageId, r.line]),
394
515
  ).toEqual([
395
516
  ["bad-plan", 1],
@@ -413,6 +534,7 @@ describe("repro/study-registered — a plan the disk cannot hold", () => {
413
534
  studyFindings(DIR, {
414
535
  files: probe,
415
536
  listDir: memoryListDir(files),
537
+ entries: memoryListEntries(files),
416
538
  codec: studyRegistryCodec,
417
539
  }).map((f) => f.problem),
418
540
  ).toEqual(["bad-plan"]);
@@ -454,3 +576,68 @@ describe("repro/study-plan-name — a plan under its old name", () => {
454
576
  ]).toEqual([[], []]);
455
577
  });
456
578
  });
579
+
580
+ /** A plan whose texts are filled and which declares no mode; checked by the author when `check`. */
581
+ const noMode = (check: boolean): string =>
582
+ planText({
583
+ question: "q",
584
+ population: { source: "s", draw: { kind: "all", method: "census" } },
585
+ arms: [],
586
+ primary_outcome: "o",
587
+ author_check: check
588
+ ? { by: "A. Author", date: "2026-10-14", quote: "Go." }
589
+ : null,
590
+ });
591
+
592
+ /** The first line of each finding over the headline study with `plan` as its plan.json. */
593
+ const firstLines = (plan: string): readonly string[] =>
594
+ lint({
595
+ ...registered(HEADLINE("plan.json")),
596
+ [`${DIR}/repro/P1-S01-baseline/plan.json`]: plan,
597
+ }).map((r) => r.message.split("\n")[0] ?? "");
598
+
599
+ it("repro/study-registered — a measurement's plan that has run without a mode is reported; approved without one, it is no plan", () => {
600
+ // Guards: decide-first is the default — a measurement that ran with no mode is reported, never silent.
601
+ expect([firstLines(noMode(false)), firstLines(noMode(true))]).toEqual([
602
+ [
603
+ "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`.",
604
+ "`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`.",
605
+ ],
606
+ [
607
+ "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`.",
608
+ ],
609
+ ]);
610
+ });
611
+
612
+ describe("repro/study-registered — the index's mode columns", () => {
613
+ it("reports an index whose mode column is not the plan's, and never compares the two read from git", () => {
614
+ const r = HEADLINE("plan.json");
615
+ const at = { commit: "a".repeat(40), date: "2026-10-14T09:00:00+05:00" };
616
+ const lintWith = (readme: string): string[] =>
617
+ lint({
618
+ ...registered(r, readme),
619
+ [`${DIR}/repro/P1-S01-baseline/plan.json`]: FILLED,
620
+ }).map((x) => x.messageId);
621
+ // Guards: the mode is in the tree, so a stale one is reported; the commits are what git said when
622
+ // `studies index` ran — a squash merge renames them — so whatever they say is in sync.
623
+ expect([
624
+ lintWith(withIndex("# Reproduction index\n", r, NO_FACTS)),
625
+ lintWith(
626
+ FILLED_INDEX(
627
+ r,
628
+ new Map([
629
+ [
630
+ "P1-S01",
631
+ {
632
+ kind: "read",
633
+ decision: at,
634
+ decisionDeleted: false,
635
+ firstOutput: at,
636
+ },
637
+ ],
638
+ ]),
639
+ ),
640
+ ),
641
+ ]).toEqual([["stale-index"], []]);
642
+ });
643
+ });