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
@@ -0,0 +1,237 @@
1
+ /**
2
+ * The one reader of what lies inside a study's folder (`src/study-folder.ts`), over a disk held in
3
+ * memory whose listing marks chosen paths as symbolic links: a file is read, a missing path is absent,
4
+ * and a path that passes through a link — at the file or at any directory above it — is `linked`,
5
+ * naming the link, with nothing read.
6
+ */
7
+ import { expect, it } from "vitest";
8
+ import { memoryFiles, memoryListEntries } from "./adapters/memory/index.ts";
9
+ import { folderRefusalText, studyFolder } from "./study-folder.ts";
10
+
11
+ const PAPER = "/work/papers/example";
12
+ const FOLDER = `${PAPER}/repro/P1-S01-outcome`;
13
+ const TREE = {
14
+ [`${FOLDER}/plan.json`]: "{}",
15
+ [`${FOLDER}/sub/plan.json`]: "{ }",
16
+ [`${FOLDER}/sub/deeper/out.tsv`]: "x\n",
17
+ [`${FOLDER}/other/note.md`]: "n\n",
18
+ [`${PAPER}/repro/P1-S01-linked/plan.json`]: "{}",
19
+ };
20
+
21
+ /** The reader over `TREE`, `links` and `others` being absolute paths the listing reports as links and as neither file nor directory (a FIFO); plus every path the disk was asked for. */
22
+ const reader = (links: readonly string[], others: readonly string[] = []) => {
23
+ const files = memoryFiles(TREE);
24
+ const asked: string[] = [];
25
+ return {
26
+ asked,
27
+ sf: studyFolder(PAPER, {
28
+ files: {
29
+ ...files,
30
+ readBytes: (p) => {
31
+ asked.push(p);
32
+ return files.readBytes(p);
33
+ },
34
+ isFile: (p) => {
35
+ asked.push(p);
36
+ return files.isFile(p);
37
+ },
38
+ },
39
+ entries: (dir) =>
40
+ memoryListEntries(files)(dir).map((e) => {
41
+ if (links.includes(`${dir}/${e.name}`))
42
+ return { ...e, kind: "symlink" as const };
43
+ return others.includes(`${dir}/${e.name}`)
44
+ ? { ...e, kind: "other" as const }
45
+ : e;
46
+ }),
47
+ }),
48
+ };
49
+ };
50
+
51
+ it("reads a file inside the folder; a path that is nothing, a directory, or under a file is absent", () => {
52
+ const { sf } = reader([]);
53
+ // Guards: the plain cases, so that `linked` below is the link's doing and not a broken reader.
54
+ expect([
55
+ sf.read("P1-S01-outcome", "plan.json"),
56
+ sf.read("P1-S01-outcome", "./sub/plan.json"),
57
+ sf.read("P1-S01-outcome", "nope.json"),
58
+ sf.read("P1-S01-outcome", "sub"),
59
+ sf.read("P1-S01-outcome", "plan.json/x"),
60
+ sf.read("P1-S01-outcome", "sub/missing/plan.json"),
61
+ ]).toEqual([
62
+ { kind: "text", text: "{}" },
63
+ { kind: "text", text: "{ }" },
64
+ { kind: "absent" },
65
+ { kind: "absent" },
66
+ { kind: "absent" },
67
+ { kind: "absent" },
68
+ ]);
69
+ });
70
+
71
+ it("a link at the file, or at any directory above it, is `linked` and names the link; nothing is read", () => {
72
+ const { sf, asked } = reader([
73
+ `${FOLDER}/plan.json`,
74
+ `${FOLDER}/sub`,
75
+ `${FOLDER}/other/note.md`,
76
+ ]);
77
+ // Guards: the file behind a link is never asked for, wherever on the path the link stands; the
78
+ // link is named relative to the folder, as far down as the walk got.
79
+ expect([
80
+ sf.read("P1-S01-outcome", "plan.json"),
81
+ sf.read("P1-S01-outcome", "sub/plan.json"),
82
+ sf.read("P1-S01-outcome", "sub/deeper/out.tsv"),
83
+ sf.read("P1-S01-outcome", "other/note.md"),
84
+ ]).toEqual([
85
+ { kind: "linked", link: "plan.json" },
86
+ { kind: "linked", link: "sub" },
87
+ { kind: "linked", link: "sub" },
88
+ { kind: "linked", link: "other/note.md" },
89
+ ]);
90
+ expect(asked).toEqual([]);
91
+ });
92
+
93
+ it("`locate` says what is there without reading it", () => {
94
+ const { sf, asked } = reader([`${FOLDER}/sub`]);
95
+ // Guards: the answer for a result file's existence needs no read of its bytes.
96
+ expect([
97
+ sf.locate("P1-S01-outcome", "plan.json"),
98
+ sf.locate("P1-S01-outcome", "sub/deeper/out.tsv"),
99
+ sf.locate("P1-S01-outcome", "nope"),
100
+ ]).toEqual([
101
+ { kind: "file" },
102
+ { kind: "linked", link: "sub" },
103
+ { kind: "absent" },
104
+ ]);
105
+ expect(asked).toEqual([]);
106
+ });
107
+
108
+ it("reads nothing outside the folder: a NUL, a path up and out, a folder name that is not one folder, a folder that is a link", () => {
109
+ const { sf, asked } = reader([`${PAPER}/repro/P1-S01-outcome`]);
110
+ const open = reader([]).sf;
111
+ // Guards: the reader itself holds the containment the callers rely on — the folder that is a link
112
+ // reads as absent (not `linked`), as the registry rule's page says.
113
+ expect([
114
+ open.read("P1-S01-outcome", "a\0b"),
115
+ open.read("P1-S01-outcome", "../x/plan.json"),
116
+ open.read("../x", "plan.json"),
117
+ open.read("a/b", "plan.json"),
118
+ sf.read("P1-S01-outcome", "plan.json"),
119
+ ]).toEqual(Array(5).fill({ kind: "absent" }));
120
+ expect(asked).toEqual([]);
121
+ });
122
+
123
+ it("a file that vanishes between the walk and the read is absent", () => {
124
+ const files = memoryFiles(TREE);
125
+ const sf = studyFolder(PAPER, {
126
+ files: { ...files, isFile: () => false },
127
+ entries: memoryListEntries(files),
128
+ });
129
+ // Guards: the reader never throws on a race; it says there is nothing.
130
+ expect(sf.read("P1-S01-outcome", "plan.json")).toEqual({ kind: "absent" });
131
+ });
132
+
133
+ it("a link is named as far down as the walk got, even when its name repeats above it", () => {
134
+ const files = memoryFiles({ [`${FOLDER}/a/a/x.txt`]: "x\n" });
135
+ const sf = studyFolder(PAPER, {
136
+ files,
137
+ entries: (dir) =>
138
+ memoryListEntries(files)(dir).map((e) =>
139
+ `${dir}/${e.name}` === `${FOLDER}/a/a`
140
+ ? { ...e, kind: "symlink" as const }
141
+ : e,
142
+ ),
143
+ });
144
+ // Guards: the second `a` is the link, not the first.
145
+ expect(sf.read("P1-S01-outcome", "a/a/x.txt")).toEqual({
146
+ kind: "linked",
147
+ link: "a/a",
148
+ });
149
+ });
150
+
151
+ it("a row's folder may be entered unless it is no one folder inside repro/ or is a link", () => {
152
+ const { sf } = reader([`${PAPER}/repro/P1-S01-linked`]);
153
+ // Guards: the one place the folder check lives; each refusal is its own reason.
154
+ expect([
155
+ sf.refusal("P1-S01-outcome"),
156
+ sf.refusal("../x"),
157
+ sf.refusal("a/b"),
158
+ sf.refusal("P1-S01-linked"),
159
+ ]).toEqual([null, "not-a-folder", "not-a-folder", "symbolic-link"]);
160
+ expect([
161
+ folderRefusalText("not-a-folder", "p"),
162
+ folderRefusalText("symbolic-link", "p"),
163
+ ]).toEqual([
164
+ {
165
+ reason: "is not one folder inside p/repro/",
166
+ fix: "Give the row its own folder's name",
167
+ },
168
+ {
169
+ reason: "is a symbolic link",
170
+ fix: "Give the row a real folder inside p/repro/",
171
+ },
172
+ ]);
173
+ });
174
+
175
+ it("a path in the paper is a file, a directory, nothing, or a link on the way — the first one, from the paper's root", () => {
176
+ const { sf, asked } = reader([`${FOLDER}/sub`, `${FOLDER}/plan.json`]);
177
+ const at = (p: string) => sf.entryAt(p);
178
+ // Guards: the decision file is judged by every component of its path, not by its last one.
179
+ expect([
180
+ at("repro/P1-S01-outcome/other/note.md"),
181
+ at("repro/P1-S01-outcome/other"),
182
+ at("repro/P1-S01-outcome/nope.md"),
183
+ at("repro/P1-S01-outcome/sub/plan.json"),
184
+ at("repro/P1-S01-outcome/plan.json"),
185
+ at("../outside.md"),
186
+ at("a\0b"),
187
+ ]).toEqual([
188
+ { kind: "file" },
189
+ { kind: "dir" },
190
+ { kind: "absent" },
191
+ { kind: "linked", link: "repro/P1-S01-outcome/sub" },
192
+ { kind: "linked", link: "repro/P1-S01-outcome/plan.json" },
193
+ { kind: "absent" },
194
+ { kind: "absent" },
195
+ ]);
196
+ expect(asked).toEqual([]);
197
+ });
198
+
199
+ it("lists every file under a study's folder, relative and /-separated; a link is one entry, never descended into", () => {
200
+ const { sf, asked } = reader([`${FOLDER}/sub`, `${FOLDER}/other/note.md`]);
201
+ // Guards: the outputs of a study are listed by the same reader that reads its plan — a linked
202
+ // directory is a file as git stores it, and what lies behind it is not the study's.
203
+ expect(sf.list("P1-S01-outcome")).toEqual([
204
+ "plan.json",
205
+ "sub",
206
+ "other/note.md",
207
+ ]);
208
+ expect(asked).toEqual([]);
209
+ });
210
+
211
+ it("lists nothing of a folder that may not be entered or is not there", () => {
212
+ const { sf } = reader([`${PAPER}/repro/P1-S01-linked`]);
213
+ // Guards: a linked study folder brings no foreign file into the study, nor does a name outside repro/.
214
+ expect([
215
+ sf.list("P1-S01-linked"),
216
+ sf.list("../x"),
217
+ sf.list("P1-S09-missing"),
218
+ ]).toEqual([[], [], []]);
219
+ });
220
+
221
+ it("something that is not a regular file — a FIFO, a socket — is absent as a plan or a result, other as a path, and still listed by name", () => {
222
+ const { sf, asked } = reader([], [`${FOLDER}/plan.json`]);
223
+ // Guards: the listing used to call every such entry a file, so the reader would open it (a FIFO
224
+ // blocks) and report it present.
225
+ expect([
226
+ sf.locate("P1-S01-outcome", "plan.json"),
227
+ sf.read("P1-S01-outcome", "plan.json"),
228
+ sf.entryAt("repro/P1-S01-outcome/plan.json"),
229
+ sf.list("P1-S01-outcome"),
230
+ ]).toEqual([
231
+ { kind: "absent" },
232
+ { kind: "absent" },
233
+ { kind: "other" },
234
+ ["plan.json", "sub/plan.json", "sub/deeper/out.tsv", "other/note.md"],
235
+ ]);
236
+ expect(asked).toEqual([]);
237
+ });
@@ -0,0 +1,176 @@
1
+ /**
2
+ * The one reader of what lies inside a study's folder, `repro/<folder>/<path>`, and of the decision
3
+ * file that lies elsewhere in the paper. It never follows a symbolic link: a path is walked one
4
+ * component at a time by the entries of each directory (`ListEntries`, which does not follow links),
5
+ * and a path that passes through a link — the file itself, or any directory above it — is `linked`,
6
+ * naming the link, before a single byte is read. Every reader of a study takes a `StudyFolder` and
7
+ * not `Files`, so a read that follows a link is not what those readers are handed.
8
+ *
9
+ * What goes through it, and who builds it (once per run, from the paper's folder):
10
+ * - the plan of a row: `repro/study-registered`, `repro/outputs-before-decision`, `studies gate`
11
+ * and the index's mode columns;
12
+ * - the result file of a row: `repro/study-registered`;
13
+ * - whether a row's folder may be entered at all (`refusal`): `studies gate`, `studies index|new`;
14
+ * - the decision file's path (`entryAt`): the gate and the order rule.
15
+ * The outputs of a study are listed through it too (`list`).
16
+ * A study folder that is itself a link, or a folder name that is not one folder inside `repro/`, is
17
+ * never entered: its files read as `absent`.
18
+ *
19
+ * A link made between the walk and the read is not caught: paperlint guards against accidents, not
20
+ * against an author who races their own tooling (docs/design/study-modes.md, the threat model).
21
+ */
22
+ import { join, posix } from "node:path";
23
+ import { callerPath } from "./caller-path.ts";
24
+ import { REPRO_DIR } from "./domain/repro-layout.ts";
25
+ import {
26
+ isFolderName,
27
+ isInside,
28
+ type Located,
29
+ } from "./domain/study-registry.ts";
30
+ import type { Files } from "./ports/files.ts";
31
+ import type { DirEntry, ListEntries } from "./ports/list-entries.ts";
32
+ import type {
33
+ FolderRefusal,
34
+ PathEntry,
35
+ StudyFolder,
36
+ } from "./ports/study-folder.ts";
37
+
38
+ /** What a refusal says of the folder a row names: `which <reason>`, and the fix without its full stop. */
39
+ export const folderRefusalText = (
40
+ why: FolderRefusal,
41
+ shown: string,
42
+ ): { readonly reason: string; readonly fix: string } =>
43
+ why === "not-a-folder"
44
+ ? {
45
+ reason: `is not one folder inside ${shown}/${REPRO_DIR}/`,
46
+ fix: "Give the row its own folder's name",
47
+ }
48
+ : {
49
+ reason: "is a symbolic link",
50
+ fix: `Give the row a real folder inside ${shown}/${REPRO_DIR}/`,
51
+ };
52
+
53
+ /** What the entry `name` of the directory `dir` is; undefined when there is none. */
54
+ const kindAt = (
55
+ entries: ListEntries,
56
+ dir: string,
57
+ name: string,
58
+ ): DirEntry["kind"] | undefined =>
59
+ entries(callerPath(dir)).find((e) => e.name === name)?.kind;
60
+
61
+ const ENTRY: Readonly<Record<Exclude<DirEntry["kind"], "symlink">, PathEntry>> =
62
+ {
63
+ file: { kind: "file" },
64
+ dir: { kind: "dir" },
65
+ other: { kind: "other" },
66
+ };
67
+
68
+ /**
69
+ * What the entry `name` below `dir`/`above` is: every component on the way is looked up in its
70
+ * directory's entries, and the first that is a symbolic link ends the walk (an entry under a
71
+ * directory that is not there is not listed, so it can be no link).
72
+ */
73
+ const walk = (
74
+ dir: string,
75
+ above: readonly string[],
76
+ name: string,
77
+ entries: ListEntries,
78
+ ): PathEntry => {
79
+ const path = [...above, name];
80
+ const at = path.findIndex(
81
+ (c, i) => kindAt(entries, join(dir, ...path.slice(0, i)), c) === "symlink",
82
+ );
83
+ if (at >= 0) return { kind: "linked", link: path.slice(0, at + 1).join("/") };
84
+ const kind = kindAt(entries, join(dir, ...above), name);
85
+ return kind === undefined || kind === "symlink"
86
+ ? { kind: "absent" }
87
+ : ENTRY[kind];
88
+ };
89
+
90
+ /** `path` as the directories above its last component, and that component. */
91
+ const split = (
92
+ path: string,
93
+ ): {
94
+ readonly above: readonly string[];
95
+ readonly name: string;
96
+ } => {
97
+ const normal = posix.normalize(path);
98
+ return {
99
+ above: posix
100
+ .dirname(normal)
101
+ .split("/")
102
+ .filter((c) => c !== "."),
103
+ name: posix.basename(normal),
104
+ };
105
+ };
106
+
107
+ /** What `path` in `folder` is, by the entries of the paper folder `paper`; nothing is read. */
108
+ const locateIn = (
109
+ paper: string,
110
+ entries: ListEntries,
111
+ folder: string,
112
+ path: string,
113
+ ): Located => {
114
+ // A NUL is no path the disk can hold, and probing one throws; the rest never leaves the folder.
115
+ if (!isFolderName(folder) || !isInside(path) || path.includes("\0"))
116
+ return { kind: "absent" };
117
+ const { above, name } = split(path);
118
+ // The folder is walked too: one that is a link is never entered, and reads as absent.
119
+ const found = walk(join(paper, REPRO_DIR), [folder, ...above], name, entries);
120
+ // A directory or a special file is no file to read: it reads as nothing.
121
+ if (found.kind === "dir" || found.kind === "other") return { kind: "absent" };
122
+ if (found.kind !== "linked") return found;
123
+ return found.link === folder
124
+ ? { kind: "absent" }
125
+ : { kind: "linked", link: found.link.slice(folder.length + 1) };
126
+ };
127
+
128
+ /** Why `folder` may not be entered in the paper folder `paper`; null when it may. */
129
+ const refusalOf = (
130
+ paper: string,
131
+ entries: ListEntries,
132
+ folder: string,
133
+ ): FolderRefusal | null => {
134
+ if (!isFolderName(folder)) return "not-a-folder";
135
+ return kindAt(entries, join(paper, REPRO_DIR), folder) === "symlink"
136
+ ? "symbolic-link"
137
+ : null;
138
+ };
139
+
140
+ /** Every file under `dir`, relative to it; a link is a file, and is not entered. */
141
+ const filesUnder = (
142
+ dir: string,
143
+ entries: ListEntries,
144
+ prefix = "",
145
+ ): readonly string[] =>
146
+ entries(callerPath(dir)).flatMap(({ name, kind }) => {
147
+ const rel = prefix === "" ? name : `${prefix}/${name}`;
148
+ return kind === "dir" ? filesUnder(join(dir, name), entries, rel) : [rel];
149
+ });
150
+
151
+ /** The reader of the study folders of the paper folder `paper`. */
152
+ export const studyFolder = (
153
+ paper: string,
154
+ deps: { readonly files: Files; readonly entries: ListEntries },
155
+ ): StudyFolder => ({
156
+ refusal: (folder) => refusalOf(paper, deps.entries, folder),
157
+ locate: (folder, path) => locateIn(paper, deps.entries, folder, path),
158
+ read: (folder, path) => {
159
+ const at = locateIn(paper, deps.entries, folder, path);
160
+ if (at.kind !== "file") return at;
161
+ const p = callerPath(join(paper, REPRO_DIR, folder, path));
162
+ const bytes = deps.files.isFile(p) ? deps.files.readBytes(p) : null;
163
+ return bytes === null
164
+ ? { kind: "absent" }
165
+ : { kind: "text", text: new TextDecoder().decode(bytes) };
166
+ },
167
+ list: (folder) =>
168
+ refusalOf(paper, deps.entries, folder) === null
169
+ ? filesUnder(join(paper, REPRO_DIR, folder), deps.entries)
170
+ : [],
171
+ entryAt: (path) => {
172
+ if (!isInside(path) || path.includes("\0")) return { kind: "absent" };
173
+ const { above, name } = split(path);
174
+ return walk(paper, above, name, deps.entries);
175
+ },
176
+ });
@@ -5,11 +5,13 @@
5
5
  */
