paperlint 10.4.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 (111) hide show
  1. package/dist/CLAUDE.md.spec.d.ts.map +1 -1
  2. package/dist/CLAUDE.md.spec.js +6 -3
  3. package/dist/CLAUDE.md.spec.js.map +1 -1
  4. package/dist/adapters/latex/captions.d.ts +9 -0
  5. package/dist/adapters/latex/captions.d.ts.map +1 -0
  6. package/dist/adapters/latex/captions.js +40 -0
  7. package/dist/adapters/latex/captions.js.map +1 -0
  8. package/dist/adapters/latex/index.d.ts +1 -0
  9. package/dist/adapters/latex/index.d.ts.map +1 -1
  10. package/dist/adapters/latex/index.js +1 -0
  11. package/dist/adapters/latex/index.js.map +1 -1
  12. package/dist/adapters/latex/parse.d.ts.map +1 -1
  13. package/dist/adapters/latex/parse.js +6 -0
  14. package/dist/adapters/latex/parse.js.map +1 -1
  15. package/dist/adapters/node/list-entries.io.d.ts.map +1 -1
  16. package/dist/adapters/node/list-entries.io.js +3 -1
  17. package/dist/adapters/node/list-entries.io.js.map +1 -1
  18. package/dist/domain/decide-first.d.ts +11 -1
  19. package/dist/domain/decide-first.d.ts.map +1 -1
  20. package/dist/domain/decide-first.js +6 -0
  21. package/dist/domain/decide-first.js.map +1 -1
  22. package/dist/domain/index-columns.d.ts.map +1 -1
  23. package/dist/domain/index-columns.js +2 -0
  24. package/dist/domain/index-columns.js.map +1 -1
  25. package/dist/domain/study-registry.d.ts +65 -8
  26. package/dist/domain/study-registry.d.ts.map +1 -1
  27. package/dist/domain/study-registry.js +63 -19
  28. package/dist/domain/study-registry.js.map +1 -1
  29. package/dist/outputs-before-decision-rule.d.ts +1 -1
  30. package/dist/outputs-before-decision-rule.d.ts.map +1 -1
  31. package/dist/outputs-before-decision-rule.js +21 -17
  32. package/dist/outputs-before-decision-rule.js.map +1 -1
  33. package/dist/ports/list-entries.d.ts +7 -4
  34. package/dist/ports/list-entries.d.ts.map +1 -1
  35. package/dist/ports/study-deps.d.ts +14 -0
  36. package/dist/ports/study-deps.d.ts.map +1 -0
  37. package/dist/ports/study-deps.js +2 -0
  38. package/dist/ports/study-deps.js.map +1 -0
  39. package/dist/ports/study-folder.d.ts +43 -0
  40. package/dist/ports/study-folder.d.ts.map +1 -0
  41. package/dist/ports/study-folder.js +2 -0
  42. package/dist/ports/study-folder.js.map +1 -0
  43. package/dist/presets.d.ts +1 -2
  44. package/dist/presets.d.ts.map +1 -1
  45. package/dist/presets.js +1 -2
  46. package/dist/presets.js.map +1 -1
  47. package/dist/studies.d.ts.map +1 -1
  48. package/dist/studies.js +10 -7
  49. package/dist/studies.js.map +1 -1
  50. package/dist/study-folder.d.ts +14 -0
  51. package/dist/study-folder.d.ts.map +1 -0
  52. package/dist/study-folder.js +123 -0
  53. package/dist/study-folder.js.map +1 -0
  54. package/dist/study-gate.d.ts +1 -1
  55. package/dist/study-gate.d.ts.map +1 -1
  56. package/dist/study-gate.js +65 -28
  57. package/dist/study-gate.js.map +1 -1
  58. package/dist/study-index-input.d.ts +1 -1
  59. package/dist/study-index-input.d.ts.map +1 -1
  60. package/dist/study-index-input.js +8 -3
  61. package/dist/study-index-input.js.map +1 -1
  62. package/dist/study-modes.d.ts +38 -27
  63. package/dist/study-modes.d.ts.map +1 -1
  64. package/dist/study-modes.js +42 -53
  65. package/dist/study-modes.js.map +1 -1
  66. package/dist/study-registry-rule.d.ts +2 -6
  67. package/dist/study-registry-rule.d.ts.map +1 -1
  68. package/dist/study-registry-rule.js +32 -41
  69. package/dist/study-registry-rule.js.map +1 -1
  70. package/docs/design/study-modes.md +46 -28
  71. package/docs/rules/repro/outputs-before-decision.md +3 -0
  72. package/docs/rules/repro/study-registered.md +3 -2
  73. package/docs/rules/tex/heading-case.md +1 -1
  74. package/eslint.config.mjs +36 -24
  75. package/package.json +1 -1
  76. package/skills/grade-paper-writing/SKILL.md +4 -2
  77. package/skills/grade-paper-writing/SKILL.md.spec.ts +3 -1
  78. package/skills/grade-paper-writing/__snapshots__/prose-lint.test.ts.snap +16 -3
  79. package/skills/grade-paper-writing/prose-lint.mjs +127 -95
  80. package/skills/grade-paper-writing/prose-lint.test.ts +119 -10
  81. package/src/CLAUDE.md +7 -4
  82. package/src/CLAUDE.md.spec.ts +6 -3
  83. package/src/adapters/latex/captions.test.ts +57 -0
  84. package/src/adapters/latex/captions.ts +56 -0
  85. package/src/adapters/latex/index.ts +1 -0
  86. package/src/adapters/latex/parse.ts +7 -0
  87. package/src/adapters/node/list-entries.io.ts +2 -1
  88. package/src/adapters/node/list-entries.test.ts +18 -0
  89. package/src/domain/decide-first.test.ts +33 -0
  90. package/src/domain/decide-first.ts +16 -1
  91. package/src/domain/index-columns.test.ts +49 -4
  92. package/src/domain/index-columns.ts +2 -0
  93. package/src/domain/study-measurements.test.ts +7 -3
  94. package/src/domain/study-registry.test.ts +34 -6
  95. package/src/domain/study-registry.ts +123 -31
  96. package/src/outputs-before-decision-rule.test.ts +47 -2
  97. package/src/outputs-before-decision-rule.ts +31 -22
  98. package/src/ports/list-entries.ts +7 -4
  99. package/src/ports/study-deps.ts +14 -0
  100. package/src/ports/study-folder.ts +38 -0
  101. package/src/presets.ts +1 -2
  102. package/src/studies-index-modes.test.ts +2 -2
  103. package/src/studies.ts +19 -10
  104. package/src/study-folder.test.ts +237 -0
  105. package/src/study-folder.ts +176 -0
  106. package/src/study-gate.test.ts +158 -7
  107. package/src/study-gate.ts +127 -54
  108. package/src/study-index-input.ts +12 -4
  109. package/src/study-modes.ts +67 -80
  110. package/src/study-registry-rule.test.ts +87 -0
  111. package/src/study-registry-rule.ts +58 -66
