paperlint 10.2.0 → 10.4.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 (147) hide show
  1. package/.github/workflows/ci.yml +15 -4
  2. package/README.md +1 -0
  3. package/dist/adapters/git/index.d.ts +10 -0
  4. package/dist/adapters/git/index.d.ts.map +1 -0
  5. package/dist/adapters/git/index.js +151 -0
  6. package/dist/adapters/git/index.js.map +1 -0
  7. package/dist/adapters/memory/index.d.ts +6 -0
  8. package/dist/adapters/memory/index.d.ts.map +1 -1
  9. package/dist/adapters/memory/index.js +8 -0
  10. package/dist/adapters/memory/index.js.map +1 -1
  11. package/dist/adapters/node/index.d.ts +1 -0
  12. package/dist/adapters/node/index.d.ts.map +1 -1
  13. package/dist/adapters/node/index.js +1 -0
  14. package/dist/adapters/node/index.js.map +1 -1
  15. package/dist/adapters/node/list-entries.io.d.ts +3 -0
  16. package/dist/adapters/node/list-entries.io.d.ts.map +1 -0
  17. package/dist/adapters/node/list-entries.io.js +24 -0
  18. package/dist/adapters/node/list-entries.io.js.map +1 -0
  19. package/dist/adapters/study-registry/index.d.ts +412 -1
  20. package/dist/adapters/study-registry/index.d.ts.map +1 -1
  21. package/dist/adapters/study-registry/index.js +245 -8
  22. package/dist/adapters/study-registry/index.js.map +1 -1
  23. package/dist/cli.d.ts.map +1 -1
  24. package/dist/cli.js +38 -6
  25. package/dist/cli.js.map +1 -1
  26. package/dist/domain/decide-first.d.ts +100 -0
  27. package/dist/domain/decide-first.d.ts.map +1 -0
  28. package/dist/domain/decide-first.js +102 -0
  29. package/dist/domain/decide-first.js.map +1 -0
  30. package/dist/domain/index-columns.d.ts +45 -0
  31. package/dist/domain/index-columns.d.ts.map +1 -0
  32. package/dist/domain/index-columns.js +62 -0
  33. package/dist/domain/index-columns.js.map +1 -0
  34. package/dist/domain/study-mode.d.ts +37 -0
  35. package/dist/domain/study-mode.d.ts.map +1 -0
  36. package/dist/domain/study-mode.js +45 -0
  37. package/dist/domain/study-mode.js.map +1 -0
  38. package/dist/domain/study-registry.d.ts +253 -10
  39. package/dist/domain/study-registry.d.ts.map +1 -1
  40. package/dist/domain/study-registry.js +325 -27
  41. package/dist/domain/study-registry.js.map +1 -1
  42. package/dist/outputs-before-decision-rule.d.ts +17 -0
  43. package/dist/outputs-before-decision-rule.d.ts.map +1 -0
  44. package/dist/outputs-before-decision-rule.js +90 -0
  45. package/dist/outputs-before-decision-rule.js.map +1 -0
  46. package/dist/ports/git-history.d.ts +44 -0
  47. package/dist/ports/git-history.d.ts.map +1 -0
  48. package/dist/ports/git-history.js +2 -0
  49. package/dist/ports/git-history.js.map +1 -0
  50. package/dist/ports/list-entries.d.ts +17 -0
  51. package/dist/ports/list-entries.d.ts.map +1 -0
  52. package/dist/ports/list-entries.js +2 -0
  53. package/dist/ports/list-entries.js.map +1 -0
  54. package/dist/ports/study-registry-codec.d.ts +9 -2
  55. package/dist/ports/study-registry-codec.d.ts.map +1 -1
  56. package/dist/studies.d.ts +4 -0
  57. package/dist/studies.d.ts.map +1 -1
  58. package/dist/studies.js +55 -24
  59. package/dist/studies.js.map +1 -1
  60. package/dist/study-gate.d.ts +14 -0
  61. package/dist/study-gate.d.ts.map +1 -0
  62. package/dist/study-gate.js +95 -0
  63. package/dist/study-gate.js.map +1 -0
  64. package/dist/study-index-input.d.ts +5 -0
  65. package/dist/study-index-input.d.ts.map +1 -0
  66. package/dist/study-index-input.js +13 -0
  67. package/dist/study-index-input.js.map +1 -0
  68. package/dist/study-modes.d.ts +45 -0
  69. package/dist/study-modes.d.ts.map +1 -0
  70. package/dist/study-modes.js +70 -0
  71. package/dist/study-modes.js.map +1 -0
  72. package/dist/study-registry-rule.d.ts +29 -6
  73. package/dist/study-registry-rule.d.ts.map +1 -1
  74. package/dist/study-registry-rule.js +91 -40
  75. package/dist/study-registry-rule.js.map +1 -1
  76. package/dist/study-run.d.ts +19 -0
  77. package/dist/study-run.d.ts.map +1 -0
  78. package/dist/study-run.js +9 -0
  79. package/dist/study-run.js.map +1 -0
  80. package/docs/design/study-modes.md +247 -0
  81. package/docs/design/study-registry.md +137 -15
  82. package/docs/e2e.md +8 -4
  83. package/docs/layout.md +26 -21
  84. package/docs/rules/repro/outputs-before-decision.md +119 -0
  85. package/docs/rules/repro/study-plan-name.md +70 -0
  86. package/docs/rules/repro/study-registered.md +206 -15
  87. package/docs/rules.md +54 -52
  88. package/docs/testing.md +1 -0
  89. package/package.json +2 -1
  90. package/scripts/check.ts +6 -0
  91. package/skills/build-benchmark/SKILL.md +24 -12
  92. package/skills/build-benchmark/SKILL.md.spec.ts +23 -11
  93. package/skills/build-label-page/SKILL.md +9 -7
  94. package/skills/build-label-page/SKILL.md.spec.ts +8 -6
  95. package/skills/camera-ready/SKILL.md +6 -1
  96. package/skills/camera-ready/SKILL.md.spec.ts +5 -0
  97. package/skills/draft-paper/SKILL.md +11 -3
  98. package/skills/draft-paper/SKILL.md.spec.ts +10 -2
  99. package/skills/harden-paper/SKILL.md +12 -2
  100. package/skills/harden-paper/SKILL.md.spec.ts +11 -1
  101. package/skills/osf-artifact-upload/SKILL.md +7 -1
  102. package/skills/osf-artifact-upload/SKILL.md.spec.ts +6 -0
  103. package/skills/pc-panel-review/SKILL.md +10 -3
  104. package/skills/pc-panel-review/SKILL.md.spec.ts +9 -2
  105. package/skills/run-agent-grid/SKILL.md +10 -5
  106. package/skills/run-agent-grid/SKILL.md.spec.ts +9 -4
  107. package/skills/tighten-paper/SKILL.md +5 -1
  108. package/skills/tighten-paper/SKILL.md.spec.ts +4 -0
  109. package/src/adapters/git/history.test.ts +121 -0
  110. package/src/adapters/git/index.test.ts +268 -0
  111. package/src/adapters/git/index.ts +227 -0
  112. package/src/adapters/memory/index.test.ts +15 -1
  113. package/src/adapters/memory/index.ts +13 -0
  114. package/src/adapters/node/index.ts +1 -0
  115. package/src/adapters/node/list-entries.io.ts +26 -0
  116. package/src/adapters/node/list-entries.test.ts +43 -0
  117. package/src/adapters/study-registry/index.test.ts +343 -0
  118. package/src/adapters/study-registry/index.ts +374 -10
  119. package/src/adapters/study-registry/mode.test.ts +162 -0
  120. package/src/cli.ts +52 -6
  121. package/src/domain/decide-first.test.ts +258 -0
  122. package/src/domain/decide-first.ts +243 -0
  123. package/src/domain/index-columns.test.ts +216 -0
  124. package/src/domain/index-columns.ts +105 -0
  125. package/src/domain/study-measurements.test.ts +633 -0
  126. package/src/domain/study-mode.test.ts +55 -0
  127. package/src/domain/study-mode.ts +78 -0
  128. package/src/domain/study-registry.test.ts +48 -32
  129. package/src/domain/study-registry.ts +621 -36
  130. package/src/new-paper.test.ts +6 -1
  131. package/src/outputs-before-decision-rule.test.ts +206 -0
  132. package/src/outputs-before-decision-rule.ts +124 -0
  133. package/src/ports/git-history.ts +44 -0
  134. package/src/ports/list-entries.ts +18 -0
  135. package/src/ports/study-registry-codec.ts +13 -2
  136. package/src/rule-cases.test.ts +103 -1
  137. package/src/studies-cli.test.ts +2 -2
  138. package/src/studies-index-modes.test.ts +129 -0
  139. package/src/studies.test.ts +39 -9
  140. package/src/studies.ts +99 -29
  141. package/src/study-gate.test.ts +316 -0
  142. package/src/study-gate.ts +161 -0
  143. package/src/study-index-input.ts +23 -0
  144. package/src/study-modes.ts +120 -0
  145. package/src/study-registry-rule.test.ts +333 -7
  146. package/src/study-registry-rule.ts +145 -41
  147. package/src/study-run.ts +21 -0
