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.
- package/README.md +2 -0
- package/dist/adapters/study-registry/index.d.ts +6 -0
- package/dist/adapters/study-registry/index.d.ts.map +1 -0
- package/dist/adapters/study-registry/index.js +58 -0
- package/dist/adapters/study-registry/index.js.map +1 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +40 -2
- package/dist/cli.js.map +1 -1
- package/dist/domain/repro-layout.d.ts +12 -4
- package/dist/domain/repro-layout.d.ts.map +1 -1
- package/dist/domain/repro-layout.js +49 -18
- package/dist/domain/repro-layout.js.map +1 -1
- package/dist/domain/scaffold-layout.d.ts +4 -3
- package/dist/domain/scaffold-layout.d.ts.map +1 -1
- package/dist/domain/scaffold-layout.js +7 -4
- package/dist/domain/scaffold-layout.js.map +1 -1
- package/dist/domain/study-registry.d.ts +176 -0
- package/dist/domain/study-registry.d.ts.map +1 -0
- package/dist/domain/study-registry.js +371 -0
- package/dist/domain/study-registry.js.map +1 -0
- package/dist/ports/study-registry-codec.d.ts +11 -0
- package/dist/ports/study-registry-codec.d.ts.map +1 -0
- package/dist/ports/study-registry-codec.js +2 -0
- package/dist/ports/study-registry-codec.js.map +1 -0
- package/dist/repro-layout-rule.d.ts.map +1 -1
- package/dist/repro-layout-rule.js +4 -3
- package/dist/repro-layout-rule.js.map +1 -1
- package/dist/studies.d.ts +27 -0
- package/dist/studies.d.ts.map +1 -0
- package/dist/studies.js +160 -0
- package/dist/studies.js.map +1 -0
- package/dist/study-registry-rule.d.ts +17 -0
- package/dist/study-registry-rule.d.ts.map +1 -0
- package/dist/study-registry-rule.js +115 -0
- package/dist/study-registry-rule.js.map +1 -0
- package/docs/design/study-registry.md +130 -0
- package/docs/layout.md +47 -44
- package/docs/rules/paper/allowed-files-only.md +3 -2
- package/docs/rules/repro/allowed-files-only.md +40 -30
- package/docs/rules/repro/study-registered.md +162 -0
- package/docs/rules/study/declare-sample.md +1 -1
- package/docs/rules.md +2 -1
- package/package.json +1 -1
- package/skills/build-benchmark/SKILL.md +20 -1
- package/skills/build-benchmark/SKILL.md.spec.ts +19 -0
- package/skills/build-label-page/SKILL.md +7 -4
- package/skills/build-label-page/SKILL.md.spec.ts +6 -3
- package/skills/paper-pipeline/references/artifact-checklist.md +2 -1
- package/skills/paper-pipeline/references/pipeline-status-template.md +5 -3
- package/skills/render-paper/check-render.sh +9 -0
- package/skills/render-paper/render-paper.harness.mjs +102 -0
- package/skills/run-agent-grid/SKILL.md +8 -1
- package/skills/run-agent-grid/SKILL.md.spec.ts +7 -0
- package/src/adapters/study-registry/index.test.ts +144 -0
- package/src/adapters/study-registry/index.ts +70 -0
- package/src/cli.ts +51 -2
- package/src/domain/paper-layout.test.ts +1 -1
- package/src/domain/repro-layout.test.ts +18 -9
- package/src/domain/repro-layout.ts +58 -18
- package/src/domain/scaffold-layout.ts +7 -4
- package/src/domain/study-registry.test.ts +657 -0
- package/src/domain/study-registry.ts +598 -0
- package/src/folder-layout-hook.test.ts +10 -0
- package/src/new-paper.test.ts +28 -2
- package/src/ports/study-registry-codec.ts +11 -0
- package/src/repro-layout-rule.ts +4 -3
- package/src/rule-cases.test.ts +41 -0
- package/src/studies-cli.test.ts +113 -0
- package/src/studies.test.ts +353 -0
- package/src/studies.ts +259 -0
- package/src/study-registry-rule.test.ts +317 -0
- package/src/study-registry-rule.ts +172 -0
|
@@ -0,0 +1,657 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The study registry (`src/domain/study-registry.ts`): the judge over `repro/`'s study folders, the
|
|
3
|
+
* registry and the README's index; the generated index; the next id and the slug. Both halves: a
|
|
4
|
+
* registered `repro/` with its index in sync is clean, and each defect is reported once, with a text
|
|
5
|
+
* that stands alone.
|
|
6
|
+
*/
|
|
7
|
+
import { format } from "prettier";
|
|
8
|
+
import { describe, expect, it } from "vitest";
|
|
9
|
+
import {
|
|
10
|
+
INDEX_BEGIN,
|
|
11
|
+
INDEX_END,
|
|
12
|
+
addStudy,
|
|
13
|
+
embeddedIndex,
|
|
14
|
+
isStudyKind,
|
|
15
|
+
indexBlock,
|
|
16
|
+
judgeStudies,
|
|
17
|
+
nextStudyId,
|
|
18
|
+
registryText,
|
|
19
|
+
studyIndex,
|
|
20
|
+
studyMessage,
|
|
21
|
+
studyReadme,
|
|
22
|
+
studySlug,
|
|
23
|
+
withIndex,
|
|
24
|
+
type RegistryState,
|
|
25
|
+
type Study,
|
|
26
|
+
type StudyInput,
|
|
27
|
+
type StudyRegistry,
|
|
28
|
+
} from "./study-registry.ts";
|
|
29
|
+
|
|
30
|
+
const study = (over: Partial<Study> = {}): Study => ({
|
|
31
|
+
id: "P1-S01",
|
|
32
|
+
folder: "P1-S01-baseline",
|
|
33
|
+
title: "Baseline pass rate",
|
|
34
|
+
question: "How often does the agent pass?",
|
|
35
|
+
kind: "measurement",
|
|
36
|
+
status: "designing",
|
|
37
|
+
date: "2026-10-15",
|
|
38
|
+
in_paper: "no",
|
|
39
|
+
...over,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const reg = (...studies: Study[]): StudyRegistry => ({
|
|
43
|
+
prefix: "P1",
|
|
44
|
+
studies,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const read = (r: StudyRegistry): RegistryState => ({
|
|
48
|
+
kind: "read",
|
|
49
|
+
registry: r,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
/** A `repro/` whose folders are exactly the rows', with its index in sync, then `over`. */
|
|
53
|
+
const input = (
|
|
54
|
+
r: StudyRegistry,
|
|
55
|
+
over: Partial<StudyInput> = {},
|
|
56
|
+
): StudyInput => ({
|
|
57
|
+
folders: r.studies.map((s) => s.folder),
|
|
58
|
+
registry: read(r),
|
|
59
|
+
readme: withIndex("# Reproduction index\n", r),
|
|
60
|
+
existingResults: new Set(),
|
|
61
|
+
...over,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const problems = (i: StudyInput): readonly string[] =>
|
|
65
|
+
judgeStudies(i).map((f) => f.problem);
|
|
66
|
+
|
|
67
|
+
describe("judgeStudies — silent", () => {
|
|
68
|
+
it("on a registered repro/ with its index in sync", () => {
|
|
69
|
+
const done = study({
|
|
70
|
+
id: "P1-S02",
|
|
71
|
+
folder: "P1-S02-ablation",
|
|
72
|
+
status: "done",
|
|
73
|
+
result: "41 of 60 pass",
|
|
74
|
+
result_file: "out/results.tsv",
|
|
75
|
+
});
|
|
76
|
+
const old = study({
|
|
77
|
+
id: "P1-S03",
|
|
78
|
+
folder: "P1-S03-old",
|
|
79
|
+
status: "superseded",
|
|
80
|
+
superseded_by: "P1-S02",
|
|
81
|
+
});
|
|
82
|
+
const r = reg(study(), done, old);
|
|
83
|
+
expect(
|
|
84
|
+
judgeStudies(
|
|
85
|
+
input(r, {
|
|
86
|
+
existingResults: new Set(["P1-S02-ablation/out/results.tsv"]),
|
|
87
|
+
}),
|
|
88
|
+
),
|
|
89
|
+
).toEqual([]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("on a paper with no study folder and no registry", () => {
|
|
93
|
+
expect(
|
|
94
|
+
judgeStudies({
|
|
95
|
+
folders: [],
|
|
96
|
+
registry: { kind: "absent" },
|
|
97
|
+
readme: null,
|
|
98
|
+
existingResults: new Set(),
|
|
99
|
+
}),
|
|
100
|
+
).toEqual([]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("on an empty registry with its index", () => {
|
|
104
|
+
expect(judgeStudies(input(reg()))).toEqual([]);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("on ids of three digits, and a title of exactly eight words", () => {
|
|
108
|
+
const r = reg(
|
|
109
|
+
study({
|
|
110
|
+
id: "P1-S100",
|
|
111
|
+
folder: "P1-S100-x",
|
|
112
|
+
title: "one two three four five six seven eight",
|
|
113
|
+
}),
|
|
114
|
+
);
|
|
115
|
+
expect(judgeStudies(input(r))).toEqual([]);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe("judgeStudies — the registry itself", () => {
|
|
120
|
+
it("study folders and no studies.json: one finding naming the file and the folders", () => {
|
|
121
|
+
expect(
|
|
122
|
+
judgeStudies({
|
|
123
|
+
folders: ["a-2026-10-01", "b-2026-10-02"],
|
|
124
|
+
registry: { kind: "absent" },
|
|
125
|
+
readme: null,
|
|
126
|
+
existingResults: new Set(),
|
|
127
|
+
}),
|
|
128
|
+
).toEqual([
|
|
129
|
+
{ problem: "no-registry", folders: ["a-2026-10-01", "b-2026-10-02"] },
|
|
130
|
+
]);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("a studies.json that does not parse: one finding with the reason, nothing else judged", () => {
|
|
134
|
+
expect(
|
|
135
|
+
judgeStudies({
|
|
136
|
+
folders: ["a-2026-10-01"],
|
|
137
|
+
registry: { kind: "unreadable", why: "prefix: required" },
|
|
138
|
+
readme: null,
|
|
139
|
+
existingResults: new Set(),
|
|
140
|
+
}),
|
|
141
|
+
).toEqual([{ problem: "unreadable", why: "prefix: required" }]);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe("judgeStudies — folders and rows", () => {
|
|
146
|
+
it("an unregistered folder, with the next free id", () => {
|
|
147
|
+
const r = reg(study());
|
|
148
|
+
expect(
|
|
149
|
+
judgeStudies(
|
|
150
|
+
input(r, { folders: ["P1-S01-baseline", "spike-2026-10-12"] }),
|
|
151
|
+
),
|
|
152
|
+
).toEqual([
|
|
153
|
+
{ problem: "unregistered", folder: "spike-2026-10-12", next: "P1-S02" },
|
|
154
|
+
]);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("a dangling row: its folder does not exist", () => {
|
|
158
|
+
const r = reg(study());
|
|
159
|
+
expect(judgeStudies(input(r, { folders: [] }))).toEqual([
|
|
160
|
+
{ problem: "no-folder", id: "P1-S01", folder: "P1-S01-baseline" },
|
|
161
|
+
]);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("two rows for one folder, reported once", () => {
|
|
165
|
+
const r = reg(study(), study({ id: "P1-S02" }));
|
|
166
|
+
expect(problems(input(r))).toEqual(["two-rows", "folder-name"]);
|
|
167
|
+
expect(
|
|
168
|
+
judgeStudies(input(r)).find((f) => f.problem === "two-rows"),
|
|
169
|
+
).toEqual({
|
|
170
|
+
problem: "two-rows",
|
|
171
|
+
folder: "P1-S01-baseline",
|
|
172
|
+
ids: ["P1-S01", "P1-S02"],
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
describe("judgeStudies — ids and folder names", () => {
|
|
178
|
+
it.each([
|
|
179
|
+
["another prefix", "P2-S01"],
|
|
180
|
+
["one digit", "P1-S1"],
|
|
181
|
+
["no S", "P1-01"],
|
|
182
|
+
["lowercase", "p1-s01"],
|
|
183
|
+
])("a bad id — %s: %s", (_why, id) => {
|
|
184
|
+
const r = reg(study({ id, folder: `${id}-x` }));
|
|
185
|
+
expect(judgeStudies(input(r))).toEqual([
|
|
186
|
+
{ problem: "bad-id", id, prefix: "P1" },
|
|
187
|
+
]);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("a duplicate id, reported once", () => {
|
|
191
|
+
const r = reg(
|
|
192
|
+
study(),
|
|
193
|
+
study({ folder: "P1-S01-again" }),
|
|
194
|
+
study({ folder: "P1-S01-thrice" }),
|
|
195
|
+
);
|
|
196
|
+
expect(judgeStudies(input(r))).toEqual([
|
|
197
|
+
{ problem: "duplicate-id", id: "P1-S01" },
|
|
198
|
+
]);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it.each([
|
|
202
|
+
["another id", "P1-S02-baseline"],
|
|
203
|
+
["no slug", "P1-S01"],
|
|
204
|
+
["a capital in the slug", "P1-S01-Baseline"],
|
|
205
|
+
["an underscore", "P1-S01-base_line"],
|
|
206
|
+
["the dated form", "baseline-2026-10-12"],
|
|
207
|
+
])("a folder that is not <id>-<slug> — %s: %s", (_why, folder) => {
|
|
208
|
+
const r = reg(study({ folder }));
|
|
209
|
+
expect(judgeStudies(input(r))).toEqual([
|
|
210
|
+
{ problem: "folder-name", id: "P1-S01", folder },
|
|
211
|
+
]);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("a title over eight words", () => {
|
|
215
|
+
const r = reg(
|
|
216
|
+
study({ title: "one two three four five six seven eight nine" }),
|
|
217
|
+
);
|
|
218
|
+
expect(judgeStudies(input(r))).toEqual([
|
|
219
|
+
{ problem: "long-title", id: "P1-S01", words: 9 },
|
|
220
|
+
]);
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
describe("judgeStudies — status", () => {
|
|
225
|
+
it("done without result and result_file", () => {
|
|
226
|
+
const r = reg(study({ status: "done" }));
|
|
227
|
+
expect(judgeStudies(input(r))).toEqual([
|
|
228
|
+
{
|
|
229
|
+
problem: "no-result",
|
|
230
|
+
id: "P1-S01",
|
|
231
|
+
missing: ["result", "result_file"],
|
|
232
|
+
},
|
|
233
|
+
]);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("done with a result_file that does not exist", () => {
|
|
237
|
+
const r = reg(
|
|
238
|
+
study({ status: "done", result: "x", result_file: "results.tsv" }),
|
|
239
|
+
);
|
|
240
|
+
expect(judgeStudies(input(r))).toEqual([
|
|
241
|
+
{
|
|
242
|
+
problem: "result-file",
|
|
243
|
+
id: "P1-S01",
|
|
244
|
+
path: "results.tsv",
|
|
245
|
+
why: "missing",
|
|
246
|
+
},
|
|
247
|
+
]);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it("a result_file that exists is fine on any status", () => {
|
|
251
|
+
const r = reg(study({ status: "running", result_file: "r.tsv" }));
|
|
252
|
+
expect(
|
|
253
|
+
judgeStudies(
|
|
254
|
+
input(r, { existingResults: new Set(["P1-S01-baseline/r.tsv"]) }),
|
|
255
|
+
),
|
|
256
|
+
).toEqual([]);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("superseded without a target, and with one that is not in the registry", () => {
|
|
260
|
+
const r = reg(
|
|
261
|
+
study({ status: "superseded" }),
|
|
262
|
+
study({
|
|
263
|
+
id: "P1-S02",
|
|
264
|
+
folder: "P1-S02-x",
|
|
265
|
+
status: "superseded",
|
|
266
|
+
superseded_by: "P1-S09",
|
|
267
|
+
}),
|
|
268
|
+
);
|
|
269
|
+
expect(judgeStudies(input(r))).toEqual([
|
|
270
|
+
{ problem: "no-successor", id: "P1-S01", superseded_by: null },
|
|
271
|
+
{ problem: "no-successor", id: "P1-S02", superseded_by: "P1-S09" },
|
|
272
|
+
]);
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
describe("judgeStudies — a result_file outside its folder", () => {
|
|
277
|
+
it.each([
|
|
278
|
+
"../tools/numbers.tsv",
|
|
279
|
+
"/etc/passwd",
|
|
280
|
+
"a/../../b",
|
|
281
|
+
"..\\tools\\numbers.tsv",
|
|
282
|
+
"C:\\tools\\numbers.tsv",
|
|
283
|
+
"C:/tools/numbers.tsv",
|
|
284
|
+
])("a result_file outside the folder: %s", (path) => {
|
|
285
|
+
const r = reg(study({ status: "running", result_file: path }));
|
|
286
|
+
expect(judgeStudies(input(r))).toEqual([
|
|
287
|
+
{ problem: "result-file", id: "P1-S01", path, why: "outside" },
|
|
288
|
+
]);
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
describe("judgeStudies — the index", () => {
|
|
293
|
+
const r = reg(study());
|
|
294
|
+
|
|
295
|
+
it("no repro/README.md", () => {
|
|
296
|
+
expect(judgeStudies(input(r, { readme: null }))).toEqual([
|
|
297
|
+
{ problem: "stale-index", why: "no-readme" },
|
|
298
|
+
]);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it("a README.md without the markers", () => {
|
|
302
|
+
expect(judgeStudies(input(r, { readme: "# index\n" }))).toEqual([
|
|
303
|
+
{ problem: "stale-index", why: "no-markers" },
|
|
304
|
+
]);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it("a table prettier padded by display width, with wide characters, is in sync", async () => {
|
|
308
|
+
const wide = reg(study({ title: "測定 A" }));
|
|
309
|
+
const readme = await format(withIndex("# index\n", wide), {
|
|
310
|
+
parser: "markdown",
|
|
311
|
+
});
|
|
312
|
+
expect(readme).not.toBe(withIndex("# index\n", wide));
|
|
313
|
+
expect(judgeStudies(input(wide, { readme }))).toEqual([]);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it("a title with a backslash before a pipe stays one cell through prettier", async () => {
|
|
317
|
+
const bs = reg(study({ title: "foo\\|bar" }));
|
|
318
|
+
const readme = await format(withIndex("# index\n", bs), {
|
|
319
|
+
parser: "markdown",
|
|
320
|
+
});
|
|
321
|
+
expect(judgeStudies(input(bs, { readme }))).toEqual([]);
|
|
322
|
+
expect(readme).toContain("foo\\\\\\|bar");
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it("a title carrying the end marker's text: in sync, and regenerating changes nothing", () => {
|
|
326
|
+
const m = reg(study({ title: `x ${INDEX_END} y` }));
|
|
327
|
+
const readme = withIndex("# index\n", m);
|
|
328
|
+
expect(judgeStudies(input(m, { readme }))).toEqual([]);
|
|
329
|
+
expect(withIndex(readme, m)).toBe(readme);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it("prose that mentions the begin marker is kept, and the real block is the one judged", () => {
|
|
333
|
+
const r1 = reg(study());
|
|
334
|
+
const prose = `# index\n\nThe table between \`${INDEX_BEGIN}\` and its end marker is generated.\n`;
|
|
335
|
+
const readme = withIndex(prose, r1);
|
|
336
|
+
expect(judgeStudies(input(r1, { readme }))).toEqual([]);
|
|
337
|
+
expect(withIndex(readme, r1)).toBe(readme);
|
|
338
|
+
expect(readme.startsWith(prose.trimEnd())).toBe(true);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it("a block that differs from the registry by one character", () => {
|
|
342
|
+
const readme = withIndex("# index\n", r).replace("designing", "running");
|
|
343
|
+
expect(judgeStudies(input(r, { readme }))).toEqual([
|
|
344
|
+
{ problem: "stale-index", why: "differs" },
|
|
345
|
+
]);
|
|
346
|
+
});
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
describe("the index", () => {
|
|
350
|
+
it("is a table padded the way prettier writes one, a row per study, in registry order", () => {
|
|
351
|
+
const r = reg(
|
|
352
|
+
study(),
|
|
353
|
+
study({
|
|
354
|
+
id: "P1-S02",
|
|
355
|
+
folder: "P1-S02-a-b",
|
|
356
|
+
title: "Pipes | and\nbreaks",
|
|
357
|
+
status: "done",
|
|
358
|
+
result: "41 of 60",
|
|
359
|
+
in_paper: "yes",
|
|
360
|
+
}),
|
|
361
|
+
);
|
|
362
|
+
expect(studyIndex(r)).toBe(
|
|
363
|
+
[
|
|
364
|
+
"| id | title | status | result | in paper | folder |",
|
|
365
|
+
"| ------ | ------------------- | --------- | -------- | -------- | ------------------------------------ |",
|
|
366
|
+
"| P1-S01 | Baseline pass rate | designing | — | no | [P1-S01-baseline/](P1-S01-baseline/) |",
|
|
367
|
+
"| P1-S02 | Pipes \\| and breaks | done | 41 of 60 | yes | [P1-S02-a-b/](P1-S02-a-b/) |",
|
|
368
|
+
].join("\n"),
|
|
369
|
+
);
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it.each([
|
|
373
|
+
["two studies, Cyrillic, a dash and a mark", 2],
|
|
374
|
+
["no study", 0],
|
|
375
|
+
])("is left as it is by prettier: %s", async (_why, n) => {
|
|
376
|
+
const r = reg(
|
|
377
|
+
...[
|
|
378
|
+
study(),
|
|
379
|
+
study({
|
|
380
|
+
id: "P1-S02",
|
|
381
|
+
folder: "P1-S02-a-b",
|
|
382
|
+
title: "Pipes | and\nbreaks — Проверка",
|
|
383
|
+
status: "done",
|
|
384
|
+
result: "41 of 60 — ✓",
|
|
385
|
+
in_paper: "yes",
|
|
386
|
+
}),
|
|
387
|
+
].slice(0, n),
|
|
388
|
+
);
|
|
389
|
+
const md = withIndex("# Reproduction index\n\nSome text.\n", r);
|
|
390
|
+
expect(await format(md, { parser: "markdown" })).toBe(md);
|
|
391
|
+
});
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
describe("the index of an empty registry, and its markers", () => {
|
|
395
|
+
it("of an empty registry is the header alone", () => {
|
|
396
|
+
expect(studyIndex(reg())).toBe(
|
|
397
|
+
[
|
|
398
|
+
"| id | title | status | result | in paper | folder |",
|
|
399
|
+
"| --- | ----- | ------ | ------ | -------- | ------ |",
|
|
400
|
+
].join("\n"),
|
|
401
|
+
);
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
it("sits between the two markers, a blank line on each side", () => {
|
|
405
|
+
expect(indexBlock(reg())).toBe(
|
|
406
|
+
`${INDEX_BEGIN}\n\n${studyIndex(reg())}\n\n${INDEX_END}`,
|
|
407
|
+
);
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
it("is read back from a README as the text between the markers, or null", () => {
|
|
411
|
+
expect(embeddedIndex(`# x\n\n${indexBlock(reg())}\n`)).toBe(
|
|
412
|
+
`\n\n${studyIndex(reg())}\n\n`,
|
|
413
|
+
);
|
|
414
|
+
expect(embeddedIndex("# x\n")).toBeNull();
|
|
415
|
+
expect(embeddedIndex(`${INDEX_END}\n${INDEX_BEGIN}\n`)).toBeNull();
|
|
416
|
+
});
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
describe("withIndex", () => {
|
|
420
|
+
const r = reg(study());
|
|
421
|
+
|
|
422
|
+
it("replaces the block in place and keeps the rest", () => {
|
|
423
|
+
const before = `# Index\n\nintro\n\n${indexBlock(reg())}\n\ntail\n`;
|
|
424
|
+
expect(withIndex(before, r)).toBe(
|
|
425
|
+
`# Index\n\nintro\n\n${indexBlock(r)}\n\ntail\n`,
|
|
426
|
+
);
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
it("appends the block to a README without markers", () => {
|
|
430
|
+
expect(withIndex("# Index\n\nintro", r)).toBe(
|
|
431
|
+
`# Index\n\nintro\n\n${indexBlock(r)}\n`,
|
|
432
|
+
);
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
it("writes a README when there is none", () => {
|
|
436
|
+
expect(withIndex(null, r)).toBe(
|
|
437
|
+
`# Reproduction index\n\n${indexBlock(r)}\n`,
|
|
438
|
+
);
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
it("is a fixed point: a second run changes nothing", () => {
|
|
442
|
+
const once = withIndex("# Index\n", r);
|
|
443
|
+
expect(withIndex(once, r)).toBe(once);
|
|
444
|
+
});
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
describe("nextStudyId", () => {
|
|
448
|
+
it("is S01 in an empty registry", () => {
|
|
449
|
+
expect(nextStudyId(reg())).toBe("P1-S01");
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
it("is one past the highest number, whatever the order and the gaps", () => {
|
|
453
|
+
expect(
|
|
454
|
+
nextStudyId(
|
|
455
|
+
reg(
|
|
456
|
+
study({ id: "P1-S07" }),
|
|
457
|
+
study({ id: "P1-S02" }),
|
|
458
|
+
study({ id: "bad" }),
|
|
459
|
+
),
|
|
460
|
+
),
|
|
461
|
+
).toBe("P1-S08");
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
it("grows past two digits", () => {
|
|
465
|
+
expect(nextStudyId(reg(study({ id: "P1-S99" })))).toBe("P1-S100");
|
|
466
|
+
});
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
describe("studySlug", () => {
|
|
470
|
+
it.each([
|
|
471
|
+
["Baseline pass rate", "baseline-pass-rate"],
|
|
472
|
+
[" GPT-5 vs. Claude: 3 arms ", "gpt-5-vs-claude-3-arms"],
|
|
473
|
+
["Café réglé", "cafe-regle"],
|
|
474
|
+
["Проверка", ""],
|
|
475
|
+
])("%s → %s", (title, slug) => {
|
|
476
|
+
expect(studySlug(title)).toBe(slug);
|
|
477
|
+
});
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
describe("registryText", () => {
|
|
481
|
+
it("is the registry as JSON, two-space indented, extra fields kept, with a final newline", () => {
|
|
482
|
+
const r = reg(study({ role: "counted", rules_under_test: ["a", "b"] }));
|
|
483
|
+
expect(registryText(r)).toBe(`${JSON.stringify(r, null, 2)}\n`);
|
|
484
|
+
expect(JSON.parse(registryText(r))).toEqual(r);
|
|
485
|
+
});
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
/** Each finding, and the head of the text it is reported with. */
|
|
489
|
+
const MESSAGES = [
|
|
490
|
+
[
|
|
491
|
+
{ problem: "no-registry", folders: ["a-2026-10-01", "b-2026-10-02"] },
|
|
492
|
+
'`repro/` holds study folders (`a-2026-10-01/`, `b-2026-10-02/`) but no `repro/studies.json`, the registry that gives every study an id, a plain name and a status. What to do: write `repro/studies.json` 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 `npx paperlint studies new <paper-dir> "<title>"`.',
|
|
493
|
+
],
|
|
494
|
+
[
|
|
495
|
+
{ problem: "unreadable", why: "prefix: required" },
|
|
496
|
+
"`repro/studies.json` is not a study registry: prefix: required. Its shape is on the rule's page.",
|
|
497
|
+
],
|
|
498
|
+
[
|
|
499
|
+
{ problem: "unregistered", folder: "spike-2026-10-12", next: "P1-S02" },
|
|
500
|
+
'`repro/spike-2026-10-12/` has no row in `repro/studies.json`, and every study folder has exactly one. What to do: add its row with the next free id, `P1-S02`, and rename the folder to `P1-S02-<slug>`; a new study is created with `npx paperlint studies new <paper-dir> "<title>"`, which does both.',
|
|
501
|
+
],
|
|
502
|
+
[
|
|
503
|
+
{ problem: "no-folder", id: "P1-S01", folder: "P1-S01-x" },
|
|
504
|
+
"`repro/studies.json`: the row `P1-S01` names the folder `repro/P1-S01-x/`, which does not exist. What to do: rename the study's folder to it, or correct the row's `folder`.",
|
|
505
|
+
],
|
|
506
|
+
[
|
|
507
|
+
{ problem: "two-rows", folder: "P1-S01-x", ids: ["P1-S01", "P1-S02"] },
|
|
508
|
+
"`repro/studies.json`: the rows `P1-S01`, `P1-S02` all name the folder `repro/P1-S01-x/`, and a study folder has exactly one row. What to do: keep one row for it.",
|
|
509
|
+
],
|
|
510
|
+
[
|
|
511
|
+
{ problem: "bad-id", id: "P1-S1", prefix: "P1" },
|
|
512
|
+
"`repro/studies.json`: the id `P1-S1` is not `P1-S` followed by two or more digits (`P1-S01`). What to do: give the row the next free id and rename its folder to match.",
|
|
513
|
+
],
|
|
514
|
+
[
|
|
515
|
+
{ problem: "duplicate-id", id: "P1-S01" },
|
|
516
|
+
"`repro/studies.json`: the id `P1-S01` is on more than one row, and an id names one study. What to do: give each other row the next free id and rename its folder to match.",
|
|
517
|
+
],
|
|
518
|
+
[
|
|
519
|
+
{ problem: "folder-name", id: "P1-S01", folder: "x" },
|
|
520
|
+
"`repro/studies.json`: the row `P1-S01` names the folder `x`, which is not `P1-S01-<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.",
|
|
521
|
+
],
|
|
522
|
+
[
|
|
523
|
+
{ problem: "long-title", id: "P1-S01", words: 9 },
|
|
524
|
+
"`repro/studies.json`: the title of `P1-S01` has 9 words; a title is a plain name of at most 8. What to do: shorten it, and put the detail in `question`.",
|
|
525
|
+
],
|
|
526
|
+
[
|
|
527
|
+
{ problem: "no-result", id: "P1-S01", missing: ["result"] },
|
|
528
|
+
"`repro/studies.json`: `P1-S01` is `done` without `result`. 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.",
|
|
529
|
+
],
|
|
530
|
+
[
|
|
531
|
+
{
|
|
532
|
+
problem: "result-file",
|
|
533
|
+
id: "P1-S01",
|
|
534
|
+
path: "r.tsv",
|
|
535
|
+
why: "missing",
|
|
536
|
+
},
|
|
537
|
+
"`repro/studies.json`: the `result_file` of `P1-S01`, `r.tsv`, is not a file in its folder. What to do: name a file that exists, relative to the study's folder.",
|
|
538
|
+
],
|
|
539
|
+
[
|
|
540
|
+
{
|
|
541
|
+
problem: "result-file",
|
|
542
|
+
id: "P1-S01",
|
|
543
|
+
path: "../r.tsv",
|
|
544
|
+
why: "outside",
|
|
545
|
+
},
|
|
546
|
+
"`repro/studies.json`: the `result_file` of `P1-S01`, `../r.tsv`, is outside its folder. What to do: name a file that exists, relative to the study's folder.",
|
|
547
|
+
],
|
|
548
|
+
[
|
|
549
|
+
{ problem: "no-successor", id: "P1-S01", superseded_by: null },
|
|
550
|
+
"`repro/studies.json`: `P1-S01` is `superseded` without `superseded_by`. What to do: name the id of the study that replaced it.",
|
|
551
|
+
],
|
|
552
|
+
[
|
|
553
|
+
{ problem: "no-successor", id: "P1-S01", superseded_by: "P1-S09" },
|
|
554
|
+
"`repro/studies.json`: `P1-S01` is `superseded` by `P1-S09`, which is no study in the registry. What to do: name the id of the study that replaced it.",
|
|
555
|
+
],
|
|
556
|
+
[
|
|
557
|
+
{ problem: "stale-index", why: "differs" },
|
|
558
|
+
"The index in `repro/README.md` (between the `studies:begin` and `studies:end` markers) is not the one `repro/studies.json` generates. What to do: run `npx paperlint studies index <paper-dir>`; the block is generated, never edited by hand.",
|
|
559
|
+
],
|
|
560
|
+
[
|
|
561
|
+
{ problem: "stale-index", why: "no-markers" },
|
|
562
|
+
"`repro/README.md` has no index of the studies (the `studies:begin` and `studies:end` markers). What to do: run `npx paperlint studies index <paper-dir>`; the block is generated, never edited by hand.",
|
|
563
|
+
],
|
|
564
|
+
[
|
|
565
|
+
{ problem: "stale-index", why: "no-readme" },
|
|
566
|
+
"`repro/README.md`, the index of the studies, does not exist. What to do: run `npx paperlint studies index <paper-dir>`; the block is generated, never edited by hand.",
|
|
567
|
+
],
|
|
568
|
+
] as const;
|
|
569
|
+
|
|
570
|
+
describe("studyMessage — stands alone", () => {
|
|
571
|
+
const RULE =
|
|
572
|
+
"Rule repro/study-registered: https://github.com/zernie/paperlint/blob/main/docs/rules/repro/study-registered.md";
|
|
573
|
+
|
|
574
|
+
it.each(MESSAGES)("%j", (finding, head) => {
|
|
575
|
+
expect(studyMessage(finding)).toBe(`${head}\n${RULE}`);
|
|
576
|
+
});
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
describe("addStudy", () => {
|
|
580
|
+
const NEW = {
|
|
581
|
+
title: "Baseline pass rate",
|
|
582
|
+
kind: "spike",
|
|
583
|
+
date: "2026-10-15",
|
|
584
|
+
} as const;
|
|
585
|
+
|
|
586
|
+
it("appends the next id, its folder, designing, not in the paper, an empty question", () => {
|
|
587
|
+
const r = reg(study({ id: "P1-S04", folder: "P1-S04-x", foo: 1 }));
|
|
588
|
+
const out = addStudy(r, { ...NEW, title: " Baseline\n pass rate " });
|
|
589
|
+
const added: Study = {
|
|
590
|
+
id: "P1-S05",
|
|
591
|
+
folder: "P1-S05-baseline-pass-rate",
|
|
592
|
+
title: "Baseline pass rate",
|
|
593
|
+
question: "",
|
|
594
|
+
kind: "spike",
|
|
595
|
+
status: "designing",
|
|
596
|
+
date: "2026-10-15",
|
|
597
|
+
in_paper: "no",
|
|
598
|
+
};
|
|
599
|
+
expect(out).toEqual({
|
|
600
|
+
ok: true,
|
|
601
|
+
registry: { prefix: "P1", studies: [...r.studies, added] },
|
|
602
|
+
study: added,
|
|
603
|
+
});
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
it.each([
|
|
607
|
+
["an empty title", " ", "the title is empty"],
|
|
608
|
+
[
|
|
609
|
+
"nine words",
|
|
610
|
+
"one two three four five six seven eight nine",
|
|
611
|
+
"the title has 9 words; a title is a plain name of at most 8 — the detail goes in the row's `question`",
|
|
612
|
+
],
|
|
613
|
+
[
|
|
614
|
+
"no Latin letter or digit",
|
|
615
|
+
"Проверка гипотезы",
|
|
616
|
+
"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)",
|
|
617
|
+
],
|
|
618
|
+
])("refuses %s", (_why, title, why) => {
|
|
619
|
+
expect(addStudy(reg(), { ...NEW, title })).toEqual({ ok: false, why });
|
|
620
|
+
});
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
describe("isStudyKind", () => {
|
|
624
|
+
it("is true for each kind and false for anything else", () => {
|
|
625
|
+
expect(
|
|
626
|
+
["measurement", "spike", "tooling", "data", "artifact", "salvage"].every(
|
|
627
|
+
isStudyKind,
|
|
628
|
+
),
|
|
629
|
+
).toBe(true);
|
|
630
|
+
expect(isStudyKind("benchmark")).toBe(false);
|
|
631
|
+
});
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
describe("studyReadme", () => {
|
|
635
|
+
it("frontmatter with the id and the quoted title, then the sections", () => {
|
|
636
|
+
expect(studyReadme(study({ title: 'A "quoted": title' }))).toBe(
|
|
637
|
+
[
|
|
638
|
+
"---",
|
|
639
|
+
"id: P1-S01",
|
|
640
|
+
'title: "A \\"quoted\\": title"',
|
|
641
|
+
"---",
|
|
642
|
+
"",
|
|
643
|
+
'# P1-S01 — A "quoted": title',
|
|
644
|
+
"",
|
|
645
|
+
"Its row in `../studies.json` holds its status. When the study ends, set `status`, `result`,",
|
|
646
|
+
"`result_file` and `in_paper` there, then run `npx paperlint studies index <paper-dir>`.",
|
|
647
|
+
"",
|
|
648
|
+
"## Question",
|
|
649
|
+
"",
|
|
650
|
+
"## How to run",
|
|
651
|
+
"",
|
|
652
|
+
"## Result",
|
|
653
|
+
"",
|
|
654
|
+
].join("\n"),
|
|
655
|
+
);
|
|
656
|
+
});
|
|
657
|
+
});
|