package/src/study-gate.ts CHANGED
@@ -6,21 +6,26 @@
6
6
  * npx paperlint studies gate papers/my-paper P1-S03 && ./run.sh
7
7
  *
8
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.
9
+ * decision file, whose row names a folder that is not one inside `repro/`, or whose id two rows
10
+ * share, is refused.
10
11
  */
11
12
  import { join } from "node:path";
12
13
  import { callerPath } from "./caller-path.ts";
13
- import {
14
- modeStudies,
15
- onDisk,
16
- type ModeStudy,
17
- type StudyDeps,
18
- } from "./study-modes.ts";
14
+ import { decisionEntry, readModeStudy, type ModeStudy } from "./study-modes.ts";
19
15
  import { REPRO_DIR } from "./domain/repro-layout.ts";
20
16
  import { decisionAsOutput, type DecideFirst } from "./domain/study-mode.ts";
21
- import type { Study } from "./domain/study-registry.ts";
17
+ import {
18
+ STUDIES_FILE,
19
+ studiesOf,
20
+ type RegistryState,
21
+ studyById,
22
+ type Study,
23
+ } from "./domain/study-registry.ts";
22
24
  import type { Committed, GitHistory } from "./ports/git-history.ts";
23
- import { readableFolders, registryOf } from "./study-registry-rule.ts";
25
+ import type { StudyFolder } from "./ports/study-folder.ts";
26
+ import { folderRefusalText, studyFolder } from "./study-folder.ts";
27
+ import type { StudyDeps } from "./ports/study-deps.ts";
28
+ import { registryOf } from "./study-registry-rule.ts";
24
29
  import {
25
30
  refusal,
26
31
  refused,
@@ -68,34 +73,48 @@ export const gateLine = (
68
73
  * directory (git answers for a directory with the last commit of anything under it); null when usable.
69
74
  */
70
75
  const unusable = (
71
- paper: string,
72
- { id, folder }: Pick<Study, "id" | "folder">,
76
+ { id, folder: studyDir }: Pick<Study, "id" | "folder">,
73
77
  mode: DecideFirst,
74
- deps: GateDeps,
78
+ folder: StudyFolder,
75
79
  ): StudyRefusal | null => {
76
80
  const file = mode.decision.file;
77
- const glob = decisionAsOutput(folder, mode);
81
+ const glob = decisionAsOutput(studyDir, mode);
78
82
  if (glob !== null)
79
83
  return refusal(
80
84
  `${id}: its decision ${file} is matched by its output \`${glob}\`; a decision cannot be what its own run produces.`,
81
85
  );
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;
86
+ const on = decisionEntry(folder, file);
87
+ switch (on.kind) {
88
+ case "not-a-file":
89
+ return refusal(
90
+ `${id}: its decision ${file} is a ${on.what}, not a file; the run cannot be gated on it. Name the decision file in \`mode.decision.file\`.`,
91
+ );
92
+ case "linked":
93
+ return refusal(
94
+ `${id}: its decision ${file} passes through the symbolic link \`${on.link}\`; git records the link, not the files behind it, so the run cannot be gated on it. Name a decision file that lies in real directories in \`mode.decision.file\`.`,
95
+ );
96
+ case "ok":
97
+ return null;
98
+ }
88
99
  };
89
100
 
101
+ /** What one gate run reads with: the paper's folder, how it is shown, the study folders' reader, git. */
102
+ interface GateRun {
103
+ readonly paper: string;
104
+ readonly shown: string;
105
+ readonly folder: StudyFolder;
106
+ readonly deps: GateDeps;
107
+ }
108
+
90
109
  /** The gate of a decide-first study. */
91
110
  const gateDecided = (
92
- paper: string,
93
- { id, folder }: Study,
111
+ { paper, folder, deps }: GateRun,
112
+ row: Study,
94
113
  mode: DecideFirst,
95
- deps: GateDeps,
96
114
  ): StudyRun => {
115
+ const { id } = row;
97
116
  const file = mode.decision.file;
98
- const blocked = unusable(paper, { id, folder }, mode, deps);
117
+ const blocked = unusable(row, mode, folder);
99
118
  if (blocked !== null) return refused(blocked);
100
119
  const g = gateLine(
101
120
  id,
@@ -105,25 +124,30 @@ const gateDecided = (
105
124
  return g.open ? { ok: true, lines: [g.line] } : refused(refusal(g.line));
106
125
  };
107
126
 
108
- /** The refusal when the row of `id` names a folder that is not one inside `repro/`; null when none does. */
127
+ /** The refusal when the row names a folder that may not be entered; null when it may. */
109
128
  const escapingRow = (
110
- paper: string,
111
129
  shown: string,
112
- id: string,
113
- deps: GateDeps,
130
+ row: Study,
131
+ folder: StudyFolder,
114
132
  ): 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),
133
+ const why = folder.refusal(row.folder);
134
+ if (why === null) return null;
135
+ const { reason, fix } = folderRefusalText(why, shown);
136
+ return refusal(
137
+ `${row.id}: its row names the folder \`${row.folder}\`, which ${reason}; nothing is read through it. ${fix}.`,
119
138
  );
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
139
  };
126
140
 
141
+ /** The refusal of an id that two or more rows carry: the gate answers for exactly one study. */
142
+ const ambiguousRefusal = (
143
+ shown: string,
144
+ id: string,
145
+ rows: readonly Study[],
146
+ ): StudyRefusal =>
147
+ refusal(
148
+ `${id}: ${String(rows.length)} rows of ${shown}/${REPRO_DIR}/${STUDIES_FILE} carry this id (folders ${rows.map((x) => `\`${x.folder}\``).join(", ")}); the gate answers for exactly one study. Give each other row the next free id and rename its folder to match, then run this again.`,
149
+ );
150
+
127
151
  /** The refusal of a study whose plan is still a draft: a mode decides nothing until its plan is ready. */
128
152
  const draftRefusal = (id: string, s: ModeStudy): StudyRefusal => {
129
153
  const names = s.empty.map((f) => `\`${f}\``).join(", ");
