paperlint 10.1.0 → 10.2.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 (72) hide show
  1. package/README.md +2 -0
  2. package/dist/adapters/study-registry/index.d.ts +6 -0
  3. package/dist/adapters/study-registry/index.d.ts.map +1 -0
  4. package/dist/adapters/study-registry/index.js +58 -0
  5. package/dist/adapters/study-registry/index.js.map +1 -0
  6. package/dist/cli.d.ts.map +1 -1
  7. package/dist/cli.js +40 -2
  8. package/dist/cli.js.map +1 -1
  9. package/dist/domain/repro-layout.d.ts +12 -4
  10. package/dist/domain/repro-layout.d.ts.map +1 -1
  11. package/dist/domain/repro-layout.js +49 -18
  12. package/dist/domain/repro-layout.js.map +1 -1
  13. package/dist/domain/scaffold-layout.d.ts +4 -3
  14. package/dist/domain/scaffold-layout.d.ts.map +1 -1
  15. package/dist/domain/scaffold-layout.js +7 -4
  16. package/dist/domain/scaffold-layout.js.map +1 -1
  17. package/dist/domain/study-registry.d.ts +176 -0
  18. package/dist/domain/study-registry.d.ts.map +1 -0
  19. package/dist/domain/study-registry.js +371 -0
  20. package/dist/domain/study-registry.js.map +1 -0
  21. package/dist/ports/study-registry-codec.d.ts +11 -0
  22. package/dist/ports/study-registry-codec.d.ts.map +1 -0
  23. package/dist/ports/study-registry-codec.js +2 -0
  24. package/dist/ports/study-registry-codec.js.map +1 -0
  25. package/dist/repro-layout-rule.d.ts.map +1 -1
  26. package/dist/repro-layout-rule.js +4 -3
  27. package/dist/repro-layout-rule.js.map +1 -1
  28. package/dist/studies.d.ts +27 -0
  29. package/dist/studies.d.ts.map +1 -0
  30. package/dist/studies.js +160 -0
  31. package/dist/studies.js.map +1 -0
  32. package/dist/study-registry-rule.d.ts +17 -0
  33. package/dist/study-registry-rule.d.ts.map +1 -0
  34. package/dist/study-registry-rule.js +115 -0
  35. package/dist/study-registry-rule.js.map +1 -0
  36. package/docs/design/study-registry.md +130 -0
  37. package/docs/layout.md +47 -44
  38. package/docs/rules/paper/allowed-files-only.md +3 -2
  39. package/docs/rules/repro/allowed-files-only.md +40 -30
  40. package/docs/rules/repro/study-registered.md +162 -0
  41. package/docs/rules/study/declare-sample.md +1 -1
  42. package/docs/rules.md +2 -1
  43. package/package.json +1 -1
  44. package/skills/build-benchmark/SKILL.md +20 -1
  45. package/skills/build-benchmark/SKILL.md.spec.ts +19 -0
  46. package/skills/build-label-page/SKILL.md +7 -4
  47. package/skills/build-label-page/SKILL.md.spec.ts +6 -3
  48. package/skills/paper-pipeline/references/artifact-checklist.md +2 -1
  49. package/skills/paper-pipeline/references/pipeline-status-template.md +5 -3
  50. package/skills/render-paper/check-render.sh +9 -0
  51. package/skills/render-paper/render-paper.harness.mjs +102 -0
  52. package/skills/run-agent-grid/SKILL.md +8 -1
  53. package/skills/run-agent-grid/SKILL.md.spec.ts +7 -0
  54. package/src/adapters/study-registry/index.test.ts +144 -0
  55. package/src/adapters/study-registry/index.ts +70 -0
  56. package/src/cli.ts +51 -2
  57. package/src/domain/paper-layout.test.ts +1 -1
  58. package/src/domain/repro-layout.test.ts +18 -9
  59. package/src/domain/repro-layout.ts +58 -18
  60. package/src/domain/scaffold-layout.ts +7 -4
  61. package/src/domain/study-registry.test.ts +657 -0
  62. package/src/domain/study-registry.ts +598 -0
  63. package/src/folder-layout-hook.test.ts +10 -0
  64. package/src/new-paper.test.ts +28 -2
  65. package/src/ports/study-registry-codec.ts +11 -0
  66. package/src/repro-layout-rule.ts +4 -3
  67. package/src/rule-cases.test.ts +41 -0
  68. package/src/studies-cli.test.ts +113 -0
  69. package/src/studies.test.ts +353 -0
  70. package/src/studies.ts +259 -0
  71. package/src/study-registry-rule.test.ts +317 -0
  72. package/src/study-registry-rule.ts +172 -0
