paperlint 10.1.1 → 10.3.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 +357 -0
  3. package/dist/adapters/study-registry/index.d.ts.map +1 -0
  4. package/dist/adapters/study-registry/index.js +221 -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 +42 -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 +372 -0
  18. package/dist/domain/study-registry.d.ts.map +1 -0
  19. package/dist/domain/study-registry.js +593 -0
  20. package/dist/domain/study-registry.js.map +1 -0
  21. package/dist/ports/study-registry-codec.d.ts +15 -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 +163 -0
  31. package/dist/studies.js.map +1 -0
  32. package/dist/study-registry-rule.d.ts +24 -0
  33. package/dist/study-registry-rule.d.ts.map +1 -0
  34. package/dist/study-registry-rule.js +145 -0
  35. package/dist/study-registry-rule.js.map +1 -0
  36. package/docs/design/study-registry.md +246 -0
  37. package/docs/layout.md +51 -46
  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-plan-name.md +70 -0
  41. package/docs/rules/repro/study-registered.md +320 -0
  42. package/docs/rules/study/declare-sample.md +1 -1
  43. package/docs/rules.md +53 -51
  44. package/package.json +1 -1
  45. package/skills/build-benchmark/SKILL.md +39 -8
  46. package/skills/build-benchmark/SKILL.md.spec.ts +38 -7
  47. package/skills/build-label-page/SKILL.md +14 -9
  48. package/skills/build-label-page/SKILL.md.spec.ts +13 -8
  49. package/skills/paper-pipeline/references/artifact-checklist.md +2 -1
  50. package/skills/paper-pipeline/references/pipeline-status-template.md +5 -3
  51. package/skills/run-agent-grid/SKILL.md +13 -1
  52. package/skills/run-agent-grid/SKILL.md.spec.ts +12 -0
  53. package/src/adapters/study-registry/index.test.ts +484 -0
  54. package/src/adapters/study-registry/index.ts +328 -0
  55. package/src/cli.ts +53 -2
  56. package/src/domain/paper-layout.test.ts +1 -1
  57. package/src/domain/repro-layout.test.ts +18 -9
  58. package/src/domain/repro-layout.ts +58 -18
  59. package/src/domain/scaffold-layout.ts +7 -4
  60. package/src/domain/study-measurements.test.ts +629 -0
  61. package/src/domain/study-registry.test.ts +666 -0
  62. package/src/domain/study-registry.ts +1044 -0
  63. package/src/folder-layout-hook.test.ts +10 -0
  64. package/src/new-paper.test.ts +29 -2
  65. package/src/ports/study-registry-codec.ts +15 -0
  66. package/src/repro-layout-rule.ts +4 -3
  67. package/src/rule-cases.test.ts +68 -0
  68. package/src/studies-cli.test.ts +113 -0
  69. package/src/studies.test.ts +359 -0
  70. package/src/studies.ts +268 -0
  71. package/src/study-registry-rule.test.ts +456 -0
  72. package/src/study-registry-rule.ts +239 -0