@@ -133,29 +157,78 @@ const draftRefusal = (id: string, s: ModeStudy): StudyRefusal => {
133
157
  );
134
158
  };
135
159
 
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);
160
+ /** The gate of the one row `row`. */
161
+ const gateRow = (run: GateRun, row: Study): StudyRun => {
162
+ const { shown, folder, deps } = run;
163
+ const escaping = escapingRow(shown, row, folder);
144
164
  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));
165
+ const read = readModeStudy(row, folder, deps.codec);
166
+ if (read.kind === "linked")
167
+ return refused(linkedPlanRefusal(shown, row, read.link));
168
+ if (read.kind === "none") return refused(noModeRefusal(shown, row.id));
169
+ const s = read.study;
170
+ if (s.empty.length > 0) return refused(draftRefusal(row.id, s));
153
171
  return s.mode.kind === "decide_before_run"
154
- ? gateDecided(paper, s.row, s.mode, deps)
172
+ ? gateDecided(run, s.row, s.mode)
155
173
  : {
156
174
  ok: true,
157
175
  lines: [
158
- `${id} is exploratory (${s.mode.reason}): it has no gate, and its results are reported as exploratory.`,
176
+ `${row.id} is exploratory (${s.mode.reason}): it has no gate, and its results are reported as exploratory.`,
159
177
  ],
160
178
  };