6
6
  import { expect, it } from "vitest";
7
7
  import type { StudyMode } from "./domain/study-mode.ts";
8
+ import { planText, registryText } from "./domain/study-registry.ts";
8
9
  import {
9
10
  DATE,
10
11
  DECIDED,
11
12
  DIR,
12
13
  ROW,
14
+ S,
13
15
  SHA,
14
16
  planOf,
15
17
  studies,
@@ -120,7 +122,7 @@ it("refuses a study that declares no mode, and one whose row names a folder outs
120
122
  [
121
123
  1,
122
124
  [
123
- "P1-S01 declares no mode in papers/example: no row of papers/example/repro/studies.json with this id names a plan.json that parses with `mode`. Lint the paper (`npx paperlint lint`) to see why.",
125
+ "P1-S01 declares no mode in papers/example: the plan its row names in papers/example/repro/studies.json is missing, does not parse, or has no `mode`. Lint the paper (`npx paperlint lint`) to see why.",
124
126
  ],
125
127
  ],
126
128
  [
@@ -158,16 +160,56 @@ it("refuses a command line without the study's id, or with an argument too many"
158
160
  it("a plan it cannot read through declares no mode: no registry, a plan outside its folder, a NUL, not JSON", () => {
159
161
  const noMode = (t: Readonly<Record<string, string>>) =>
160
162
  studies("gate", t).err[0]?.split(":")[0];
161
- const withoutRegistry = Object.fromEntries(
162
- Object.entries(decided()).filter(([k]) => !k.endsWith("studies.json")),
163
- );
164
163
  // Guards: each is refused as declaring no mode — none is read past, none opens the gate.
165
164
  expect([
166
- noMode(withoutRegistry),
167
165
  noMode(tree({}, planOf(DECIDED), { ...ROW, plan: "../plan.json" })),
168
166
  noMode(tree({}, planOf(DECIDED), { ...ROW, plan: "a\0b.json" })),
169
167
  noMode({ ...decided(), [`${DIR}/repro/P1-S01-outcome/plan.json`]: "{" }),
170
- ]).toEqual(Array(4).fill("P1-S01 declares no mode in papers/example"));
168
+ ]).toEqual(Array(3).fill("P1-S01 declares no mode in papers/example"));
169
+ });
170
+
171
+ it("refuses when the registry is missing or does not parse, naming the file and the way to see why", () => {
172
+ const withoutRegistry = Object.fromEntries(
173
+ Object.entries(decided()).filter(([k]) => !k.endsWith("studies.json")),
174
+ );
175
+ const unparseable = {
176
+ ...decided(),
177
+ [`${DIR}/repro/studies.json`]: "{ not json",
178
+ };
179
+ // Guards: "no row carries this id" would send a person to the registry that is the problem.
180
+ expect(
181
+ [withoutRegistry, unparseable].map((t) => {
182
+ const r = studies("gate", t);
183
+ return [r.code, r.err];
184
+ }),
185
+ ).toEqual([
186
+ [
187
+ 1,
188
+ [
189
+ "P1-S01: the registry papers/example/repro/studies.json is missing, so no row can carry this id. Run `npx paperlint lint` to see why, then run this again.",
190
+ ],
191
+ ],
192
+ [
193
+ 1,
194
+ [
195
+ expect.stringMatching(
196
+ /^P1-S01: the registry papers\/example\/repro\/studies\.json does not parse \(.+\), so no row can carry this id\. Run `npx paperlint lint` to see why, then run this again\.$/u,
197
+ ),
198
+ ],
199
+ ],
200
+ ]);
201
+ });
202
+
203
+ it("refuses an id no row of the registry carries, saying so and what to do: exit 1", () => {
204
+ const r = studies("gate", decided(), { title: "P1-S09" });
205
+ // Guards: an id that is not in the registry is not a study that "declares no mode".
206
+ expect([r.code, r.log, r.err]).toEqual([
207
+ 1,
208
+ [],
209
+ [
210
+ "P1-S09: no row of papers/example/repro/studies.json carries this id. Use an id from the registry, or register the study with `npx paperlint studies new`, then run this again.",
211
+ ],
212
+ ]);
171
213
  });
172
214
 
173
215
  it("refuses a decision that names a directory, whatever git says of it", () => {
@@ -287,7 +329,7 @@ it("refuses a decision or a study folder that is a symbolic link", () => {
287
329
  [
288
330
  1,
289
331
  [
290
- "P1-S01: its row names the folder `P1-S01-outcome`, which is not one folder inside papers/example/repro/; nothing is read through it. Give the row its own folder's name.",
332
+ "P1-S01: its row names the folder `P1-S01-outcome`, which is a symbolic link; nothing is read through it. Give the row a real folder inside papers/example/repro/.",
291
333
  ],
292
334
  ],
293
335
  ]);
@@ -314,3 +356,112 @@ it("never reads a plan through a backslash: `..\\x\\plan.json` is outside the fo
314
356
  "P1-S01 declares no mode in papers/example",
315
357
  );
316
358
  });
359
+
360
+ it("refuses an id that two rows share, even when the first row is exploratory: exit 1, naming both folders", () => {
361
+ const second = { ...ROW, folder: "P1-S01-again" };
362
+ const t = {
363
+ ...tree(
364
+ {
365
+ [`${DIR}/repro/P1-S01-again/plan.json`]: JSON.stringify(
366
+ planOf(DECIDED),
367
+ ),
368
+ },
369
+ planOf({ kind: "exploratory", reason: "a first look" }),
370
+ ),
371
+ [`${DIR}/repro/studies.json`]: registryText({
372
+ prefix: "P1",
373
+ studies: [ROW, second],
374
+ }),
375
+ };
376
+ const r = studies("gate", t);
377
+ // Guards: `gate && run` must not start on the first row's mode while a later row of the same id
378
+ // decides before its run; lint need not have run first.
379
+ expect([r.code, r.log, r.err]).toEqual([
380
+ 1,
381
+ [],
382
+ [
383
+ "P1-S01: 2 rows of papers/example/repro/studies.json carry this id (folders `P1-S01-outcome`, `P1-S01-again`); 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.",
384
+ ],
385
+ ]);
386
+ });
387
+
388
+ it("refuses a plan that is a symbolic link, or sits under a linked directory, naming the link: exit 1", () => {
389
+ const exploratory = planOf({ kind: "exploratory", reason: "a first look" });
390
+ const asLink = studies(
391
+ "gate",
392
+ tree({}, exploratory),
393
+ {},
394
+ { links: [`${S}/plan.json`] },
395
+ );
396
+ const sub = { ...ROW, plan: "sub/plan.json" };
397
+ const underLink = studies(
398
+ "gate",
399
+ tree({ [`${S}/sub/plan.json`]: planText(exploratory) }, exploratory, sub),
400
+ {},
401
+ { links: [`${S}/sub`] },
402
+ );
403
+ // Guards: a plan read through a link would open the gate on a file git does not store as the
404
+ // study's; the refusal names the row, the plan, the link and the fix. The files behind the link
405
+ // are ready exploratory plans, so a read through it would exit 0.
406
+ expect([asLink, underLink].map((r) => [r.code, r.log, r.err])).toEqual([
407
+ [
408
+ 1,
409
+ [],
410
+ [
411
+ "P1-S01: the plan its row names, in papers/example/repro/P1-S01-outcome, passes through the symbolic link `plan.json`; nothing is read through a link. Replace the link with the plan file itself, then run this again.",
412
+ ],
413
+ ],
414
+ [
415
+ 1,
416
+ [],
417
+ [
418
+ "P1-S01: the plan its row names, in papers/example/repro/P1-S01-outcome, passes through the symbolic link `sub`; nothing is read through a link. Replace the link with the plan file itself, then run this again.",
419
+ ],
420
+ ],
421
+ ]);
422
+ });
423
+
424
+ it("refuses a decision under a linked directory, naming the link: git cannot vouch for what lies behind it", () => {
425
+ const file = "repro/P1-S01-outcome/sub/decision.md";
426
+ const r = studies(
427
+ "gate",
428
+ tree(
429
+ { [`${DIR}/${file}`]: "exclude run 7\n" },
430
+ planOf({ ...DECIDED, decision: { file, note: "n" } }),
431
+ ),
432
+ {},
433
+ {
434
+ git: {
435
+ [`${DIR}/${file}`]: { kind: "committed", commit: SHA, date: DATE },
436
+ },
437
+ links: [`${S}/sub`],
438
+ },
439
+ );
440
+ // Guards: only the last component of the decision path was looked at, so a linked directory above
441
+ // it passed and git was asked about a path it stores as a link's target.
442
+ expect([r.code, r.err]).toEqual([
443
+ 1,
444
+ [
445
+ `P1-S01: its decision ${file} passes through the symbolic link \`repro/P1-S01-outcome/sub\`; 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\`.`,
446
+ ],
447
+ ]);
448
+ });
449
+
450
+ it("refuses a decision that is a FIFO or another special file: git tracks no such thing", () => {
451
+ const r = studies(
452
+ "gate",
453
+ { ...decided(), [DECISION]: "" },
454
+ {},
455
+ {
456
+ git: { [DECISION]: { kind: "committed", commit: SHA, date: DATE } },
457
+ others: [DECISION],
458
+ },
459
+ );
460
+ // Guards: a decision the listing called a file passed, and git was asked about it.
461
+ expect([r.code, r.err]).toEqual([
462
+ 1,
463
+ [
464
+ "P1-S01: its decision repro/P1-S01-outcome/decision.md is a special file, not a file; the run cannot be gated on it. Name the decision file in `mode.decision.file`.",
465
+ ],
466
+ ]);
467
+ });