@@ -0,0 +1,78 @@
1
+ /**
2
+ * A STUDY'S MODE — how its outcomes are kept from the decisions about its inputs. A measurement study
3
+ * declares exactly one (docs/design/study-modes.md):
4
+ *
5
+ * decide_before_run the default: the author's decisions on the inputs (exclusions, veto, the
6
+ * sample's approval) are committed BEFORE any output exists. No key, no extra
7
+ * tool: git's history is the proof of order, and
8
+ * `repro/outputs-before-decision` re-checks it on every lint.
9
+ * exploratory no gate: the study is exploratory and is reported as such, with its reason.
10
+ *
11
+ * "None" and "two at once" cannot be written: the plan holds one `mode`, a union on its `kind`, and a
12
+ * measurement study's plan is not ready without it (the parser, `src/adapters/study-registry/`).
13
+ *
14
+ * Paths: `outputs` are globs relative to the study's folder (the study's results live there); the
15
+ * decision's `file` is relative to the PAPER's folder, so a decision may live beside the study or
16
+ * anywhere in the paper (`research/decisions.md`). Pure.
17
+ */
18
+ import { posix } from "node:path";
19
+ import { REPRO_DIR } from "./repro-layout.ts";
20
+ import type { StudyKind } from "./study-registry.ts";
21
+
22
+ /** A decision recorded in a file: where, and what it decides. */
23
+ export interface Decision {
24
+ /** Relative to the paper's folder. */
25
+ readonly file: string;
26
+ readonly note: string;
27
+ }
28
+
29
+ /** Decide first: the decision on the inputs is committed before any output exists. */
30
+ export interface DecideFirst {
31
+ readonly kind: "decide_before_run";
32
+ /** Globs relative to the study's folder: the files the run produces. */
33
+ readonly outputs: readonly string[];
34
+ readonly decision: Decision;
35
+ }
36
+
37
+ /** Exploratory: no gate, and reported as exploratory. */
38
+ export interface Exploratory {
39
+ readonly kind: "exploratory";
40
+ readonly reason: string;
41
+ }
42
+
43
+ export type StudyMode = DecideFirst | Exploratory;
44
+
45
+ /**
46
+ * The kinds of study whose plan must declare a mode: the ones that measure. A spike, a tool, a data
47
+ * set, an artifact or a salvage may omit it.
48
+ */
49
+ export const MODE_REQUIRED: readonly StudyKind[] = ["measurement"];
50
+
51
+ /** Whether a study of `kind` must declare its mode. */
52
+ export const needsMode = (kind: StudyKind): boolean =>
53
+ MODE_REQUIRED.includes(kind);
54
+
55
+ /** `paperPath` relative to the study folder `folder`, when it lies inside it; else null. */
56
+ export const inStudy = (folder: string, paperPath: string): string | null => {
57
+ const prefix = `${REPRO_DIR}/${folder}/`;
58
+ const n = posix.normalize(paperPath);
59
+ return n.startsWith(prefix) ? n.slice(prefix.length) : null;
60
+ };
61
+
62
+ /** Whether the file at `path` (relative to the study's folder) is one of `outputs`. */
63
+ export const isOutput = (outputs: readonly string[], path: string): boolean =>
64
+ outputs.some((g) => posix.matchesGlob(path, g));
65
+
66
+ /**
67
+ * The output glob that matches the study's own decision file, when the file lies in the study's
68
+ * folder: the decision would be "produced" by the run it gates.
69
+ */
70
+ export const decisionAsOutput = (
71
+ folder: string,
72
+ m: DecideFirst,
73
+ ): string | null => {
74
+ const rel = inStudy(folder, m.decision.file);
75
+ return rel === null
76
+ ? null
77
+ : (m.outputs.find((g) => posix.matchesGlob(rel, g)) ?? null);
78
+ };
@@ -25,8 +25,12 @@ import {
25
25
  type Study,
26
26
  type StudyInput,
27
27
  type StudyRegistry,
28
+ type IndexInput,
28
29
  } from "./study-registry.ts";
