paperlint 10.2.0 → 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 (46) hide show
  1. package/dist/adapters/study-registry/index.d.ts +352 -1
  2. package/dist/adapters/study-registry/index.d.ts.map +1 -1
  3. package/dist/adapters/study-registry/index.js +171 -8
  4. package/dist/adapters/study-registry/index.js.map +1 -1
  5. package/dist/cli.d.ts.map +1 -1
  6. package/dist/cli.js +6 -4
  7. package/dist/cli.js.map +1 -1
  8. package/dist/domain/study-registry.d.ts +201 -5
  9. package/dist/domain/study-registry.d.ts.map +1 -1
  10. package/dist/domain/study-registry.js +224 -2
  11. package/dist/domain/study-registry.js.map +1 -1
  12. package/dist/ports/study-registry-codec.d.ts +6 -2
  13. package/dist/ports/study-registry-codec.d.ts.map +1 -1
  14. package/dist/studies.d.ts.map +1 -1
  15. package/dist/studies.js +7 -4
  16. package/dist/studies.js.map +1 -1
  17. package/dist/study-registry-rule.d.ts +12 -5
  18. package/dist/study-registry-rule.d.ts.map +1 -1
  19. package/dist/study-registry-rule.js +67 -37
  20. package/dist/study-registry-rule.js.map +1 -1
  21. package/docs/design/study-registry.md +131 -15
  22. package/docs/layout.md +23 -21
  23. package/docs/rules/repro/study-plan-name.md +70 -0
  24. package/docs/rules/repro/study-registered.md +164 -6
  25. package/docs/rules.md +53 -52
  26. package/package.json +1 -1
  27. package/skills/build-benchmark/SKILL.md +24 -12
  28. package/skills/build-benchmark/SKILL.md.spec.ts +23 -11
  29. package/skills/build-label-page/SKILL.md +9 -7
  30. package/skills/build-label-page/SKILL.md.spec.ts +8 -6
  31. package/skills/run-agent-grid/SKILL.md +10 -5
  32. package/skills/run-agent-grid/SKILL.md.spec.ts +9 -4
  33. package/src/adapters/study-registry/index.test.ts +340 -0
  34. package/src/adapters/study-registry/index.ts +268 -10
  35. package/src/cli.ts +6 -4
  36. package/src/domain/study-measurements.test.ts +629 -0
  37. package/src/domain/study-registry.test.ts +10 -1
  38. package/src/domain/study-registry.ts +456 -10
  39. package/src/new-paper.test.ts +1 -0
  40. package/src/ports/study-registry-codec.ts +6 -2
  41. package/src/rule-cases.test.ts +27 -0
  42. package/src/studies-cli.test.ts +2 -2
  43. package/src/studies.test.ts +6 -0
  44. package/src/studies.ts +12 -3
  45. package/src/study-registry-rule.test.ts +140 -1
  46. package/src/study-registry-rule.ts +106 -39
@@ -9,6 +9,8 @@ import { memoryFiles, memoryListDir } from "./adapters/memory/index.ts";
9
9
  import { studyRegistryCodec } from "./adapters/study-registry/index.ts";
