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,56 @@
1
+ /** The captions of a document: the text a reader sees under a figure or a table. */
2
+ import { isDefined } from "remeda";
3
+ import { isDefinition, isUnrendered } from "./hidden.ts";
4
+ import {
5
+ collapse,
6
+ isNode,
7
+ mandatory,
8
+ textOf,
9
+ visited,
10
+ type Macro,
11
+ type Node,
12
+ } from "./nodes.ts";
13
+ import type { ParsedTex } from "./parse.ts";
14
+
15
+ const isCaption = (n: Node): n is Macro =>
16
+ n.type === "macro" && n.content === "caption";
17
+
18
+ /** Math a reader sees as symbols: it counts as one word, and says nothing else. */
19
+ const MATH_NODES: ReadonlySet<Node["type"]> = new Set<Node["type"]>([
20
+ "inlinemath",
21
+ "displaymath",
22
+ "mathenv",
23
+ ]);
24
+ const MATH_WORD = "math";
25
+
26
+ /** The escapes of a character LaTeX sets for itself: `\%` is a percent sign the reader sees. */
27
+ const ESCAPED: ReadonlySet<string> = new Set(["%", "&", "#", "$", "_"]);
28
+
29
+ /** What a reader sees of a caption's nodes: its words, its escapes, and one word for each formula. */
30
+ const captionText = (nodes: readonly Node[]): string =>
31
+ nodes
32
+ .map((n) => {
33
+ if (MATH_NODES.has(n.type)) return MATH_WORD;
34
+ if (n.type === "macro" && ESCAPED.has(n.content)) return n.content;
35
+ return n.type === "group" ? captionText(n.content) : textOf([n]);
36
+ })
37
+ .join("");
38
+
39
+ /**
40
+ * The text of every `\caption` in document order: its last mandatory argument, whatever optional
41
+ * argument (`\caption[Short]{…}`), star or whitespace comes before it. What a reader never sees is
42
+ * pruned (`isUnrendered`: comments, code), and so is a macro definition's body, where the caption
43
+ * is written but not set. A caption with no word in it is left out.
44
+ */
45
+ export function captionsOf(t: ParsedTex): readonly string[] {
46
+ return visited(
47
+ t.root,
48
+ (v, parent) => isUnrendered(v, parent) || isDefinition(v),
49
+ )
50
+ .filter(isNode)
51
+ .filter(isCaption)
52
+ .map((m) => mandatory(m).at(-1))
53
+ .filter(isDefined)
54
+ .map((a) => collapse(captionText(a.content)).trim())
55
+ .filter((text) => text !== "");
56
+ }
@@ -50,6 +50,7 @@ export {
50
50
  textOf,
51
51
  } from "./nodes.ts";
52
52
  export { documentBodyOf, includesOf } from "./includes.ts";
53
+ export { captionsOf } from "./captions.ts";
53
54
  export { headingsOf } from "./headings.ts";
54
55
  export { outlineOf } from "./outline.ts";
55
56
  export { parseLatex, type ParsedTex } from "./parse.ts";
@@ -92,6 +92,12 @@ const DECLARATION_SIGNATURES = Object.fromEntries([
92
92
  ...signed(["linespread", "geometry", "newgeometry"], "m"),
93
93
  ]);
94
94
 
95
+ /**
96
+ * `\caption` takes a star, an optional short caption and the text (`\caption*[Short]{Text}`). unified-latex
97
+ * signs it `o m`, so a starred caption came back with its star as the text and the text as a group beside it.
98
+ */
99
+ const CAPTION_SIGNATURES = Object.fromEntries(signed(["caption"], "s o m"));
100
+
95
101
  /**
96
102
  * Macros that define other macros or environments. A heading in a body is no section where it is
97
103
  * written, so the outline skips these; the rendered text reads a body as written, since nothing here
@@ -133,6 +139,7 @@ const parser = once(() =>
133
139
  ...LINK_SIGNATURES,
134
140
  ...DECLARATION_SIGNATURES,
135
141
  ...DEF_SIGNATURES,
142
+ ...CAPTION_SIGNATURES,
136
143
  },
137
144
  }),
138
145
  );
@@ -7,7 +7,8 @@ import type { DirEntry, ListEntries } from "../../ports/list-entries.ts";
7
7
  /** What a directory entry is, its link never followed. */