29
30
 
31
+ /** No plan and no history: the index with every mode column `—`. */
32
+ const NO_FACTS: IndexInput = { plans: new Map(), history: new Map() };
33
+
30
34
  const study = (over: Partial<Study> = {}): Study => ({
31
35
  id: "P1-S01",
32
36
  folder: "P1-S01-baseline",
@@ -56,8 +60,9 @@ const input = (
56
60
  ): StudyInput => ({
57
61
  folders: r.studies.map((s) => s.folder),
58
62
  registry: read(r),
59
- readme: withIndex("# Reproduction index\n", r),
63
+ readme: withIndex("# Reproduction index\n", r, NO_FACTS),
60
64
  existingResults: new Set(),
65
+ plans: new Map(),
61
66
  ...over,
62
67
  });
63
68
 
@@ -96,6 +101,7 @@ describe("judgeStudies — silent", () => {
96
101
  registry: { kind: "absent" },
97
102
  readme: null,
98
103
  existingResults: new Set(),
104
+ plans: new Map(),
99
105
  }),
100
106
  ).toEqual([]);
101
107
  });
@@ -124,6 +130,7 @@ describe("judgeStudies — the registry itself", () => {
124
130
  registry: { kind: "absent" },
125
131
  readme: null,
126
132
  existingResults: new Set(),
133
+ plans: new Map(),
127
134
  }),