@@ -0,0 +1,598 @@
1
+ /**
2
+ * THE STUDY REGISTRY — `repro/studies.json`: one row per study, each with a stable id, a plain name and
3
+ * its status. The design is docs/design/study-registry.md; the rule is `repro/study-registered`
4
+ * (docs/rules/repro/study-registered.md).
5
+ *
6
+ * A paper's `repro/` once reached 79 study folders named `<slug>-YYYY-MM-DD`, and nobody could tell
7
+ * which was which, which had ended, or which a number in the paper came from. Now every study folder is
8
+ * `<id>-<slug>` (`P1-S03-baseline`), and its row says what it asks, where it stands and what it found.
9
+ *
10
+ * ── JSON, NEVER MARKDOWN ─────────────────────────────────────────────────────────
11
+ * The registry is data: its text is parsed once, where it enters, against the schema in
12
+ * `src/adapters/study-registry/` (`StudyRegistryCodec`). The index in `repro/README.md` is OUTPUT: it is
13
+ * generated from the registry and compared as a string, between two fixed markers. Nothing parses it.
14
+ *
15
+ * Pure: the caller lists `repro/`, reads the registry and the README, and says which result files exist.
16
+ */
17
+ import { isAbsolute, posix, win32 } from "node:path";
18
+ import { STUDIES_FILE, STUDY_SLUG } from "./repro-layout.ts";
19
+
20
+ /** The registry, at the top of the paper's `repro/`; the layout allows it there. */
21
+ export { STUDIES_FILE };
22
+
23
+ export const STUDY_KINDS = [
24
+ "measurement",
25
+ "spike",
26
+ "tooling",
27
+ "data",
28
+ "artifact",
29
+ "salvage",
30
+ ] as const;
31
+ export type StudyKind = (typeof STUDY_KINDS)[number];
32
+
33
+ export const STUDY_STATUSES = [
34
+ "designing",
35
+ "running",
36
+ "done",
37
+ "stopped",
38
+ "superseded",
39
+ "dropped",
40
+ ] as const;
41
+ export type StudyStatus = (typeof STUDY_STATUSES)[number];
42
+
43
+ /** `registry`: listed in the registry only, not in the paper's text. */
44
+ export const IN_PAPER = ["yes", "no", "registry"] as const;
45
+ export type InPaper = (typeof IN_PAPER)[number];
46
+
47
+ /** A title is a plain name, not a sentence. */
48
+ export const MAX_TITLE_WORDS = 8;
49
+
50
+ /** A paper's prefix: a capital letter, then capitals or digits (`P1`). */
51
+ export const PREFIX_PATTERN = /^[A-Z][A-Z0-9]*$/u;
52
+
53
+ /** The kind `paperlint studies new` gives a study when none is named. */
54
+ export const DEFAULT_KIND: StudyKind = "measurement";
55
+
56
+ /** The markers around the generated index in `repro/README.md`. Fixed: the rule finds the block by them. */
57
+ export const INDEX_BEGIN =
58
+ "<!-- studies:begin — generated from studies.json by `paperlint studies index`; do not edit -->";
59
+ export const INDEX_END = "<!-- studies:end -->";
60
+
61
+ /** The README written when `repro/` has none. */
62
+ const INDEX_HEADING = "# Reproduction index";
63
+
64
+ /**
65
+ * One study. The named fields are the registry's; a row may carry more (a paper merges its own
66
+ * measurement metadata into it), and they are kept as they are.
67
+ */
68
+ export interface Study {
69
+ readonly id: string;
70
+ readonly folder: string;
71
+ readonly title: string;
72
+ readonly question: string;
73
+ readonly kind: StudyKind;
74
+ readonly status: StudyStatus;
75
+ readonly date: string;
76
+ readonly result?: string | undefined;
77
+ readonly result_file?: string | undefined;
78
+ readonly superseded_by?: string | undefined;
79
+ readonly in_paper: InPaper;
80
+ readonly [extra: string]: unknown;
81
+ }
82
+
83
+ export interface StudyRegistry {
84
+ readonly prefix: string;
85
+ readonly studies: readonly Study[];
86
+ }
87
+
88
+ /** A registry's text, parsed: the registry, or the one reason it is not one. */
89
+ export type ParsedRegistry =
90
+ | { readonly ok: true; readonly registry: StudyRegistry }
91
+ | { readonly ok: false; readonly why: string };
92
+
93
+ /** What the paper's `repro/` holds of a registry. */
94
+ export type RegistryState =
95
+ | { readonly kind: "absent" }
96
+ | { readonly kind: "unreadable"; readonly why: string }
97
+ | { readonly kind: "read"; readonly registry: StudyRegistry };
98
+
99
+ /** What the judge reads. */
100
+ export interface StudyInput {
101
+ /** The study folders at the top of `repro/`: every folder but the shared ones and the byproducts. */
102
+ readonly folders: readonly string[];
103
+ readonly registry: RegistryState;
104
+ /** `repro/README.md`'s text; null when there is none. */
105
+ readonly readme: string | null;
106
+ /** Each `<folder>/<result_file>` of the registry that is a file. */
107
+ readonly existingResults: ReadonlySet<string>;
108
+ }
109
+
110
+ /** One defect of the registry, of a row, or of the index. */
111
+ export type StudyFinding =
112
+ | { readonly problem: "no-registry"; readonly folders: readonly string[] }
113
+ | { readonly problem: "unreadable"; readonly why: string }
114
+ | {
115
+ readonly problem: "unregistered";
116
+ readonly folder: string;
117
+ readonly next: string;
118
+ }
119
+ | {
120
+ readonly problem: "no-folder";
121
+ readonly id: string;
122
+ readonly folder: string;
123
+ }
124
+ | {
125
+ readonly problem: "two-rows";
126
+ readonly folder: string;
127
+ readonly ids: readonly string[];
128
+ }
129
+ | { readonly problem: "bad-id"; readonly id: string; readonly prefix: string }
130
+ | { readonly problem: "duplicate-id"; readonly id: string }
131
+ | {
132
+ readonly problem: "folder-name";
133
+ readonly id: string;
134
+ readonly folder: string;
135
+ }
136
+ | {
137
+ readonly problem: "long-title";
138
+ readonly id: string;
139
+ readonly words: number;
140
+ }
141
+ | {
142
+ readonly problem: "no-result";
143
+ readonly id: string;
144
+ readonly missing: readonly ("result" | "result_file")[];
145
+ }
146
+ | {
147
+ readonly problem: "result-file";
148
+ readonly id: string;
149
+ readonly path: string;
150
+ readonly why: "missing" | "outside";
151
+ }
152
+ | {
153
+ readonly problem: "no-successor";
154
+ readonly id: string;
155
+ readonly superseded_by: string | null;
156
+ }
157
+ | {
158
+ readonly problem: "stale-index";
159
+ readonly why: "no-readme" | "no-markers" | "differs";
160
+ };
161
+
162
+ /** The number of an id of `prefix` (`P1-S07` → 7); null for an id of another shape. */
163
+ function idNumber(prefix: string, id: string): number | null {
164
+ const m = new RegExp(`^${prefix}-S(\\d{2,})$`, "u").exec(id);
165
+ return m === null ? null : Number(m[1]);
166
+ }
167
+
168
+ /** The id after the highest one in `r`, at least two digits: `P1-S01` in an empty registry. */
169
+ export function nextStudyId(r: StudyRegistry): string {
170
+ const numbers = r.studies
171
+ .map((s) => idNumber(r.prefix, s.id))
172
+ .filter((n): n is number => n !== null);
173
+ const next = Math.max(0, ...numbers) + 1;
174
+ return `${r.prefix}-S${String(next).padStart(2, "0")}`;
175
+ }
176
+
177
+ /** The slug of a title: lowercase Latin letters and digits, words joined by single hyphens; "" when none. */
178
+ export const studySlug = (title: string): string =>
179
+ title
180
+ .normalize("NFKD")
181
+ .replace(/\p{M}/gu, "")
182
+ .toLowerCase()
183
+ .replace(/[^a-z0-9]+/gu, "-")
184
+ .replace(/^-+|-+$/gu, "");
185
+
186
+ /** The number of words in a title. */
187
+ export const titleWords = (title: string): number =>
188
+ title.split(/\s+/u).filter((w) => w !== "").length;
189
+
190
+ /**
191
+ * Whether `path` stays inside the folder it is relative to. A backslash or a Windows absolute path
192
+ * is refused outright: `posix.normalize` does not resolve `..\\`, while the host's `join` may.
193
+ */
194
+ const isInside = (path: string): boolean => {
195
+ if (path.includes("\\") || isAbsolute(path) || win32.isAbsolute(path))
196
+ return false;
197
+ const n = posix.normalize(path);
198
+ return n !== ".." && !n.startsWith("../");
199
+ };
200
+
201
+ /** One row of a registry, with what the judge read beside it. */
202
+ interface Row {
203
+ readonly r: StudyRegistry;
204
+ readonly s: Study;
205
+ readonly i: number;
206
+ readonly input: StudyInput;
207
+ }
208
+
209
+ /** One check of a row: its finding, or null. */
210
+ type RowCheck = (row: Row) => StudyFinding | null;
211
+
212
+ /** Whether the row is the first of the registry with its value of `key`. */
213
+ const isFirst = ({ r, s, i }: Row, key: (o: Study) => string): boolean =>
214
+ r.studies.findIndex((o) => key(o) === key(s)) === i;
215
+
216
+ const badId: RowCheck = ({ r, s }) =>
217
+ idNumber(r.prefix, s.id) === null
218
+ ? { problem: "bad-id", id: s.id, prefix: r.prefix }
219
+ : null;
220
+
221
+ /** Reported once, on the first row that holds the id. */
222
+ const duplicateId: RowCheck = (row) =>
223
+ row.r.studies.filter((o) => o.id === row.s.id).length > 1 &&
224
+ isFirst(row, (o) => o.id)
225
+ ? { problem: "duplicate-id", id: row.s.id }
226
+ : null;
227
+
228
+ /** Reported once, on the first row that names the folder. */
229
+ const twoRows: RowCheck = (row) => {
230
+ const { r, s } = row;
231
+ const ids = r.studies.filter((o) => o.folder === s.folder).map((o) => o.id);
232
+ return ids.length > 1 && isFirst(row, (o) => o.folder)
233
+ ? { problem: "two-rows", folder: s.folder, ids }
234
+ : null;
235
+ };
236
+
237
+ /** A bad id is reported once, as an id: no folder can match it. */
238
+ const folderName: RowCheck = ({ r, s }) =>
239
+ idNumber(r.prefix, s.id) === null ||
240
+ (s.folder.startsWith(`${s.id}-`) &&
241
+ STUDY_SLUG.test(s.folder.slice(s.id.length + 1)))
242
+ ? null
243
+ : { problem: "folder-name", id: s.id, folder: s.folder };
244
+
245
+ const noFolder: RowCheck = ({ s, input }) =>
246
+ input.folders.includes(s.folder)
247
+ ? null
248
+ : { problem: "no-folder", id: s.id, folder: s.folder };
249
+
250
+ const longTitle: RowCheck = ({ s }) => {
251
+ const words = titleWords(s.title);
252
+ return words > MAX_TITLE_WORDS
253
+ ? { problem: "long-title", id: s.id, words }
254
+ : null;
255
+ };
256
+
257
+ const noResult: RowCheck = ({ s }) => {
258
+ const missing = [
259
+ ...(s.result === undefined ? (["result"] as const) : []),
260
+ ...(s.result_file === undefined ? (["result_file"] as const) : []),
261
+ ];
262
+ return s.status === "done" && missing.length > 0
263
+ ? { problem: "no-result", id: s.id, missing }
264
+ : null;
265
+ };
266
+
267
+ /** A `result_file` on any status must be a file inside the study's folder. */
268
+ const resultFile: RowCheck = ({ s, input }) => {
269
+ const path = s.result_file;
270
+ if (path === undefined) return null;
271
+ if (!isInside(path))
272
+ return { problem: "result-file", id: s.id, path, why: "outside" };
273
+ return input.existingResults.has(`${s.folder}/${path}`)
274
+ ? null
275
+ : { problem: "result-file", id: s.id, path, why: "missing" };
276
+ };
277
+
278
+ const noSuccessor: RowCheck = ({ r, s }) => {
279
+ const by = s.superseded_by ?? null;
280
+ return s.status === "superseded" &&
281
+ (by === null || !r.studies.some((o) => o.id === by))
282
+ ? { problem: "no-successor", id: s.id, superseded_by: by }
283
+ : null;
284
+ };
285
+
286
+ /** The checks of a row, in the order their findings are reported. */
287
+ const ROW_CHECKS: readonly RowCheck[] = [
288
+ badId,
289
+ duplicateId,
290
+ twoRows,
291
+ folderName,
292
+ noFolder,
293
+ longTitle,
294
+ noResult,
295
+ resultFile,
296
+ noSuccessor,
297
+ ];
298
+
299
+ /** What is wrong with a row, in a fixed order. */
300
+ const judgeRow = (row: Row): readonly StudyFinding[] =>
301
+ ROW_CHECKS.map((check) => check(row)).filter(
302
+ (f): f is StudyFinding => f !== null,
303
+ );
304
+
305
+ /** Whether the README's index is the one `r` generates; the finding when not. */
306
+ function judgeIndex(
307
+ r: StudyRegistry,
308
+ readme: string | null,
309
+ ): StudyFinding | null {
310
+ if (readme === null) return { problem: "stale-index", why: "no-readme" };
311
+ const block = embeddedIndex(readme);
312
+ if (block === null) return { problem: "stale-index", why: "no-markers" };
313
+ return indexInSync(readme, r)
314
+ ? null
315
+ : { problem: "stale-index", why: "differs" };
316
+ }
317
+
318
+ /**
319
+ * Every defect of a paper's study registry, in a fixed order: the registry itself; the unregistered
320
+ * folders; each row's; the index. Pure.
321
+ */
322
+ export function judgeStudies(input: StudyInput): readonly StudyFinding[] {
323
+ const state = input.registry;
324
+ if (state.kind === "absent")
325
+ return input.folders.length === 0
326
+ ? []
327
+ : [{ problem: "no-registry", folders: input.folders }];
328
+ if (state.kind === "unreadable")
329
+ return [{ problem: "unreadable", why: state.why }];
330
+ const r = state.registry;
331
+ const next = nextStudyId(r);
332
+ const index = judgeIndex(r, input.readme);
333
+ return [
334
+ ...input.folders
335
+ .filter((f) => !r.studies.some((s) => s.folder === f))
336
+ .map((folder): StudyFinding => ({
337
+ problem: "unregistered",
338
+ folder,
339
+ next,
340
+ })),
341
+ ...r.studies.flatMap((s, i) => judgeRow({ r, s, i, input })),
342
+ ...(index === null ? [] : [index]),
343
+ ];
344
+ }
345
+
346
+ // ── the index ─────────────────────────────────────────────────────────────────────
347
+
348
+ /** A cell's text: one line, a backslash and then a pipe escaped, so `a\\|b` stays one cell. */
349
+ const cell = (s: string): string =>
350
+ s.replace(/\r?\n/gu, " ").replace(/\\/gu, "\\\\").replace(/\|/gu, "\\|");
351
+
352
+ /** The columns of the index: each one's header, and its cell for a study. */
353
+ const COLUMNS: readonly {
354
+ readonly head: string;
355
+ readonly of: (s: Study) => string;
356
+ }[] = [
357
+ { head: "id", of: (s) => s.id },
358
+ { head: "title", of: (s) => cell(s.title) },
359
+ { head: "status", of: (s) => s.status },
360
+ { head: "result", of: (s) => cell(s.result ?? "—") },
361
+ { head: "in paper", of: (s) => s.in_paper },
362
+ { head: "folder", of: (s) => `[${s.folder}/](${s.folder}/)` },
363
+ ];
364
+
365
+ /** The width a cell takes: its code points. */
366
+ const widthOf = (s: string): number => Array.from(s).length;
367
+
368
+ /** `t` padded to `width`. */
369
+ const padded = (t: string, width: number): string =>
370
+ t + " ".repeat(width - widthOf(t));
371
+
372
+ /**
373
+ * The index of `r`: a markdown table, a row per study in registry order, each column padded to its
374
+ * widest cell (at least 3), the way prettier writes a table — so formatting the README leaves it as
375
+ * it is.
376
+ */
377
+ export function studyIndex(r: StudyRegistry): string {
378
+ const sized = COLUMNS.map((c) => ({
379
+ ...c,
380
+ width: Math.max(
381
+ 3,
382
+ widthOf(c.head),
383
+ ...r.studies.map((s) => widthOf(c.of(s))),
384
+ ),
385
+ }));
386
+ const line = (cells: readonly string[]): string => `| ${cells.join(" | ")} |`;
387
+ return [
388
+ line(sized.map((c) => padded(c.head, c.width))),
389
+ line(sized.map((c) => "-".repeat(c.width))),
390
+ ...r.studies.map((s) => line(sized.map((c) => padded(c.of(s), c.width)))),
391
+ ].join("\n");
392
+ }
393
+
394
+ /**
395
+ * What a markdown table says, whatever its padding: its rows, each cell trimmed, a separator cell as
396
+ * `---`. The index is judged on this, because the padding belongs to the formatter: prettier sizes a
397
+ * column by display width, so a wide character pads differently from a code-point count.
398
+ */
399
+ const cellsOf = (table: string): string =>
400
+ table
401
+ .trim()
402
+ .split("\n")
403
+ .map((line) =>
404
+ line
405
+ .trim()
406
+ .replace(/^\||\|$/gu, "")
407
+ .split(/(?<!\\)\|/u)
408
+ .map((c) => c.trim().replace(/^:?-{3,}:?$/u, "---"))
409
+ .join("|"),
410
+ )
411
+ .join("\n");
412
+
413
+ /** Whether `readme` holds the index of `r`, whatever padding a formatter gave its table. */
414
+ export function indexInSync(readme: string | null, r: StudyRegistry): boolean {
415
+ const block = readme === null ? null : embeddedIndex(readme);
416
+ return block !== null && cellsOf(block) === cellsOf(studyIndex(r));
417
+ }
418
+
419
+ /** The index between its markers, a blank line on each side. */
420
+ export const indexBlock = (r: StudyRegistry): string =>
421
+ `${INDEX_BEGIN}\n\n${studyIndex(r)}\n\n${INDEX_END}`;
422
+
423
+ /**
424
+ * Where `marker` stands alone on a line of `text`, at or after `from`; -1 when it does not. A marker
425
+ * counts only there, where the generator writes it: prose can mention it and a cell can carry it, but
426
+ * neither puts it alone on a line.
427
+ */
428
+ const markerAt = (text: string, marker: string, from = 0): number => {
429
+ const literal = marker.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
430
+ const m = new RegExp(`^${literal}$`, "mu").exec(text.slice(from));
431
+ return m === null ? -1 : from + m.index;
432
+ };
433
+
434
+ /** The text between the markers of `readme`; null when it has no begin marker followed by an end one. */
435
+ export function embeddedIndex(readme: string): string | null {
436
+ const begin = markerAt(readme, INDEX_BEGIN);
437
+ const end = begin === -1 ? -1 : markerAt(readme, INDEX_END, begin);
438
+ return end === -1 ? null : readme.slice(begin + INDEX_BEGIN.length, end);
439
+ }
440
+
441
+ /**
442
+ * `readme` with its index regenerated from `r`: the block replaced in place, appended when the README
443
+ * has none, a README of its own when there is none.
444
+ */
445
+ export function withIndex(readme: string | null, r: StudyRegistry): string {
446
+ if (readme === null) return `${INDEX_HEADING}\n\n${indexBlock(r)}\n`;
447
+ const block = embeddedIndex(readme);
448
+ if (block === null) return `${readme.trimEnd()}\n\n${indexBlock(r)}\n`;
449
+ const begin = markerAt(readme, INDEX_BEGIN);
450
+ const after = begin + INDEX_BEGIN.length + block.length + INDEX_END.length;
451
+ return `${readme.slice(0, begin)}${indexBlock(r)}${readme.slice(after)}`;
452
+ }
453
+
454
+ /** The registry's text as `paperlint studies new` writes it: JSON, two-space indented, a final newline. */
455
+ export const registryText = (r: StudyRegistry): string =>
456
+ `${JSON.stringify(r, null, 2)}\n`;
457
+
458
+ // ── the messages ──────────────────────────────────────────────────────────────────
459
+
460
+ const REGISTRY = "`repro/studies.json`";
461
+ const NEW_STUDY = '`npx paperlint studies new <paper-dir> "<title>"`';
462
+ const RUN_INDEX =
463
+ "What to do: run `npx paperlint studies index <paper-dir>`; the block is generated, never edited by hand.";
464
+ const RENAME =
465
+ "give each other row the next free id and rename its folder to match";
466
+
467
+ const tick = (s: string): string => `\`${s}\``;
468
+
469
+ /** Each finding by its problem: the map the messages are looked up in. */
470
+ type FindingOf = {
471
+ readonly [F in StudyFinding as F["problem"]]: F;
472
+ };
473
+ type Problem = keyof FindingOf;
474
+
475
+ const STALE_HEAD: Readonly<Record<FindingOf["stale-index"]["why"], string>> = {
476
+ differs: `The index in \`repro/README.md\` (between the \`studies:begin\` and \`studies:end\` markers) is not the one ${REGISTRY} generates.`,
477
+ "no-markers":
478
+ "`repro/README.md` has no index of the studies (the `studies:begin` and `studies:end` markers).",
479
+ "no-readme": "`repro/README.md`, the index of the studies, does not exist.",
480
+ };
481
+
482
+ /** The first part of each finding's text: what is wrong, and what to do. */
483
+ const HEADS: { readonly [P in Problem]: (f: FindingOf[P]) => string } = {
484
+ "no-registry": (f) =>
485
+ `\`repro/\` holds study folders (${f.folders.map((d) => tick(`${d}/`)).join(", ")}) but no ${REGISTRY}, the registry that gives every study an id, a plain name and a status. What to do: write ${REGISTRY} with the paper's prefix — \`{ "prefix": "P1", "studies": [] }\` — then give each folder its row and rename it to \`<id>-<slug>\`; a new study is created with ${NEW_STUDY}.`,
486
+ unreadable: (f) =>
487
+ `${REGISTRY} is not a study registry: ${f.why}. Its shape is on the rule's page.`,
488
+ unregistered: (f) =>
489
+ `\`repro/${f.folder}/\` has no row in ${REGISTRY}, and every study folder has exactly one. What to do: add its row with the next free id, ${tick(f.next)}, and rename the folder to \`${f.next}-<slug>\`; a new study is created with ${NEW_STUDY}, which does both.`,
490
+ "no-folder": (f) =>
491
+ `${REGISTRY}: the row ${tick(f.id)} names the folder \`repro/${f.folder}/\`, which does not exist. What to do: rename the study's folder to it, or correct the row's \`folder\`.`,
492
+ "two-rows": (f) =>
493
+ `${REGISTRY}: the rows ${f.ids.map(tick).join(", ")} all name the folder \`repro/${f.folder}/\`, and a study folder has exactly one row. What to do: keep one row for it.`,
494
+ "bad-id": (f) =>
495
+ `${REGISTRY}: the id ${tick(f.id)} is not \`${f.prefix}-S\` followed by two or more digits (\`${f.prefix}-S01\`). What to do: give the row the next free id and rename its folder to match.`,
496
+ "duplicate-id": (f) =>
497
+ `${REGISTRY}: the id ${tick(f.id)} is on more than one row, and an id names one study. What to do: ${RENAME}.`,
498
+ "folder-name": (f) =>
499
+ `${REGISTRY}: the row ${tick(f.id)} names the folder ${tick(f.folder)}, which is not \`${f.id}-<slug>\` — the id, then lowercase letters and digits in words joined by hyphens. What to do: rename the folder and the row's \`folder\` together.`,
500
+ "long-title": (f) =>
501
+ `${REGISTRY}: the title of ${tick(f.id)} has ${String(f.words)} words; a title is a plain name of at most ${String(MAX_TITLE_WORDS)}. What to do: shorten it, and put the detail in \`question\`.`,
502
+ "no-result": (f) =>
503
+ `${REGISTRY}: ${tick(f.id)} is \`done\` without ${f.missing.map(tick).join(" and ")}. What to do: write the one-line result and the file it is read from (\`result\`, \`result_file\`), or set the status it really has.`,
504
+ "result-file": (f) =>
505
+ `${REGISTRY}: the \`result_file\` of ${tick(f.id)}, ${tick(f.path)}, ${f.why === "missing" ? "is not a file in its folder" : "is outside its folder"}. What to do: name a file that exists, relative to the study's folder.`,
506
+ "no-successor": (f) =>
507
+ `${REGISTRY}: ${tick(f.id)} is \`superseded\` ${f.superseded_by === null ? "without `superseded_by`" : `by ${tick(f.superseded_by)}, which is no study in the registry`}. What to do: name the id of the study that replaced it.`,
508
+ "stale-index": (f) => `${STALE_HEAD[f.why]} ${RUN_INDEX}`,
509
+ };
510
+
511
+ /** The head of finding `f` of problem `p`: the lookup, typed by the problem. */
512
+ const headOf = <P extends Problem>(p: P, f: FindingOf[P]): string =>
513
+ HEADS[p](f);
514
+
515
+ /** The one text a finding is reported with: what is wrong, what to do, and the rule's page. */
516
+ export const studyMessage = (f: StudyFinding): string =>
517
+ `${headOf(f.problem, f)}\nRule repro/study-registered: https://github.com/zernie/paperlint/blob/main/docs/rules/repro/study-registered.md`;
518
+
519
+ // ── a new study ───────────────────────────────────────────────────────────────────
520
+
521
+ /** Whether `s` names a kind of study. */
522
+ export const isStudyKind = (s: string): s is StudyKind =>
523
+ STUDY_KINDS.some((k) => k === s);
524
+
525
+ /** What a new study is made from. */
526
+ export interface NewStudy {
527
+ readonly title: string;
528
+ readonly kind: StudyKind;
529
+ /** The day it is registered, `YYYY-MM-DD`. */
530
+ readonly date: string;
531
+ }
532
+
533
+ /** Why `title` cannot name a study; null when it can. */
534
+ function titleProblem(title: string): string | null {
535
+ const words = titleWords(title);
536
+ if (words === 0) return "the title is empty";
537
+ if (words > MAX_TITLE_WORDS)
538
+ return `the title has ${String(words)} words; a title is a plain name of at most ${String(MAX_TITLE_WORDS)} — the detail goes in the row's \`question\``;
539
+ return studySlug(title) === ""
540
+ ? "the title has no Latin letter or digit to name the folder by; give it one (an English title, or a word such as the method's name)"
541
+ : null;
542
+ }
543
+
544
+ /**
545
+ * `r` with a new study appended: the next id, the folder `<id>-<slug>`, `status: designing`,
546
+ * `in_paper: no` and an empty question; or why the title cannot make one.
547
+ */
548
+ export function addStudy(
549
+ r: StudyRegistry,
550
+ n: NewStudy,
551
+ ):
552
+ | {
553
+ readonly ok: true;
554
+ readonly registry: StudyRegistry;
555
+ readonly study: Study;
556
+ }
557
+ | { readonly ok: false; readonly why: string } {
558
+ const title = n.title.trim().replace(/\s+/gu, " ");
559
+ const why = titleProblem(title);
560
+ if (why !== null) return { ok: false, why };
561
+ const id = nextStudyId(r);
562
+ const study: Study = {
563
+ id,
564
+ folder: `${id}-${studySlug(title)}`,
565
+ title,
566
+ question: "",
567
+ kind: n.kind,
568
+ status: "designing",
569
+ date: n.date,
570
+ in_paper: "no",
571
+ };
572
+ return {
573
+ ok: true,
574
+ registry: { ...r, studies: [...r.studies, study] },
575
+ study,
576
+ };
577
+ }
578
+
579
+ /** The `README.md` a new study's folder starts with: its id and title in frontmatter, then the sections. */
580
+ export const studyReadme = (s: Study): string =>
581
+ [
582
+ "---",
583
+ `id: ${s.id}`,
584
+ `title: ${JSON.stringify(s.title)}`,
585
+ "---",
586
+ "",
587
+ `# ${s.id} — ${s.title}`,
588
+ "",
589
+ `Its row in \`../${STUDIES_FILE}\` holds its status. When the study ends, set \`status\`, \`result\`,`,
590
+ "`result_file` and `in_paper` there, then run `npx paperlint studies index <paper-dir>`.",
591
+ "",
592
+ "## Question",
593
+ "",
594
+ "## How to run",
595
+ "",
596
+ "## Result",
597
+ "",
598
+ ].join("\n");
@@ -145,9 +145,19 @@ describe("paper-folder-layout hook — allows", () => {
145
145
  expect(reason(`${DIR}/reviews/2026-10-12-x.md`)).toBeNull();
146
146
  expect(reason(`${DIR}/extra/anything.bin`)).toBeNull();
147
147
  expect(reason(`${DIR}/repro/README.md`)).toBeNull();
148
+ expect(reason(`${DIR}/repro/studies.json`)).toBeNull();
148
149
  expect(reason(`${DIR}/repro/tools/paper_numbers.py`)).toBeNull();
149
150
  expect(reason(`${DIR}/repro/data/numbers.tsv`)).toBeNull();
150
151
  });