8
8
  const kindOf = (d: Readonly<Dirent>): DirEntry["kind"] => {
9
9
  if (d.isSymbolicLink()) return "symlink";
10
- return d.isDirectory() ? "dir" : "file";
10
+ if (d.isDirectory()) return "dir";
11
+ return d.isFile() ? "file" : "other";
11
12
  };
12
13
 
13
14
  export const nodeListEntries: ListEntries = (
@@ -2,6 +2,7 @@
2
2
  * `nodeListEntries`: a directory's entries with their kinds, a symbolic link reported as one and never
3
3
  * followed; none for a missing directory or a file; other errors thrown.
4
4
  */
5
+ import { spawnSync } from "node:child_process";
5
6
  import { mkdirSync, symlinkSync, writeFileSync } from "node:fs";
6
7
  import { join } from "node:path";
7
8
  import { describe, expect, it } from "vitest";
@@ -29,6 +30,23 @@ describe("nodeListEntries", () => {
29
30
  ]);
30
31
  });
31
32
 
33
+ // A FIFO needs `mkfifo`, which Windows does not have; the reason is said, not hidden.
34
+ it.skipIf(process.platform === "win32")(
35
+ "names a FIFO as neither a file nor a directory: other",
36
+ () => {
37
+ const d = join(root, "fifo");
38
+ mkdirSync(d, { recursive: true });
39
+ const made = spawnSync("mkfifo", [join(d, "pipe")]);
40
+ expect(made.status).toBe(0);
41
+ writeFileSync(join(d, "a.md"), "");
42
+ // Guards: it was reported as a file, so a reader would open it and block.
43
+ expect(sorted(d)).toEqual([
44
+ { name: "a.md", kind: "file" },
45
+ { name: "pipe", kind: "other" },
46
+ ]);
47
+ },
48
+ );
49
+
32
50
  it("is empty for a directory that does not exist, and for a file", () => {
33
51
  writeFileSync(join(root, "f"), "");
34
52
  expect([
@@ -180,6 +180,23 @@ it("a decision that names a directory is reported, and no history is judged", ()
180
180
  ]);
181
181
  });
182
182
 
183
+ it("a decision behind a linked directory is reported with the link, and no history is judged", () => {
184
+ // Guards: git records a path through a link as what lies behind the link.
185
+ expect(
186
+ judgeOrder({
187
+ ...STUDY,
188
+ facts: { kind: "linked", link: "repro/P1-S01-x/sub" },
189
+ }),
190
+ ).toEqual([
191
+ {
192
+ problem: "decision-linked",
193
+ id: "P1-S01",
194
+ file: "repro/P1-S01-x/decision.md",
195
+ link: "repro/P1-S01-x/sub",
196
+ },
197
+ ]);
198
+ });
199
+
183
200
  const PAGE =
184
201
  "\nRule repro/outputs-before-decision: https://github.com/zernie/paperlint/blob/main/docs/rules/repro/outputs-before-decision.md";
185
202
 
@@ -192,6 +209,12 @@ const ALL: readonly OrderFinding[] = [
192
209
  glob: "**/*.md",
193
210
  },
194
211
  { problem: "decision-deleted", id: "P1-S01", file: "repro/d.md" },
212
+ {
213
+ problem: "decision-not-file",
214
+ id: "P1-S01",
215
+ file: "repro/p",
216
+ what: "special file",
217
+ },
195
218
  {
196
219
  problem: "decision-not-file",
197
220
  id: "P1-S01",
@@ -204,6 +227,12 @@ const ALL: readonly OrderFinding[] = [
204
227
  file: "repro/l.md",
205
228
  what: "symbolic link",
206
229
  },
230
+ {
231
+ problem: "decision-linked",
232
+ id: "P1-S01",
233
+ file: "repro/sub/d.md",
234
+ link: "repro/sub",
235
+ },
207
236
  { problem: "no-history", id: "P1-S01", why: "git is not installed" },
208
237
  { problem: "shallow", id: "P1-S01" },
209
238
  {
@@ -240,10 +269,14 @@ it("each finding's message: what is wrong, what to do, the rule's page", () => {
240
269
  PAGE,
241
270
  "The decision of `P1-S01`, `repro/d.md`, was committed and then deleted: it is not in HEAD, so the study has no decision its outputs could follow. What to do: restore the decision file as it was decided and commit it — never write a new one after the outputs; if the decision no longer holds, declare the study `exploratory` with the reason." +
242
271
  PAGE,
272
+ "The decision of `P1-S01`, `repro/p`, is a special file, not a file: git tracks only regular files, so no order can be read from it. What to do: name the decision file itself in `mode.decision.file`." +
273
+ PAGE,
243
274
  "The decision of `P1-S01`, `repro/d`, is a directory, not a file: git answers for a directory with the commits of anything under it, so no order can be read from it. What to do: name the decision file itself in `mode.decision.file`." +
244
275
  PAGE,
245
276
  "The decision of `P1-S01`, `repro/l.md`, is a symbolic link, not a file: git records the link, not the file it points to, so no order can be read from it. What to do: name the decision file itself in `mode.decision.file`." +
246
277
  PAGE,
278
+ "The decision of `P1-S01`, `repro/sub/d.md`, passes through the symbolic link `repro/sub`: git records the link, not the files behind it, so no order can be read from it. What to do: name a decision file that lies in real directories in `mode.decision.file`." +
279
+ PAGE,
247
280
  "`P1-S01` is decided before its run, and git's history is the proof of that order, but git could not answer: git is not installed. What to do: lint the paper inside its git repository, with git installed." +
248
281
  PAGE,
249
282
  "`P1-S01` is decided before its run, and this clone is shallow: every file's first commit is the clone's first, so the order of decision and outputs cannot be read. What to do: fetch the whole history (`git fetch --unshallow`; in GitHub Actions, `actions/checkout` with `fetch-depth: 0`)." +
@@ -23,7 +23,7 @@ import {
23
23
  } from "./study-mode.ts";
24
24
 
25
25
  /** What a decision path names on disk when it is no file git can vouch for. */
26
- export type NotAFile = "directory" | "symbolic link";
26
+ export type NotAFile = "directory" | "symbolic link" | "special file";
27
27
 
28
28
  /** A commit, and its committer date (ISO 8601 with its offset). */
29
29
  export interface CommitAt {
@@ -54,6 +54,8 @@ export interface FirstCommit extends CommitAt {
54
54
  */
55
55
  export type OrderFacts =
56
56
  | { readonly kind: "not-a-file"; readonly what: NotAFile }
57
+ /** The decision's path passes through a symbolic link above the file (`link`: the first, relative to the paper). */
58
+ | { readonly kind: "linked"; readonly link: string }
57
59
  | { readonly kind: "unknown"; readonly why: string }
58
60
  /** A shallow clone: every file's first commit is the clone's first, so no order can be read. */
59
61
  | { readonly kind: "shallow" }
@@ -97,6 +99,12 @@ export type OrderFinding =
97
99
  readonly file: string;
98
100
  readonly what: NotAFile;
99
101
  }
102
+ | {
103
+ readonly problem: "decision-linked";
104
+ readonly id: string;
105
+ readonly file: string;
106
+ readonly link: string;
107
+ }
100
108
  | {
101
109
  readonly problem: "no-history";
102
110
  readonly id: string;
@@ -188,6 +196,10 @@ const historyFindings = (s: DecideFirstStudy): readonly OrderFinding[] => {
188
196
  return [
189
197
  { problem: "decision-not-file", id: s.id, file, what: s.facts.what },
190
198
  ];
199
+ case "linked":
200
+ return [
201
+ { problem: "decision-linked", id: s.id, file, link: s.facts.link },
202
+ ];
191
203
  case "unknown":
192
204
  return [{ problem: "no-history", id: s.id, why: s.facts.why }];
193
205
  case "shallow":
@@ -212,6 +224,7 @@ const NOT_A_FILE: Readonly<Record<NotAFile, string>> = {
212
224
  directory:
213
225
  "git answers for a directory with the commits of anything under it",
214
226
  "symbolic link": "git records the link, not the file it points to",
227
+ "special file": "git tracks only regular files",
215
228
  };
216
229
 
217
230
  const short = (c: CommitAt): string => `${c.commit.slice(0, 12)} (${c.date})`;
@@ -225,6 +238,8 @@ const HEADS: {
225
238
  `The decision of \`${f.id}\`, \`${f.file}\`, was committed and then deleted: it is not in HEAD, so the study has no decision its outputs could follow. What to do: restore the decision file as it was decided and commit it — never write a new one after the outputs; if the decision no longer holds, declare the study \`exploratory\` with the reason.`,
226
239
  "decision-not-file": (f) =>
227
240
  `The decision of \`${f.id}\`, \`${f.file}\`, is a ${f.what}, not a file: ${NOT_A_FILE[f.what]}, so no order can be read from it. What to do: name the decision file itself in \`mode.decision.file\`.`,
241
+ "decision-linked": (f) =>
242
+ `The decision of \`${f.id}\`, \`${f.file}\`, passes through the symbolic link \`${f.link}\`: git records the link, not the files behind it, so no order can be read from it. What to do: name a decision file that lies in real directories in \`mode.decision.file\`.`,
228
243
  "no-history": (f) =>
229
244
  `\`${f.id}\` is decided before its run, and git's history is the proof of that order, but git could not answer: ${f.why}. What to do: lint the paper inside its git repository, with git installed.`,
230
245
  shallow: (f) =>
@@ -82,11 +82,11 @@ const NINE_AT: IndexInput = {
82
82
  ]),
83
83
  history: new Map([
84
84
  [
85
- "P1-S01",
85
+ "P1-S01-hint/plan.json",
86
86
  { kind: "read", decision: D, decisionDeleted: false, firstOutput: A },
87
87
  ],
88
88
  [
89
- "P1-S02",
89
+ "P1-S02-s/plan.json",
90
90
  {
91
91
  kind: "read",
92
92
  decision: null,
@@ -94,7 +94,7 @@ const NINE_AT: IndexInput = {
94
94
  firstOutput: null,
95
95
  },
96
96
  ],
97
- ["P1-S04", { kind: "unread", why: "shallow clone" }],
97
+ ["P1-S04-s/plan.json", { kind: "unread", why: "shallow clone" }],
98
98
  ]),
99
99
  };
100
100
 
@@ -124,7 +124,7 @@ describe("the mode columns of the index", () => {
124
124
  plans,
125
125
  history: new Map([
126
126
  [
127
- "P1-S01",
127
+ "P1-S01-s/plan.json",
128
128
  {
129
129
  kind: "read",
130
130
  decision: D,
@@ -151,6 +151,49 @@ describe("the mode columns of the index", () => {
151
151
  });
152
152
  });
153
153
 
154
+ it("two rows of one id each show their own history, keyed by folder and plan", () => {
155
+ const r: StudyRegistry = {
156
+ prefix: "P1",
157
+ studies: [row(1, { folder: "P1-S01-a" }), row(1, { folder: "P1-S01-b" })],
158
+ };
159
+ const at: IndexInput = {
160
+ plans: new Map([
161
+ ["P1-S01-a/plan.json", plan(DECIDED)],
162
+ ["P1-S01-b/plan.json", plan(DECIDED)],
163
+ ]),
164
+ history: new Map([
165
+ [
166
+ "P1-S01-a/plan.json",
167
+ { kind: "read", decision: D, decisionDeleted: false, firstOutput: A },
168
+ ],
169
+ [
170
+ "P1-S01-b/plan.json",
171
+ {
172
+ kind: "read",
173
+ decision: null,
174
+ decisionDeleted: false,
175
+ firstOutput: null,
176
+ },
177
+ ],
178
+ ]),
179
+ };
180
+ // Guards: keyed by id, the second row's history was shown on both rows (the later one wins).
181
+ expect(
182
+ studyIndex(r, at)
183
+ .split("\n")
184
+ .slice(2)
185
+ .map((l) =>
186
+ l
187
+ .split("|")
188
+ .slice(6, 9)
189
+ .map((c) => c.trim()),
190
+ ),
191
+ ).toEqual([
192
+ ["decide_before_run", "dddddddddddd 2026-10-14", "aaaaaaaaaaaa 2026-10-16"],
193
+ ["decide_before_run", "not committed", "none"],
194
+ ]);
195
+ });
196
+
154
197
  it("a decision committed and since deleted shows its commit, and that it is gone", () => {
155
198
  // Guards: the Methods sentence must not cite a decision that no longer stands.
156
199
  expect(
@@ -181,6 +224,7 @@ describe("indexHistory", () => {
181
224
  indexHistory(study({ kind: "unknown", why: "not a repository" })),
182
225
  indexHistory(study({ kind: "shallow" })),
183
226
  indexHistory(study({ kind: "not-a-file", what: "directory" })),
227
+ indexHistory(study({ kind: "linked", link: "repro/sub" })),
184
228
  indexHistory(
185
229
  study({
186
230
  kind: "read",
@@ -204,6 +248,7 @@ describe("indexHistory", () => {
204
248
  { kind: "unread", why: "git could not answer" },
205
249
  { kind: "unread", why: "shallow clone" },
206
250
  { kind: "unread", why: "the decision is a directory" },
251
+ { kind: "unread", why: "the decision is behind a symbolic link" },
207
252
  { kind: "read", decision: D, decisionDeleted: false, firstOutput: A },
208
253
  {
209
254
  kind: "read",
@@ -85,6 +85,8 @@ export const indexHistory = (s: DecideFirstStudy): IndexHistory => {
85
85
  switch (s.facts.kind) {
86
86
  case "not-a-file":
87
87
  return { kind: "unread", why: `the decision is a ${s.facts.what}` };
88
+ case "linked":
89
+ return { kind: "unread", why: "the decision is behind a symbolic link" };
88
90
  case "unknown":
89
91
  return { kind: "unread", why: "git could not answer" };
90
92
  case "shallow":
@@ -22,6 +22,7 @@ import {
22
22
  type StudyPlan,
23
23
  type StudyRegistry,
24
24
  type IndexInput,
25
+ folderKey,
25
26
  } from "./study-registry.ts";
26
27
 
27
28
  /** No plan and no history: the index with every mode column `—`. */
@@ -110,10 +111,10 @@ const input = (
110
111
  folders: r.studies.map((s) => s.folder),
111
112
  registry: { kind: "read", registry: r },
112
113
  readme: withIndex("# Reproduction index\n", r, NO_FACTS),
113
- existingResults: new Set(),
114
+ results: new Map(),
114
115
  plans: new Map(
115
116
  r.studies.flatMap((s) =>
116
- s.plan === undefined ? [] : [[`${s.folder}/${s.plan}`, read(PLAN)]],
117
+ s.plan === undefined ? [] : [[folderKey(s.folder, s.plan), read(PLAN)]],
117
118
  ),
118
119
  ),
119
120
  ...over,
@@ -385,7 +386,10 @@ describe("plans — the parser decides ready or draft", () => {
385
386
  });
386
387
  const plans = new Map([[`P1-S01-census/${PLAN_FILE}`, read(PLAN)]]);
387
388
  expect(
388
- findings(r, { plans, existingResults: new Set(["P1-S01-census/r.tsv"]) }),
389
+ findings(r, {
390
+ plans,
391
+ results: new Map([["P1-S01-census/r.tsv", { kind: "file" as const }]]),
392
+ }),
389
393
  ).toEqual([]);
390
394
  });
391
395
 
@@ -19,6 +19,7 @@ import {
19
19
  studyIndex,
20
20
  studyMessage,
21
21
  studyReadme,
22
+ studyById,
22
23
  studySlug,
23
24
  withIndex,
24
25
  type RegistryState,
@@ -61,7 +62,7 @@ const input = (
61
62
  folders: r.studies.map((s) => s.folder),
62
63
  registry: read(r),
63
64
  readme: withIndex("# Reproduction index\n", r, NO_FACTS),
64
- existingResults: new Set(),
65
+ results: new Map(),
65
66
  plans: new Map(),
66
67
  ...over,
67
68
  });
@@ -88,7 +89,9 @@ describe("judgeStudies — silent", () => {
88
89
  expect(
89
90
  judgeStudies(
90
91
  input(r, {
91
- existingResults: new Set(["P1-S02-ablation/out/results.tsv"]),
92
+ results: new Map([
93
+ ["P1-S02-ablation/out/results.tsv", { kind: "file" as const }],
94
+ ]),
92
95
  }),
93
96
  ),
94
97
  ).toEqual([]);
@@ -100,7 +103,7 @@ describe("judgeStudies — silent", () => {
100
103
  folders: [],
101
104
  registry: { kind: "absent" },
102
105
  readme: null,
103
- existingResults: new Set(),
106
+ results: new Map(),
104
107
  plans: new Map(),
105
108
  }),
106
109
  ).toEqual([]);
@@ -129,7 +132,7 @@ describe("judgeStudies — the registry itself", () => {
129
132
  folders: ["a-2026-10-01", "b-2026-10-02"],
130
133
  registry: { kind: "absent" },
131
134
  readme: null,
132
- existingResults: new Set(),
135
+ results: new Map(),
133
136
  plans: new Map(),
134
137
  }),
135
138
  ).toEqual([
@@ -143,7 +146,7 @@ describe("judgeStudies — the registry itself", () => {
143
146
  folders: ["a-2026-10-01"],
144
147
  registry: { kind: "unreadable", why: "prefix: required" },
145
148
  readme: null,
146
- existingResults: new Set(),
149
+ results: new Map(),
147
150
  plans: new Map(),
148
151
  }),
149
152
  ).toEqual([{ problem: "unreadable", why: "prefix: required" }]);
@@ -259,7 +262,11 @@ describe("judgeStudies — status", () => {
259
262
  const r = reg(study({ status: "running", result_file: "r.tsv" }));
260
263
  expect(
261
264
  judgeStudies(
262
- input(r, { existingResults: new Set(["P1-S01-baseline/r.tsv"]) }),
265
+ input(r, {
266
+ results: new Map([
267
+ ["P1-S01-baseline/r.tsv", { kind: "file" as const }],
268
+ ]),
269
+ }),
263
270
  ),
264
271
  ).toEqual([]);
265
272
  });
@@ -671,3 +678,24 @@ describe("studyReadme", () => {
671
678
  );
672
679
  });
673
680
  });
681
+
682
+ describe("studyById — the one way to address a study by its id", () => {
683
+ const a = study({ id: "P1-S01", folder: "P1-S01-a" });
684
+ const b = study({ id: "P1-S01", folder: "P1-S01-b" });
685
+ const c = study({ id: "P1-S02", folder: "P1-S02-c" });
686
+
687
+ it("exactly one row: that row; no row: none; two or more: all of them, in registry order", () => {
688
+ // Guards: a caller can take a row only from `one`, never "the first" of an ambiguous id.
689
+ expect([
690
+ studyById([a, c], "P1-S01"),
691
+ studyById([a, c], "P1-S09"),
692
+ studyById([], "P1-S01"),
693
+ studyById([a, c, b], "P1-S01"),
694
+ ]).toEqual([
695
+ { kind: "one", row: a },
696
+ { kind: "none" },
697
+ { kind: "none" },
698
+ { kind: "ambiguous", rows: [a, b] },
699
+ ]);
700
+ });
701
+ });