128
135
  ).toEqual([
129
136
  { problem: "no-registry", folders: ["a-2026-10-01", "b-2026-10-02"] },
@@ -137,6 +144,7 @@ describe("judgeStudies — the registry itself", () => {
137
144
  registry: { kind: "unreadable", why: "prefix: required" },
138
145
  readme: null,
139
146
  existingResults: new Set(),
147
+ plans: new Map(),
140
148
  }),
141
149
  ).toEqual([{ problem: "unreadable", why: "prefix: required" }]);
142
150
  });
@@ -306,16 +314,16 @@ describe("judgeStudies — the index", () => {
306
314
 
307
315
  it("a table prettier padded by display width, with wide characters, is in sync", async () => {
308
316
  const wide = reg(study({ title: "測定 A" }));
309
- const readme = await format(withIndex("# index\n", wide), {
317
+ const readme = await format(withIndex("# index\n", wide, NO_FACTS), {
310
318
  parser: "markdown",
311
319
  });
312
- expect(readme).not.toBe(withIndex("# index\n", wide));
320
+ expect(readme).not.toBe(withIndex("# index\n", wide, NO_FACTS));
313
321
  expect(judgeStudies(input(wide, { readme }))).toEqual([]);
314
322
  });
315
323
 
316
324
  it("a title with a backslash before a pipe stays one cell through prettier", async () => {
317
325
  const bs = reg(study({ title: "foo\\|bar" }));
318
- const readme = await format(withIndex("# index\n", bs), {
326
+ const readme = await format(withIndex("# index\n", bs, NO_FACTS), {
319
327
  parser: "markdown",
320
328
  });
321
329
  expect(judgeStudies(input(bs, { readme }))).toEqual([]);
@@ -324,22 +332,25 @@ describe("judgeStudies — the index", () => {
324
332
 
325
333
  it("a title carrying the end marker's text: in sync, and regenerating changes nothing", () => {
326
334
  const m = reg(study({ title: `x ${INDEX_END} y` }));
327
- const readme = withIndex("# index\n", m);
335
+ const readme = withIndex("# index\n", m, NO_FACTS);
328
336
  expect(judgeStudies(input(m, { readme }))).toEqual([]);
329
- expect(withIndex(readme, m)).toBe(readme);
337
+ expect(withIndex(readme, m, NO_FACTS)).toBe(readme);
330
338
  });
331
339
 
332
340
  it("prose that mentions the begin marker is kept, and the real block is the one judged", () => {
333
341
  const r1 = reg(study());
334
342
  const prose = `# index\n\nThe table between \`${INDEX_BEGIN}\` and its end marker is generated.\n`;
335
- const readme = withIndex(prose, r1);
343
+ const readme = withIndex(prose, r1, NO_FACTS);
336
344
  expect(judgeStudies(input(r1, { readme }))).toEqual([]);
337
- expect(withIndex(readme, r1)).toBe(readme);
345
+ expect(withIndex(readme, r1, NO_FACTS)).toBe(readme);
338
346
  expect(readme.startsWith(prose.trimEnd())).toBe(true);
339
347
  });
340
348
 
341
349
  it("a block that differs from the registry by one character", () => {
342
- const readme = withIndex("# index\n", r).replace("designing", "running");
350
+ const readme = withIndex("# index\n", r, NO_FACTS).replace(
351
+ "designing",
352
+ "running",
353
+ );
343
354
  expect(judgeStudies(input(r, { readme }))).toEqual([
344
355
  { problem: "stale-index", why: "differs" },
345
356
  ]);
@@ -359,12 +370,12 @@ describe("the index", () => {
359
370
  in_paper: "yes",
360
371
  }),
361
372
  );
362
- expect(studyIndex(r)).toBe(
373
+ expect(studyIndex(r, NO_FACTS)).toBe(
363
374
  [
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/) |",
375
+ "| id | title | status | result | in paper | mode | decision | first output | folder |",
376
+ "| ------ | ------------------- | --------- | -------- | -------- | ---- | -------- | ------------ | ------------------------------------ |",
377
+ "| P1-S01 | Baseline pass rate | designing | — | no | — | — | — | [P1-S01-baseline/](P1-S01-baseline/) |",
378
+ "| P1-S02 | Pipes \\| and breaks | done | 41 of 60 | yes | — | — | — | [P1-S02-a-b/](P1-S02-a-b/) |",
368
379
  ].join("\n"),
369
380
  );
370
381
  });
@@ -386,30 +397,30 @@ describe("the index", () => {
386
397
  }),
387
398
  ].slice(0, n),
388
399
  );
389
- const md = withIndex("# Reproduction index\n\nSome text.\n", r);
400
+ const md = withIndex("# Reproduction index\n\nSome text.\n", r, NO_FACTS);
390
401
  expect(await format(md, { parser: "markdown" })).toBe(md);
391
402
  });
392
403
  });
393
404
 
394
405
  describe("the index of an empty registry, and its markers", () => {
395
406
  it("of an empty registry is the header alone", () => {
396
- expect(studyIndex(reg())).toBe(
407
+ expect(studyIndex(reg(), NO_FACTS)).toBe(
397
408
  [
398
- "| id | title | status | result | in paper | folder |",
399
- "| --- | ----- | ------ | ------ | -------- | ------ |",
409
+ "| id | title | status | result | in paper | mode | decision | first output | folder |",
410
+ "| --- | ----- | ------ | ------ | -------- | ---- | -------- | ------------ | ------ |",
400
411
  ].join("\n"),
401
412
  );
402
413
  });
403
414
 
404
415
  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}`,
416
+ expect(indexBlock(reg(), NO_FACTS)).toBe(
417
+ `${INDEX_BEGIN}\n\n${studyIndex(reg(), NO_FACTS)}\n\n${INDEX_END}`,
407
418
  );
408
419
  });
409
420
 
410
421
  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`,
422
+ expect(embeddedIndex(`# x\n\n${indexBlock(reg(), NO_FACTS)}\n`)).toBe(
423
+ `\n\n${studyIndex(reg(), NO_FACTS)}\n\n`,
413
424
  );
414
425
  expect(embeddedIndex("# x\n")).toBeNull();
415
426
  expect(embeddedIndex(`${INDEX_END}\n${INDEX_BEGIN}\n`)).toBeNull();
@@ -420,27 +431,27 @@ describe("withIndex", () => {
420
431
  const r = reg(study());
421
432
 
422
433
  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`,
434
+ const before = `# Index\n\nintro\n\n${indexBlock(reg(), NO_FACTS)}\n\ntail\n`;
435
+ expect(withIndex(before, r, NO_FACTS)).toBe(
436
+ `# Index\n\nintro\n\n${indexBlock(r, NO_FACTS)}\n\ntail\n`,
426
437
  );
427
438
  });
428
439
 
429
440
  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`,
441
+ expect(withIndex("# Index\n\nintro", r, NO_FACTS)).toBe(
442
+ `# Index\n\nintro\n\n${indexBlock(r, NO_FACTS)}\n`,
432
443
  );
433
444
  });
434
445
 
435
446
  it("writes a README when there is none", () => {
436
- expect(withIndex(null, r)).toBe(
437
- `# Reproduction index\n\n${indexBlock(r)}\n`,
447
+ expect(withIndex(null, r, NO_FACTS)).toBe(
448
+ `# Reproduction index\n\n${indexBlock(r, NO_FACTS)}\n`,
438
449
  );
439
450
  });
440
451
 
441
452
  it("is a fixed point: a second run changes nothing", () => {
442
- const once = withIndex("# Index\n", r);
443
- expect(withIndex(once, r)).toBe(once);
453
+ const once = withIndex("# Index\n", r, NO_FACTS);
454
+ expect(withIndex(once, r, NO_FACTS)).toBe(once);
444
455
  });
445
456
  });
446
457
 
@@ -583,7 +594,7 @@ describe("addStudy", () => {
583
594
  date: "2026-10-15",
584
595
  } as const;
585
596
 
586
- it("appends the next id, its folder, designing, not in the paper, an empty question", () => {
597
+ it("appends the next id, its folder, designing, not in the paper, an empty question, its plan", () => {
587
598
  const r = reg(study({ id: "P1-S04", folder: "P1-S04-x", foo: 1 }));
588
599
  const out = addStudy(r, { ...NEW, title: " Baseline\n pass rate " });
589
600
  const added: Study = {
@@ -595,6 +606,7 @@ describe("addStudy", () => {
595
606
  status: "designing",
596
607
  date: "2026-10-15",
597
608
  in_paper: "no",
609
+ plan: "plan.json",
598
610
  };
599
611
  expect(out).toEqual({
600
612
  ok: true,
@@ -645,6 +657,10 @@ describe("studyReadme", () => {
645
657
  "Its row in `../studies.json` holds its status. When the study ends, set `status`, `result`,",
646
658
  "`result_file` and `in_paper` there, then run `npx paperlint studies index <paper-dir>`.",
647
659
  "",
660
+ "Its plan is `plan.json`: what it measures, the population and the draw, the arms and the primary",
661
+ "outcome, filled in before the first run; the author's check of the drawn units goes in its",
662
+ "`author_check`.",
663
+ "",
648
664
  "## Question",
649
665
  "",
650
666
  "## How to run",