179
+ };
180
+
181
+ /** The refusal of a plan whose path passes through a symbolic link. */
182
+ const linkedPlanRefusal = (
183
+ shown: string,
184
+ row: Study,
185
+ link: string,
186
+ ): StudyRefusal =>
187
+ refusal(
188
+ `${row.id}: the plan its row names, in ${shown}/${REPRO_DIR}/${row.folder}, passes through the symbolic link \`${link}\`; nothing is read through a link. Replace the link with the plan file itself, then run this again.`,
189
+ );
190
+
191
+ /** The refusal of an id no row declares a mode for. */
192
+ const noModeRefusal = (shown: string, id: string): StudyRefusal =>
193
+ refusal(
194
+ `${id} declares no mode in ${shown}: the plan its row names in ${shown}/${REPRO_DIR}/${STUDIES_FILE} is missing, does not parse, or has no \`mode\`. Lint the paper (\`npx paperlint lint\`) to see why.`,
195
+ );
196
+
197
+ /** The refusal of an id that no row of the registry carries. */
198
+ const unknownIdRefusal = (shown: string, id: string): StudyRefusal =>
199
+ refusal(
200
+ `${id}: no row of ${shown}/${REPRO_DIR}/${STUDIES_FILE} carries this id. Use an id from the registry, or register the study with \`npx paperlint studies new\`, then run this again.`,
201
+ );
202
+
203
+ /** The refusal of a registry that is not there or does not parse: no row can carry the id. */
204
+ const registryRefusal = (
205
+ shown: string,
206
+ id: string,
207
+ r: Exclude<RegistryState, { readonly kind: "read" }>,
208
+ ): StudyRefusal =>
209
+ refusal(
210
+ `${id}: the registry ${shown}/${REPRO_DIR}/${STUDIES_FILE} ${r.kind === "absent" ? "is missing" : `does not parse (${r.why})`}, so no row can carry this id. Run \`npx paperlint lint\` to see why, then run this again.`,
211
+ );
212
+
213
+ /** `studies gate`: 0 when the run may start, 1 with the reason when not. */
214
+ export function runGate(
215
+ paper: string,
216
+ shown: string,
217
+ id: string,
218
+ deps: GateDeps,
219
+ ): StudyRun {
220
+ const r = registryOf(paper, deps);
221
+ if (r.kind !== "read") return refused(registryRefusal(shown, id, r));
222
+ const found = studyById(studiesOf(r), id);
223
+ switch (found.kind) {
224
+ case "none":
225
+ return refused(unknownIdRefusal(shown, id));
226
+ case "ambiguous":
227
+ return refused(ambiguousRefusal(shown, id, found.rows));
228
+ case "one":
229
+ return gateRow(
230
+ { paper, shown, folder: studyFolder(paper, deps), deps },
231
+ found.row,
232
+ );
233
+ }
161
234
  }
@@ -3,12 +3,17 @@
3
3
  * row's plan, and each decide-first study's history as git gives it now (src/domain/index-columns.ts).
4
4
  */
5
5
  import { indexHistory } from "./domain/index-columns.ts";
6
- import type { IndexInput, RegistryState } from "./domain/study-registry.ts";
6
+ import {
7
+ folderKey,
8
+ type IndexInput,
9
+ type RegistryState,
10
+ } from "./domain/study-registry.ts";
7
11
  import {
8
12
  decideFirstStudies,
9
13
  type OrderDeps,
10
14
  } from "./outputs-before-decision-rule.ts";