@@ -0,0 +1,456 @@
1
+ /**
2
+ * `repro/study-registered` (`src/study-registry-rule.ts`) on paper folders held in memory, run the way
3
+ * ESLint runs it: `create`, then `root`. Both halves: a registered `repro/` with its index in sync is
4
+ * silent; each defect — an unregistered folder, a dangling row, a bad id, `done` without a result,
5
+ * `superseded` without a target, a stale index, a missing or unreadable registry — is reported once,
6
+ * on the first line of `PIPELINE-STATUS.md`, with the domain's text.
7
+ */
8
+ import { describe, expect, it } from "vitest";
9
+ import { memoryFiles, memoryListDir } from "./adapters/memory/index.ts";
10
+ import { studyRegistryCodec } from "./adapters/study-registry/index.ts";
11
+ import {
12
+ planTemplate,
13
+ planText,
14
+ registryText,
15
+ studyMessage,
16
+ withIndex,
17
+ type Study,
18
+ type StudyRegistry,
19
+ } from "./domain/study-registry.ts";
20
+ import {
21
+ registryOf,
22
+ STUDY_REGISTRY_RULE_LEVELS,
23
+ studyFindings,
24
+ studyRegistryRules,
25
+ } from "./study-registry-rule.ts";
26
+
27
+ const DIR = "/work/papers/example";
28
+ const SCAFFOLD = {
29
+ [`${DIR}/PIPELINE-STATUS.md`]: "# status\n",
30
+ [`${DIR}/paper.tex`]: "\\documentclass{article}\n",
31
+ };
32
+
33
+ const study = (over: Partial<Study> = {}): Study => ({
34
+ id: "P1-S01",
35
+ folder: "P1-S01-baseline",
36
+ title: "Baseline pass rate",
37
+ question: "How often does the agent pass?",
38
+ kind: "measurement",
39
+ status: "designing",
40
+ date: "2026-10-15",
41
+ in_paper: "no",
42
+ ...over,
43
+ });
44
+
45
+ /** A registered `repro/`: the registry, its index, and a README.md in each row's folder. */
46
+ const registered = (
47
+ r: StudyRegistry,
48
+ readme: string | null = withIndex("# Reproduction index\n", r),
49
+ ): Record<string, string> => ({
50
+ ...SCAFFOLD,
51
+ [`${DIR}/repro/studies.json`]: registryText(r),
52
+ ...(readme === null ? {} : { [`${DIR}/repro/README.md`]: readme }),
53
+ ...Object.fromEntries(
54
+ r.studies.map((s) => [`${DIR}/repro/${s.folder}/README.md`, "# s\n"]),
55
+ ),
56
+ });
57
+
58
+ const P1 = (...studies: Study[]): StudyRegistry => ({ prefix: "P1", studies });
59
+
60
+ interface Reported {
61
+ readonly messageId: string;
62
+ readonly message: string;
63
+ readonly line: number;
64
+ }
65
+
66
+ /** The rule over the folder `tree` describes, reached through `file` (default the scorecard). */
67
+ function lint(
68
+ tree: Readonly<Record<string, string>>,
69
+ file = `${DIR}/PIPELINE-STATUS.md`,
70
+ name: "study-registered" | "study-plan-name" = "study-registered",
71
+ ): Reported[] {
72
+ const files = memoryFiles(tree);
73
+ const rule = studyRegistryRules({
74
+ files,
75
+ listDir: memoryListDir(files),
76
+ codec: studyRegistryCodec,
77
+ })[name];
78
+ const out: Reported[] = [];
79
+ const visitor = rule.create({
80
+ filename: file,
81
+ sourceCode: { getLocFromIndex: () => ({ line: 1, column: 0 }) },
82
+ report: (d) => {
83
+ const template = rule.meta.messages[d.messageId] ?? "";
84
+ const message = Object.entries(d.data).reduce(
85
+ (m, [k, v]) => m.split(`{{${k}}}`).join(String(v)),
86
+ template,
87
+ );
88
+ out.push({ messageId: d.messageId, message, line: d.loc.start.line });
89
+ },
90
+ });
91
+ visitor.root?.();
92
+ return out;
93
+ }
94
+
95
+ const messages = (tree: Readonly<Record<string, string>>): string[] =>
96
+ lint(tree).map((r) => r.message);
97
+
98
+ describe("repro/study-registered — silent", () => {
99
+ it("on a registered repro/ with its index in sync, beside tools/, data/ and byproducts", () => {
100
+ const done = study({
101
+ id: "P1-S02",
102
+ folder: "P1-S02-ablation",
103
+ status: "done",
104
+ result: "41 of 60 pass",
105
+ result_file: "out/results.tsv",
106
+ });
107
+ expect(
108
+ lint({
109
+ ...registered(P1(study(), done)),
110
+ [`${DIR}/repro/P1-S02-ablation/out/results.tsv`]: "x\n",
111
+ [`${DIR}/repro/tools/run.py`]: "x",
112
+ [`${DIR}/repro/data/numbers.tsv`]: "x",
113
+ [`${DIR}/repro/__pycache__/x.pyc`]: "x",
114
+ [`${DIR}/repro/node_modules/a/index.js`]: "x",
115
+ [`${DIR}/repro/.gitignore`]: "x",
116
+ }),
117
+ ).toEqual([]);
118
+ });
119
+
120
+ it("on a paper with no repro/", () => {
121
+ expect(lint(SCAFFOLD)).toEqual([]);
122
+ });
123
+
124
+ it("on a repro/ with only its shared folders and no registry", () => {
125
+ expect(
126
+ lint({
127
+ ...SCAFFOLD,
128
+ [`${DIR}/repro/README.md`]: "# index\n",
129
+ [`${DIR}/repro/tools/.gitkeep`]: "",
130
+ [`${DIR}/repro/data/.gitkeep`]: "",
131
+ }),
132
+ ).toEqual([]);
133
+ });
134
+
135
+ it("on any file but PIPELINE-STATUS.md", () => {
136
+ expect(
137
+ lint(
138
+ { ...SCAFFOLD, [`${DIR}/repro/spike-2026-10-12/README.md`]: "x" },
139
+ `${DIR}/paper.tex`,
140
+ ),
141
+ ).toEqual([]);
142
+ });
143
+ });
144
+
145
+ describe("repro/study-registered — reports, once each, on line 1", () => {
146
+ it("an unregistered folder", () => {
147
+ expect(
148
+ lint({
149
+ ...registered(P1(study())),
150
+ [`${DIR}/repro/spike-2026-10-12/README.md`]: "x",
151
+ }),
152
+ ).toEqual([
153
+ {
154
+ messageId: "unregistered",
155
+ message: studyMessage({
156
+ problem: "unregistered",
157
+ folder: "spike-2026-10-12",
158
+ next: "P1-S02",
159
+ }),
160
+ line: 1,
161
+ },
162
+ ]);
163
+ });
164
+
165
+ it("a dangling row", () => {
166
+ const tree = registered(P1(study()));
167
+ const { [`${DIR}/repro/P1-S01-baseline/README.md`]: gone, ...rest } = tree;
168
+ expect(gone).toBeDefined();
169
+ expect(messages(rest)).toEqual([
170
+ studyMessage({
171
+ problem: "no-folder",
172
+ id: "P1-S01",
173
+ folder: "P1-S01-baseline",
174
+ }),
175
+ ]);
176
+ });
177
+
178
+ it("a bad id", () => {
179
+ expect(
180
+ messages(registered(P1(study({ id: "P1-S1", folder: "P1-S1-x" })))),
181
+ ).toEqual([studyMessage({ problem: "bad-id", id: "P1-S1", prefix: "P1" })]);
182
+ });
183
+ });
184
+
185
+ describe("repro/study-registered — reports a row's status and the index", () => {
186
+ it("done without a result", () => {
187
+ expect(messages(registered(P1(study({ status: "done" }))))).toEqual([
188
+ studyMessage({
189
+ problem: "no-result",
190
+ id: "P1-S01",
191
+ missing: ["result", "result_file"],
192
+ }),
193
+ ]);
194
+ });
195
+
196
+ it("done with a result_file that is not there", () => {
197
+ expect(
198
+ messages(
199
+ registered(
200
+ P1(study({ status: "done", result: "x", result_file: "r.tsv" })),
201
+ ),
202
+ ),
203
+ ).toEqual([
204
+ studyMessage({
205
+ problem: "result-file",
206
+ id: "P1-S01",
207
+ path: "r.tsv",
208
+ why: "missing",
209
+ }),
210
+ ]);
211
+ });
212
+
213
+ it("superseded without a target", () => {
214
+ expect(messages(registered(P1(study({ status: "superseded" }))))).toEqual([
215
+ studyMessage({
216
+ problem: "no-successor",
217
+ id: "P1-S01",
218
+ superseded_by: null,
219
+ }),
220
+ ]);
221
+ });
222
+
223
+ it("a stale index", () => {
224
+ const r = P1(study());
225
+ expect(messages(registered(r, withIndex("# x\n", P1())))).toEqual([
226
+ studyMessage({ problem: "stale-index", why: "differs" }),
227
+ ]);
228
+ });
229
+ });
230
+
231
+ describe("repro/study-registered — reports the registry itself, once", () => {
232
+ it("study folders and no studies.json: one finding naming the file", () => {
233
+ expect(
234
+ lint({
235
+ ...SCAFFOLD,
236
+ [`${DIR}/repro/README.md`]: "# index\n",
237
+ [`${DIR}/repro/b-2026-10-02/README.md`]: "x",
238
+ [`${DIR}/repro/a-2026-10-01/README.md`]: "x",
239
+ [`${DIR}/repro/tools/run.py`]: "x",
240
+ }),
241
+ ).toEqual([
242
+ {
243
+ messageId: "no-registry",
244
+ message: studyMessage({
245
+ problem: "no-registry",
246
+ folders: ["a-2026-10-01", "b-2026-10-02"],
247
+ }),
248
+ line: 1,
249
+ },
250
+ ]);
251
+ });
252
+
253
+ it("a studies.json that is not a registry: one finding with the reasons", () => {
254
+ expect(
255
+ lint({
256
+ ...SCAFFOLD,
257
+ [`${DIR}/repro/studies.json`]: '{ "studies": [] }',
258
+ [`${DIR}/repro/P1-S01-x/README.md`]: "x",
259
+ }),
260
+ ).toEqual([
261
+ {
262
+ messageId: "unreadable",
263
+ message: studyMessage({
264
+ problem: "unreadable",
265
+ why: "prefix: Invalid input: expected string, received undefined",
266
+ }),
267
+ line: 1,
268
+ },
269
+ ]);
270
+ });
271
+ });
272
+
273
+ describe("repro/study-registered — level", () => {
274
+ it("is an error from the start", () => {
275
+ expect(STUDY_REGISTRY_RULE_LEVELS).toEqual({
276
+ "repro/study-registered": "error",
277
+ "repro/study-plan-name": "warn",
278
+ });
279
+ });
280
+ });
281
+
282
+ describe("repro/study-registered — a directory where the registry should be", () => {
283
+ it("is read as no registry, not thrown on (the disk adapter throws EISDIR on a directory)", () => {
284
+ const files = {
285
+ ...memoryFiles({}),
286
+ isFile: () => false,
287
+ readBytes: (): Uint8Array => {
288
+ throw Object.assign(new Error("EISDIR"), { code: "EISDIR" });
289
+ },
290
+ };
291
+ expect(registryOf(DIR, { files, codec: studyRegistryCodec })).toEqual({
292
+ kind: "absent",
293
+ });
294
+ });
295
+ });
296
+
297
+ describe("repro/study-registered — a result_file the disk cannot hold", () => {
298
+ it("reads as missing, and the disk is never asked (the disk adapter throws on a NUL)", () => {
299
+ const r = P1(study({ status: "running", result_file: "a\u0000b.tsv" }));
300
+ const files = memoryFiles({
301
+ ...SCAFFOLD,
302
+ [`${DIR}/repro/studies.json`]: registryText(r),
303
+ [`${DIR}/repro/README.md`]: withIndex("# index\n", r),
304
+ [`${DIR}/repro/P1-S01-baseline/README.md`]: "# s\n",
305
+ });
306
+ const probe = {
307
+ ...files,
308
+ isFile: (p: Parameters<typeof files.isFile>[0]): boolean => {
309
+ if (p.includes("\0")) throw new Error("ERR_INVALID_ARG_VALUE");
310
+ return files.isFile(p);
311
+ },
312
+ };
313
+ expect(
314
+ studyFindings(DIR, {
315
+ files: probe,
316
+ listDir: memoryListDir(files),
317
+ codec: studyRegistryCodec,
318
+ }).map((f) => f.problem),
319
+ ).toEqual(["result-file"]);
320
+ });
321
+ });
322
+
323
+ /** A plan as a study would hold it once the author has checked its drawn units. */
324
+ const FILLED = planText({
325
+ question: "How many rules can a linter check?",
326
+ population: {
327
+ source: "the crawl of 2026-10-01",
328
+ draw: { kind: "all", method: "census" },
329
+ },
330
+ arms: [],
331
+ primary_outcome: "the share of rules a linter can check",
332
+ author_check: { by: "A. Author", date: "2026-10-14", quote: "Go." },
333
+ });
334
+
335
+ /** A registry with a headline measurement of one running study whose row names `plan`. */
336
+ const HEADLINE = (plan: string): StudyRegistry => ({
337
+ prefix: "P1",
338
+ author: "A. Author",
339
+ measurements: [
340
+ {
341
+ id: "M1",
342
+ title: "Rule census",
343
+ role: "headline",
344
+ approval: { by: "A. Author", date: "2026-10-12", quote: "Yes." },
345
+ },
346
+ ],
347
+ studies: [study({ status: "running", measurement: "M1", plan })],
348
+ });
349
+
350
+ describe("repro/study-registered — the plan a row names, read from its folder", () => {
351
+ it("is silent on a plan.json the author checked", () => {
352
+ expect(
353
+ lint({
354
+ ...registered(HEADLINE("plan.json")),
355
+ [`${DIR}/repro/P1-S01-baseline/plan.json`]: FILLED,
356
+ }),
357
+ ).toEqual([]);
358
+ });
359
+
360
+ it("reports a plan that is not there", () => {
361
+ expect(messages(registered(HEADLINE("plan.json")))).toEqual([
362
+ studyMessage({
363
+ problem: "bad-plan",
364
+ id: "P1-S01",
365
+ path: "plan.json",
366
+ why: "missing",
367
+ }),
368
+ ]);
369
+ });
370
+
371
+ it("reports a plan that is not JSON, with the reason", () => {
372
+ expect(
373
+ messages({
374
+ ...registered(HEADLINE("plan.json")),
375
+ [`${DIR}/repro/P1-S01-baseline/plan.json`]: "{",
376
+ }),
377
+ ).toEqual([
378
+ studyMessage({
379
+ problem: "bad-plan",
380
+ id: "P1-S01",
381
+ path: "plan.json",
382
+ why: "unreadable",
383
+ reasons: "not JSON",
384
+ }),
385
+ ]);
386
+ });
387
+
388
+ it("reports a running study of the headline whose plan is still the template", () => {
389
+ expect(
390
+ lint({
391
+ ...registered(HEADLINE("plan.json")),
392
+ [`${DIR}/repro/P1-S01-baseline/plan.json`]: planText(planTemplate()),
393
+ }).map((r) => [r.messageId, r.line]),
394
+ ).toEqual([
395
+ ["bad-plan", 1],
396
+ ["author-check", 1],
397
+ ]);
398
+ });
399
+ });
400
+
401
+ describe("repro/study-registered — a plan the disk cannot hold", () => {
402
+ it("reads as missing, and the disk is never asked", () => {
403
+ const r = HEADLINE("a\u0000.json");
404
+ const files = memoryFiles(registered(r));
405
+ const probe = {
406
+ ...files,
407
+ isFile: (p: Parameters<typeof files.isFile>[0]): boolean => {
408
+ if (p.includes("\0")) throw new Error("ERR_INVALID_ARG_VALUE");
409
+ return files.isFile(p);
410
+ },
411
+ };
412
+ expect(
413
+ studyFindings(DIR, {
414
+ files: probe,
415
+ listDir: memoryListDir(files),
416
+ codec: studyRegistryCodec,
417
+ }).map((f) => f.problem),
418
+ ).toEqual(["bad-plan"]);
419
+ });
420
+ });
421
+
422
+ describe("repro/study-plan-name — a plan under its old name", () => {
423
+ const OLD = {
424
+ ...registered(HEADLINE("prereg.json")),
425
+ [`${DIR}/repro/P1-S01-baseline/prereg.json`]: FILLED,
426
+ };
427
+
428
+ it("is read as the plan: repro/study-registered is silent", () => {
429
+ expect(lint(OLD)).toEqual([]);
430
+ });
431
+
432
+ it("is reported by repro/study-plan-name, naming plan.json", () => {
433
+ expect(lint(OLD, `${DIR}/PIPELINE-STATUS.md`, "study-plan-name")).toEqual([
434
+ {
435
+ messageId: "legacy-plan",
436
+ message: studyMessage({
437
+ problem: "legacy-plan",
438
+ id: "P1-S01",
439
+ path: "prereg.json",
440
+ }),
441
+ line: 1,
442
+ },
443
+ ]);
444
+ });
445
+
446
+ it("is silent on plan.json, and on any file but PIPELINE-STATUS.md", () => {
447
+ const tree = {
448
+ ...registered(HEADLINE("plan.json")),
449
+ [`${DIR}/repro/P1-S01-baseline/plan.json`]: FILLED,
450
+ };
451
+ expect([
452
+ lint(tree, `${DIR}/PIPELINE-STATUS.md`, "study-plan-name"),
453
+ lint(OLD, `${DIR}/paper.tex`, "study-plan-name"),
454
+ ]).toEqual([[], []]);
455
+ });
456
+ });
@@ -0,0 +1,239 @@
1
+ /**
2
+ * `repro/study-registered` — every study folder at the top of a paper's `repro/` has exactly one row in
3
+ * `repro/studies.json`, every row names a folder that exists, a row's measurement and plan hold
4
+ * together, and the index in `repro/README.md` is the one the registry generates. Beside it,
5
+ * `repro/study-plan-name`, a warning: a plan still named `prereg.json`.
6
+ *
7
+ * What is judged, and why, is `src/domain/study-registry.ts` and docs/rules/repro/study-registered.md.
8
+ * This file lists `repro/`, reads the registry, the README, the result files and the plans, and reports.
9
+ *
10
+ * ── WHY A RULE OF ITS OWN, NOT MORE OF `repro/allowed-files-only` ─────────────────────
11
+ * `repro/allowed-files-only` judges what may stand at the top of `repro/`, by name, and accepts the
12
+ * dated `<slug>-YYYY-MM-DD` folders of a paper that predates the registry. This rule judges the
13
+ * registry against the folders. A paper that keeps its dated folders turns this one off and keeps
14
+ * its layout judged.
15
+ *
16
+ * ── WHICH FILE IT REPORTS ON ─────────────────────────────────────────────────────
17
+ * `PIPELINE-STATUS.md`, first line, like the layout rules: every paper has it, and ESLint lints no
18
+ * file in `repro/`.
19
+ */
20
+ import { basename, dirname, join, posix } from "node:path";
21
+ import { callerPath } from "./caller-path.ts";
22
+ import {
23
+ README,
24
+ REPRO_BYPRODUCTS,
25
+ REPRO_DIR,
26
+ REPRO_DIRS,
27
+ } from "./domain/repro-layout.ts";
28
+ import {
29
+ LEGACY_PLAN_FILE,
30
+ PLAN_NAME_PROBLEMS,
31
+ STUDIES_FILE,
32
+ judgeStudies,
33
+ planKeys,
34
+ studyMessage,
35
+ type PlanState,
36
+ type RegistryState,
37
+ type StudyFinding,
38
+ } from "./domain/study-registry.ts";
39
+ import type { FolderLayoutDeps } from "./folder-layout-rule.ts";
40
+ import type {
41
+ FolderRuleContext,
42
+ FolderRuleModule,
43
+ } from "./folder-venue-rule.ts";
44
+ import type { Files } from "./ports/files.ts";
45
+ import type { StudyRegistryCodec } from "./ports/study-registry-codec.ts";
46
+ import { reproEntriesOf } from "./repro-layout-rule.ts";
47
+ import { rulePageUrl } from "./venue-rules.ts";
48
+
49
+ const STATUS_FILE = "PIPELINE-STATUS.md";
50
+
51
+ /** What the rule reads with: the disk, its listings, and the registry's parser. */
52
+ export interface StudyRegistryDeps extends FolderLayoutDeps {
53
+ readonly codec: StudyRegistryCodec;
54
+ }
55
+
56
+ /** The text of the file at `p`; null when there is none, or a directory sits there. */
57
+ const textAt = (files: Files, p: string): string | null => {
58
+ const b = files.isFile(callerPath(p)) ? files.readBytes(callerPath(p)) : null;
59
+ return b === null ? null : new TextDecoder().decode(b);
60
+ };
61
+
62
+ /** The registry of the paper folder `dir`: absent, unreadable with why, or read. */
63
+ export function registryOf(
64
+ dir: string,
65
+ deps: Pick<StudyRegistryDeps, "files" | "codec">,
66
+ ): RegistryState {
67
+ const text = textAt(deps.files, join(dir, REPRO_DIR, STUDIES_FILE));
68
+ if (text === null) return { kind: "absent" };
69
+ const parsed = deps.codec.parse(text);
70
+ return parsed.ok
71
+ ? { kind: "read", registry: parsed.registry }
72
+ : { kind: "unreadable", why: parsed.why };
73
+ }
74
+
75
+ /** The study folders at the top of `<dir>/repro/`: every folder but the shared ones and the byproducts. */
76
+ const studyFolders = (dir: string, deps: FolderLayoutDeps): readonly string[] =>
77
+ reproEntriesOf(dir, deps)
78
+ .filter(
79
+ (e) =>
80
+ e.kind === "dir" &&
81
+ !REPRO_DIRS.includes(e.name) &&
82
+ !REPRO_BYPRODUCTS.includes(e.name),
83
+ )
84
+ .map((e) => e.name);
85
+
86
+ /** Each `<folder>/<result_file>` of `r` that is a file. */
87
+ const existingResults = (
88
+ dir: string,
89
+ r: RegistryState,
90
+ files: Files,
91
+ ): ReadonlySet<string> =>
92
+ new Set(
93
+ (r.kind === "read" ? r.registry.studies : [])
94
+ .flatMap((s) =>
95
+ s.result_file === undefined ? [] : [`${s.folder}/${s.result_file}`],
96
+ )
97
+ // A NUL is no path the disk can hold, and probing one throws: such a row reads as missing.
98
+ .filter(
99
+ (p) =>
100
+ !p.includes("\0") &&
101
+ files.isFile(callerPath(join(dir, REPRO_DIR, p))),
102
+ ),
103
+ );
104
+
105
+ /**
106
+ * The plan each row of `r` names, read and parsed, by `<folder>/<plan>`; a plan not there is left out.
107
+ * A plan still named `prereg.json` is parsed by the loose schema of its old name.
108
+ */
109
+ const plansOf = (
110
+ dir: string,
111
+ r: RegistryState,
112
+ deps: Pick<StudyRegistryDeps, "files" | "codec">,
113
+ ): ReadonlyMap<string, PlanState> =>
114
+ new Map(
115
+ (r.kind === "read" ? planKeys(r.registry) : [])
116
+ // A NUL is no path the disk can hold, and probing one throws: such a plan reads as missing.
117
+ .filter((key) => !key.includes("\0"))
118
+ .flatMap((key): readonly (readonly [string, PlanState])[] => {
119
+ const text = textAt(deps.files, join(dir, REPRO_DIR, key));
120
+ if (text === null) return [];
121
+ const parse =
122
+ posix.basename(key) === LEGACY_PLAN_FILE
123
+ ? deps.codec.parseLegacyPlan
124
+ : deps.codec.parsePlan;
125
+ return [[key, parse(text)]];
126
+ }),
127
+ );
128
+
129
+ /** Every finding of the paper folder `dir`'s study registry — THE function behind both rules. */
130
+ export function studyFindings(
131
+ dir: string,
132
+ deps: StudyRegistryDeps,
133
+ ): readonly StudyFinding[] {
134
+ const registry = registryOf(dir, deps);
135
+ return judgeStudies({
136
+ folders: studyFolders(dir, deps),
137
+ registry,
138
+ readme: textAt(deps.files, join(dir, REPRO_DIR, README)),
139
+ existingResults: existingResults(dir, registry, deps.files),
140
+ plans: plansOf(dir, registry, deps),
141
+ });
142
+ }
143
+
144
+ /** The problems `repro/study-registered` reports, each its own message id; the text is `studyMessage`. */
145
+ const PROBLEMS: readonly StudyFinding["problem"][] = [
146
+ "no-registry",
147
+ "unreadable",
148
+ "unregistered",
149
+ "no-folder",
150
+ "two-rows",
151
+ "bad-id",
152
+ "duplicate-id",
153
+ "folder-name",
154
+ "long-title",
155
+ "no-result",
156
+ "result-file",
157
+ "no-successor",
158
+ "stale-index",
159
+ "unknown-measurement",
160
+ "empty-measurement",
161
+ "unknown-requirement",
162
+ "headline-approval",
163
+ "bad-plan",
164
+ "author-check",
165
+ ];
166
+
167
+ /** A rule's meta: its page, and a message id per problem, the whole text being `studyMessage`. */
168
+ const metaOf = (
169
+ name: string,
170
+ description: string,
171
+ problems: readonly StudyFinding["problem"][],
172
+ ): FolderRuleModule["meta"] => ({
173
+ type: "suggestion",
174
+ docs: { description, url: rulePageUrl(name) },
175
+ schema: [],
176
+ messages: Object.fromEntries(problems.map((p) => [p, "{{text}}"])),
177
+ });
178
+
179
+ /** A rule that reports the findings of `problems`, reading the folder through `deps`. */
180
+ const ruleOf = (
181
+ meta: FolderRuleModule["meta"],
182
+ problems: readonly StudyFinding["problem"][],
183
+ deps: StudyRegistryDeps,
184
+ ): FolderRuleModule => ({
185
+ meta,
186
+ create(context: FolderRuleContext) {
187
+ if (basename(context.filename) !== STATUS_FILE) return {};
188
+ return {
189
+ root() {
190
+ const at = context.sourceCode.getLocFromIndex(0);
191
+ studyFindings(dirname(context.filename), deps)
192
+ .filter((f) => problems.includes(f.problem))
193
+ .forEach((f) => {
194
+ context.report({
195
+ loc: { start: at, end: at },
196
+ messageId: f.problem,
197
+ data: { text: studyMessage(f) },
198
+ });
199
+ });
200
+ },
201
+ };
202
+ },
203
+ });
204
+
205
+ /**
206
+ * The two rules, reading the folder through `deps`: `study-registered`, the registry's errors, and
207
+ * `study-plan-name`, a warning for a plan still named `prereg.json` — read as the plan, so a paper
208
+ * that wrote one before `plan.json` existed is not broken, and told the name to move to.
209
+ */
210
+ export function studyRegistryRules(
211
+ deps: StudyRegistryDeps,
212
+ ): Readonly<Record<"study-registered" | "study-plan-name", FolderRuleModule>> {
213
+ return {
214
+ "study-registered": ruleOf(
215
+ metaOf(
216
+ "repro/study-registered",
217
+ "every study folder at the top of a paper's repro/ has exactly one row in repro/studies.json, every row names a folder that exists, its measurement and its plan hold together, and repro/README.md's index is the one the registry generates",
218
+ PROBLEMS,
219
+ ),
220
+ PROBLEMS,
221
+ deps,
222
+ ),
223
+ "study-plan-name": ruleOf(
224
+ metaOf(
225
+ "repro/study-plan-name",
226
+ "a study's plan is named plan.json, not prereg.json",
227
+ PLAN_NAME_PROBLEMS,
228
+ ),
229
+ PLAN_NAME_PROBLEMS,
230
+ deps,
231
+ ),
232
+ };
233
+ }
234
+
235
+ /** The levels they are on at in paperlint's own config, for every paper's `PIPELINE-STATUS.md`. */
236
+ export const STUDY_REGISTRY_RULE_LEVELS: Readonly<{
237
+ "repro/study-registered": "error";
238
+ "repro/study-plan-name": "warn";
239
+ }> = { "repro/study-registered": "error", "repro/study-plan-name": "warn" };