152
+ });
153
+
154
+ describe("paper-folder-layout hook — a new study folder", () => {
155
+ it("a registered study folder started with its README.md; the first file otherwise is refused", () => {
156
+ expect(reason(`${DIR}/repro/P1-S01-ablation/README.md`)).toBeNull();
157
+ expect(reason(`${DIR}/repro/P1-S01-ablation/run.py`)).toMatch(
158
+ /^`repro\/P1-S01-ablation\/` is a study folder without a README\.md/,
159
+ );
160
+ });
151
161
 
152
162
  it("a new study folder started with its README.md, then any file in it", () => {
153
163
  expect(reason(`${DIR}/repro/ablation-2026-10-12/README.md`)).toBeNull();
@@ -32,11 +32,17 @@ import { judgeLayout } from "./domain/paper-layout.ts";
32
32
  import {
33
33
  GITKEEP,
34
34
  NOT_SCAFFOLDED,
35
+ REPRO_INDEX_STUB,
35
36
  SCAFFOLD_DIRS,
36
37
  SCAFFOLD_FILES,
37
38
  } from "./domain/scaffold-layout.ts";
38
39
  import { ROOT_DIRS } from "./domain/paper-layout.ts";
39
40
  import { judgeRepro, REPRO_DIR } from "./domain/repro-layout.ts";
41
+ import {
42
+ registryText,
43
+ withIndex,
44
+ type StudyRegistry,
45
+ } from "./domain/study-registry.ts";
40
46
  import { statSync } from "node:fs";
41
47
 
42
48
  const dirs: string[] = [];
@@ -765,10 +771,28 @@ async function lintScaffold(added: Record<string, string> = {}) {
765
771
  };
766
772
  }
767
773
 
774
+ /** One registered study, as `paperlint studies new` writes it. */
775
+ const SOME_STUDY: StudyRegistry = {
776
+ prefix: "P1",
777
+ studies: [
778
+ {
779
+ id: "P1-S01",
780
+ folder: "P1-S01-some-study",
781
+ title: "Some study",
782
+ question: "",
783
+ kind: "measurement",
784
+ status: "designing",
785
+ date: "2026-10-13",
786
+ in_paper: "no",
787
+ },
788
+ ],
789
+ };
790
+
768
791
  describe("paperlint lint — a scaffolded paper has no layout finding", () => {
769
792
  const LAYOUT_RULES = [
770
793
  "paper/allowed-files-only",
771
794
  "repro/allowed-files-only",
795
+ "repro/study-registered",
772
796
  "review/frontmatter",
773
797
  "sibling/frontmatter",
774
798
  "paper/stages",
@@ -787,8 +811,10 @@ describe("paperlint lint — a scaffolded paper has no layout finding", () => {
787
811
  "siblings/.gitkeep": "",
788
812
  "talk/.gitkeep": "",
789
813
  "sections/.gitkeep": "",
790
- "repro/some-study-2026-10-13/README.md": "# s\n",
791
- "repro/some-study-2026-10-13/.gitkeep": "",
814
+ "repro/studies.json": registryText(SOME_STUDY),
815
+ "repro/README.md": withIndex(REPRO_INDEX_STUB, SOME_STUDY),
816
+ "repro/P1-S01-some-study/README.md": "# s\n",
817
+ "repro/P1-S01-some-study/.gitkeep": "",
792
818
  });
793
819
  expect(ids.filter((id) => LAYOUT_RULES.includes(id ?? ""))).toEqual([]);
794
820
  });
@@ -0,0 +1,11 @@
1
+ /**
2
+ * How a `repro/studies.json` becomes a value: its text parsed once, where it enters, against the schema
3
+ * of `StudyRegistry` (`src/domain/study-registry.ts`). The one implementation is
4
+ * `src/adapters/study-registry/` (zod); `src/cli.ts` wires it.
5
+ */
6
+ import type { ParsedRegistry } from "../domain/study-registry.ts";
7
+
8
+ export interface StudyRegistryCodec {
9
+ /** A `studies.json`'s text: the registry, or the one reason it is not one of this schema. */
10
+ readonly parse: (text: string) => ParsedRegistry;
11
+ }