11
- import { plansOf, readableFolders } from "./study-registry-rule.ts";
15
+ import { studyFolder } from "./study-folder.ts";
16
+ import { plansOf } from "./study-registry-rule.ts";
12
17
 
13
18
  /** The plans and the histories of the paper folder `paper`, whose registry is `r`. */
14
19
  export const indexInputOf = (
@@ -16,8 +21,11 @@ export const indexInputOf = (
16
21
  r: RegistryState,
17
22
  deps: OrderDeps,
18
23
  ): IndexInput => ({
19
- plans: plansOf(paper, r, deps, readableFolders(paper, deps)),
24
+ plans: plansOf(r, studyFolder(paper, deps), deps.codec),
20
25
  history: new Map(
21
- decideFirstStudies(paper, deps).map((s) => [s.id, indexHistory(s)]),
26
+ decideFirstStudies(paper, deps).map((s) => [
27
+ folderKey(s.folder, s.plan),
28
+ indexHistory(s),
29
+ ]),
22
30
  ),
23
31
  });
@@ -1,69 +1,47 @@
1
1
  /**
2
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`
3
+ * `mode`, and how a decision's path stands. What `repro/outputs-before-decision`, `studies gate`
4
4
  * and the index's mode columns read with (docs/design/study-modes.md). A row whose folder is not one
5
5
  * folder inside `repro/` is never read through.
6
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";
7
+ import { posix } from "node:path";
8
+ import { filter, map, pipe } from "remeda";
11
9
  import type { NotAFile } from "./domain/decide-first.ts";
10
+ import type { StudyMode } from "./domain/study-mode.ts";
12
11
  import {
13
- isInside,
12
+ studiesOf,
14
13
  type PlanTextField,
14
+ type RegistryState,
15
15
  type Study,
16
16
  } from "./domain/study-registry.ts";
17
- import type { Files } from "./ports/files.ts";
18
17
  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;
18
+ import type { StudyFolder } from "./ports/study-folder.ts";
33
19
 
34
- const ON_DISK: Readonly<Record<DirEntry["kind"], OnDisk>> = {
35
- file: "file",
36
- dir: "directory",
37
- symlink: "symbolic link",
38
- };
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 };
39
25
 
40
26
  /**
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.
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.
44
31
  */
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];
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;
50
43
  };
51
44
 
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
45
  /**
68
46
  * A study whose plan parsed with a mode: its row, the plan's path in its folder, the mode, and the
69
47
  * plan's empty texts — none when it is ready. Only a ready plan's mode decides anything: a draft may
@@ -76,45 +54,54 @@ export interface ModeStudy {
76
54
  readonly empty: readonly PlanTextField[];
77
55
  }
78
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
+
79
63
  /**
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.
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.
83
67
  */
84
- const modeStudyOf = (
85
- paper: string,
68
+ export const readModeStudy = (
86
69
  s: Study,
87
- deps: Pick<StudyDeps, "files" | "codec">,
88
- readable: (folder: string) => boolean,
89
- ): ModeStudy | null => {
70
+ folder: StudyFolder,
71
+ codec: StudyRegistryCodec,
72
+ ): ModeRead => {
90
73
  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);
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);
100
79
  if (parsed.kind === "unreadable" || parsed.plan.mode === undefined)
101
- return null;
80
+ return { kind: "none" };
102
81
  const empty = parsed.kind === "draft" ? parsed.empty : [];
103
- return { row: s, plan, mode: parsed.plan.mode, empty };
82
+ return {
83
+ kind: "mode",
84
+ study: { row: s, plan, mode: parsed.plan.mode, empty },
85
+ };
104
86
  };
105
87
 
106
88
  /**
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.
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.
109
92
  */
110
93
  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
- };
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";
@@ -23,6 +23,7 @@ import {
23
23
  type StudyRegistry,
24
24
  type IndexInput,
25
25
  } from "./domain/study-registry.ts";
26
+ import { planOf } from "../test/study-tree.ts";
26
27
  import {
27
28
  registryOf,
28
29
  STUDY_REGISTRY_RULE_LEVELS,
@@ -366,6 +367,92 @@ describe("repro/study-registered — a study folder that is a symbolic link", ()
366
367
  });
367
368
  });
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
+
369
456
  describe("repro/study-registered — a row whose folder leads outside repro/", () => {
370
457
  it("is reported by name, and nothing is read through it: its plan and result read as missing", () => {
371
458
  const r = P1(