10
10
  import {
11
11
  indexBlock,
12
+ planTemplate,
13
+ planText,
12
14
  registryText,
13
15
  studyReadme,
14
16
  withIndex,
@@ -101,17 +103,21 @@ describe("studies new", () => {
101
103
  status: "designing",
102
104
  date: "2026-10-15",
103
105
  in_paper: "no",
106
+ plan: "plan.json",
104
107
  };
105
108
  const r = P1(study(), added);
106
109
  expect({ code, err }).toEqual({ code: 0, err: [] });
107
110
  expect(after).toEqual({
108
111
  ...before,
109
112
  [`${REPRO}/P1-S02-ablation-of-the-guard/README.md`]: studyReadme(added),
113
+ [`${REPRO}/P1-S02-ablation-of-the-guard/plan.json`]:
114
+ planText(planTemplate()),
110
115
  [`${REPRO}/studies.json`]: registryText(r),
111
116
  [`${REPRO}/README.md`]: `# Reproduction index\n\nIntro.\n\n${indexBlock(r)}\n`,
112
117
  });
113
118
  expect(log).toEqual([
114
119
  "created papers/example/repro/P1-S02-ablation-of-the-guard/README.md",
120
+ "created papers/example/repro/P1-S02-ablation-of-the-guard/plan.json, the study's plan: its fields are there to fill in before the first run",
115
121
  "added P1-S02 to papers/example/repro/studies.json: kind spike, status designing, 2026-10-15",
116
122
  "wrote the index of 2 studies in papers/example/repro/README.md",
117
123
  "Next: write the study's one-sentence question in its row (`question`) and in its README.md. When it ends, set `status`, `result`, `result_file` and `in_paper` in its row, then run `npx paperlint studies index papers/example`.",
package/src/studies.ts CHANGED
@@ -3,8 +3,9 @@
3
3
  * `repro/studies.json`, and its index in `repro/README.md` (docs/design/study-registry.md).
4
4
  *
5
5
  * new <paper-dir> "<title>" [--kind <kind>]
6
- * the next id; `repro/<id>-<slug>/README.md` with the id and title in its frontmatter; the
7
- * row, `status: designing`, today's date; the index regenerated
6
+ * the next id; `repro/<id>-<slug>/README.md` with the id and title in its frontmatter, and
7
+ * `plan.json`, the plan's template; the row, `status: designing`, today's date, its `plan`;
8
+ * the index regenerated
8
9
  * index <paper-dir>
9
10
  * the index block of `repro/README.md`, regenerated from the registry
10
11
  *
@@ -18,11 +19,14 @@ import type { AbsolutePath } from "./domain/paths.ts";
18
19
  import { README, REPRO_DIR } from "./domain/repro-layout.ts";
19
20
  import {
20
21
  DEFAULT_KIND,
22
+ PLAN_FILE,
21
23
  STUDIES_FILE,
22
24
  STUDY_KINDS,
23
25
  addStudy,
24
26
  indexInSync,
25
27
  isStudyKind,
28
+ planTemplate,
29
+ planText,
26
30
  registryText,
27
31
  studyReadme,
28
32
  withIndex,
@@ -178,7 +182,7 @@ function newArgs(
178
182
  );
179
183
  }
180
184
 
181
- /** Write a new study's README.md, the registry, and the index; the lines that say so. */
185
+ /** Write a new study's README.md and plan.json, the registry, and the index; the lines that say so. */
182
186
  function writeStudy(
183
187
  p: Paper,
184
188
  added: { readonly registry: StudyRegistry; readonly study: Study },
@@ -188,11 +192,16 @@ function writeStudy(
188
192
  const at = (...names: readonly string[]): AbsolutePath =>
189
193
  callerPath(join(p.dir, REPRO_DIR, ...names));
190
194
  files.writeAtomic(at(study.folder, README), text(studyReadme(study)));
195
+ files.writeAtomic(
196
+ at(study.folder, PLAN_FILE),
197
+ text(planText(planTemplate())),
198
+ );
191
199
  files.writeAtomic(at(STUDIES_FILE), text(registryText(registry)));
192
200
  writeIndex(p.dir, registry, files);
193
201
  const repro = `${p.shown}/${REPRO_DIR}`;
194
202
  return [
195
203
  `created ${repro}/${study.folder}/${README}`,
204
+ `created ${repro}/${study.folder}/${PLAN_FILE}, the study's plan: its fields are there to fill in before the first run`,
196
205
  `added ${study.id} to ${repro}/${STUDIES_FILE}: kind ${study.kind}, status ${study.status}, ${study.date}`,
197
206
  `wrote the index of ${studiesCount(registry)} in ${repro}/${README}`,
198
207
  `Next: write the study's one-sentence question in its row (\`question\`) and in its README.md. When it ends, set \`status\`, \`result\`, \`result_file\` and \`in_paper\` in its row, then run \`npx paperlint studies index ${p.shown}\`.`,
@@ -9,6 +9,8 @@ import { describe, expect, it } from "vitest";
9
9
  import { memoryFiles, memoryListDir } from "./adapters/memory/index.ts";
10
10
  import { studyRegistryCodec } from "./adapters/study-registry/index.ts";
11
11
  import {
12
+ planTemplate,
13
+ planText,
12
14
  registryText,
13
15
  studyMessage,
14
16
  withIndex,
@@ -65,13 +67,14 @@ interface Reported {
65
67
  function lint(
66
68
  tree: Readonly<Record<string, string>>,
67
69
  file = `${DIR}/PIPELINE-STATUS.md`,
70
+ name: "study-registered" | "study-plan-name" = "study-registered",
68
71
  ): Reported[] {
69
72
  const files = memoryFiles(tree);
70
73
  const rule = studyRegistryRules({
71
74
  files,
72
75
  listDir: memoryListDir(files),
73
76
  codec: studyRegistryCodec,
74
- })["study-registered"];
77
+ })[name];
75
78
  const out: Reported[] = [];
76
79
  const visitor = rule.create({
77
80
  filename: file,
@@ -271,6 +274,7 @@ describe("repro/study-registered — level", () => {
271
274
  it("is an error from the start", () => {
272
275
  expect(STUDY_REGISTRY_RULE_LEVELS).toEqual({
273
276
  "repro/study-registered": "error",
277
+ "repro/study-plan-name": "warn",
274
278
  });
275
279
  });
276
280
  });
@@ -315,3 +319,138 @@ describe("repro/study-registered — a result_file the disk cannot hold", () =>
315
319
  ).toEqual(["result-file"]);
316
320
  });
317
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
+ });
@@ -1,10 +1,11 @@
1
1
  /**
2
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, and the index in `repro/README.md` is the
4
- * one the registry generates.
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`.
5
6
  *
6
7
  * What is judged, and why, is `src/domain/study-registry.ts` and docs/rules/repro/study-registered.md.
7
- * This file lists `repro/`, reads the registry, the README and the result files, and reports.
8
+ * This file lists `repro/`, reads the registry, the README, the result files and the plans, and reports.
8
9
  *
9
10
  * ── WHY A RULE OF ITS OWN, NOT MORE OF `repro/allowed-files-only` ─────────────────────
10
11
  * `repro/allowed-files-only` judges what may stand at the top of `repro/`, by name, and accepts the
@@ -16,7 +17,7 @@
16
17
  * `PIPELINE-STATUS.md`, first line, like the layout rules: every paper has it, and ESLint lints no
17
18
  * file in `repro/`.
18
19
  */
19
- import { basename, dirname, join } from "node:path";
20
+ import { basename, dirname, join, posix } from "node:path";
20
21
  import { callerPath } from "./caller-path.ts";
21
22
  import {
22
23
  README,
@@ -25,9 +26,13 @@ import {
25
26
  REPRO_DIRS,
26
27
  } from "./domain/repro-layout.ts";
27
28
  import {
29
+ LEGACY_PLAN_FILE,
30
+ PLAN_NAME_PROBLEMS,
28
31
  STUDIES_FILE,
29
32
  judgeStudies,
33
+ planKeys,
30
34
  studyMessage,
35
+ type PlanState,
31
36
  type RegistryState,
32
37
  type StudyFinding,
33
38
  } from "./domain/study-registry.ts";
@@ -97,7 +102,31 @@ const existingResults = (
97
102
  ),
98
103
  );
99
104
 
100
- /** Every finding of the paper folder `dir`'s study registry — THE function behind the rule. */
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. */
101
130
  export function studyFindings(
102
131
  dir: string,
103
132
  deps: StudyRegistryDeps,
@@ -108,10 +137,11 @@ export function studyFindings(
108
137
  registry,
109
138
  readme: textAt(deps.files, join(dir, REPRO_DIR, README)),
110
139
  existingResults: existingResults(dir, registry, deps.files),
140
+ plans: plansOf(dir, registry, deps),
111
141
  });
112
142
  }
113
143
 
114
- /** The problems a finding can be, each its own message id; the text is `studyMessage`. */
144
+ /** The problems `repro/study-registered` reports, each its own message id; the text is `studyMessage`. */
115
145
  const PROBLEMS: readonly StudyFinding["problem"][] = [
116
146
  "no-registry",
117
147
  "unreadable",
@@ -126,47 +156,84 @@ const PROBLEMS: readonly StudyFinding["problem"][] = [
126
156
  "result-file",
127
157
  "no-successor",
128
158
  "stale-index",
159
+ "unknown-measurement",
160
+ "empty-measurement",
161
+ "unknown-requirement",
162
+ "headline-approval",
163
+ "bad-plan",
164
+ "author-check",
129
165
  ];
130
166
 
131
- const META: FolderRuleModule["meta"] = {
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"] => ({
132
173
  type: "suggestion",
133
- docs: {
134
- description:
135
- "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, and repro/README.md's index is the one the registry generates",
136
- url: rulePageUrl("repro/study-registered"),
137
- },
174
+ docs: { description, url: rulePageUrl(name) },
138
175
  schema: [],
139
- // The whole text is `studyMessage` (src/domain/study-registry.ts).
140
- messages: Object.fromEntries(PROBLEMS.map((p) => [p, "{{text}}"])),
141
- };
176
+ messages: Object.fromEntries(problems.map((p) => [p, "{{text}}"])),
177
+ });
142
178
 
143
- /** The rule, reading the folder through `deps`. */
144
- export function studyRegistryRules(
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"][],
145
183
  deps: StudyRegistryDeps,
146
- ): Readonly<Record<"study-registered", FolderRuleModule>> {
147
- return {
148
- "study-registered": {
149
- meta: META,
150
- create(context: FolderRuleContext) {
151
- if (basename(context.filename) !== STATUS_FILE) return {};
152
- return {
153
- root() {
154
- const at = context.sourceCode.getLocFromIndex(0);
155
- studyFindings(dirname(context.filename), deps).forEach((f) => {
156
- context.report({
157
- loc: { start: at, end: at },
158
- messageId: f.problem,
159
- data: { text: studyMessage(f) },
160
- });
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) },
161
198
  });
162
- },
163
- };
199
+ });
164
200
  },
165
- },
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
+ ),
166
232
  };
167
233
  }
168
234
 
169
- /** The level it is on at in paperlint's own config, for every paper's `PIPELINE-STATUS.md`. */
170
- export const STUDY_REGISTRY_RULE_LEVELS: Readonly<
171
- Record<"repro/study-registered", "error">
172
- > = { "repro/study-registered": "error" };
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" };