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,216 @@
1
+ /**
2
+ * The index's mode columns (`src/domain/index-columns.ts`) and the table that carries them
3
+ * (`studyIndex`, `indexInSync`, `indexCurrent` in `src/domain/study-registry.ts`): the mode from the
4
+ * plan, the decision's and the first output's commits from git's history, and the two git columns
5
+ * left out of what the lint compares.
6
+ */
7
+ import { describe, expect, it } from "vitest";
8
+ import type { DecideFirstStudy } from "./decide-first.ts";
9
+ import { decisionCell, indexHistory } from "./index-columns.ts";
10
+ import type { DecideFirst } from "./study-mode.ts";
11
+ import {
12
+ indexCurrent,
13
+ indexInSync,
14
+ studyIndex,
15
+ withIndex,
16
+ type IndexInput,
17
+ type PlanState,
18
+ type Study,
19
+ type StudyPlan,
20
+ type StudyRegistry,
21
+ } from "./study-registry.ts";
22
+
23
+ const D = { commit: "d".repeat(40), date: "2026-10-14T09:12:00+05:00" };
24
+ const A = { commit: "a".repeat(40), date: "2026-10-16T11:00:00+05:00" };
25
+
26
+ const DECIDED: DecideFirst = {
27
+ kind: "decide_before_run",
28
+ outputs: ["RESULTS.md"],
29
+ decision: { file: "repro/P1-S01-hint/decision.md", note: "exclusions" },
30
+ };
31
+
32
+ const plan = (mode: StudyPlan["mode"]): PlanState => ({
33
+ kind: "ready",
34
+ plan: {
35
+ question: "q?",
36
+ population: { source: "s", draw: { kind: "all", method: "census" } },
37
+ arms: [],
38
+ primary_outcome: "o",
39
+ author_check: null,
40
+ mode,
41
+ },
42
+ });
43
+
44
+ const row = (n: number, over: Partial<Study> = {}): Study => ({
45
+ id: `P1-S0${String(n)}`,
46
+ folder: `P1-S0${String(n)}-s`,
47
+ title: `S${String(n)}`,
48
+ question: "q?",
49
+ kind: "measurement",
50
+ status: "running",
51
+ date: "2026-10-15",
52
+ in_paper: "no",
53
+ plan: "plan.json",
54
+ ...over,
55
+ });
56
+
57
+ /** Nine studies, one per state a mode cell can be in. */
58
+ const NINE: StudyRegistry = {
59
+ prefix: "P1",
60
+ studies: [
61
+ row(1, { folder: "P1-S01-hint" }),
62
+ row(2),
63
+ row(3),
64
+ row(4),
65
+ row(5),
66
+ row(6, { kind: "spike", plan: undefined }),
67
+ row(7, { plan: "../plan.json" }),
68
+ row(8),
69
+ row(9),
70
+ ],
71
+ };
72
+ /** Their plans and histories. */
73
+ const NINE_AT: IndexInput = {
74
+ plans: new Map<string, PlanState>([
75
+ ["P1-S01-hint/plan.json", plan(DECIDED)],
76
+ ["P1-S02-s/plan.json", plan(DECIDED)],
77
+ ["P1-S03-s/plan.json", plan({ kind: "exploratory", reason: "a | look" })],
78
+ ["P1-S04-s/plan.json", plan(DECIDED)],
79
+ ["P1-S05-s/plan.json", plan(DECIDED)],
80
+ ["P1-S08-s/plan.json", { kind: "unreadable", why: "not JSON" }],
81
+ ["P1-S09-s/plan.json", plan(undefined)],
82
+ ]),
83
+ history: new Map([
84
+ [
85
+ "P1-S01",
86
+ { kind: "read", decision: D, decisionDeleted: false, firstOutput: A },
87
+ ],
88
+ [
89
+ "P1-S02",
90
+ {
91
+ kind: "read",
92
+ decision: null,
93
+ decisionDeleted: false,
94
+ firstOutput: null,
95
+ },
96
+ ],
97
+ ["P1-S04", { kind: "unread", why: "shallow clone" }],
98
+ ]),
99
+ };
100
+
101
+ describe("the mode columns of the index", () => {
102
+ it("say each study's mode and, for one decided before its run, its two commits", () => {
103
+ // Guards: the sample row a reader and the review skills read — decided before the run, the
104
+ // decision's first commit and date, the first output's — and every other state its own words.
105
+ expect(studyIndex(NINE, NINE_AT).split("\n").slice(2)).toEqual([
106
+ "| P1-S01 | S1 | running | — | no | decide_before_run | dddddddddddd 2026-10-14 | aaaaaaaaaaaa 2026-10-16 | [P1-S01-hint/](P1-S01-hint/) |",
107
+ "| P1-S02 | S2 | running | — | no | decide_before_run | not committed | none | [P1-S02-s/](P1-S02-s/) |",
108
+ "| P1-S03 | S3 | running | — | no | exploratory: a \\| look | — | — | [P1-S03-s/](P1-S03-s/) |",
109
+ "| P1-S04 | S4 | running | — | no | decide_before_run | unknown (shallow clone) | unknown (shallow clone) | [P1-S04-s/](P1-S04-s/) |",
110
+ "| P1-S05 | S5 | running | — | no | decide_before_run | not read | not read | [P1-S05-s/](P1-S05-s/) |",
111
+ "| P1-S06 | S6 | running | — | no | — | — | — | [P1-S06-s/](P1-S06-s/) |",
112
+ "| P1-S07 | S7 | running | — | no | — | — | — | [P1-S07-s/](P1-S07-s/) |",
113
+ "| P1-S08 | S8 | running | — | no | — | — | — | [P1-S08-s/](P1-S08-s/) |",
114
+ "| P1-S09 | S9 | running | — | no | — | — | — | [P1-S09-s/](P1-S09-s/) |",
115
+ ]);
116
+ });
117
+
118
+ it("the lint compares the mode, never the two commits; `studies index` rewrites on either", () => {
119
+ const r: StudyRegistry = { prefix: "P1", studies: [row(1)] };
120
+ const plans = new Map([["P1-S01-s/plan.json", plan(DECIDED)]]);
121
+ const was: IndexInput = { plans, history: new Map() };
122
+ const readme = withIndex("# index\n", r, was);
123
+ const committed: IndexInput = {
124
+ plans,
125
+ history: new Map([
126
+ [
127
+ "P1-S01",
128
+ {
129
+ kind: "read",
130
+ decision: D,
131
+ decisionDeleted: false,
132
+ firstOutput: null,
133
+ },
134
+ ],
135
+ ]),
136
+ };
137
+ const explored: IndexInput = {
138
+ plans: new Map([
139
+ ["P1-S01-s/plan.json", plan({ kind: "exploratory", reason: "r" })],
140
+ ]),
141
+ history: new Map(),
142
+ };
143
+ // Guards: a squash merge renames the commits, so the lint cannot hold them; the command can.
144
+ expect([
145
+ indexInSync(readme, r, committed),
146
+ indexCurrent(readme, r, committed),
147
+ indexInSync(readme, r, explored),
148
+ indexCurrent(readme, r, was),
149
+ indexCurrent(null, r, was),
150
+ ]).toEqual([true, false, false, true, false]);
151
+ });
152
+ });
153
+
154
+ it("a decision committed and since deleted shows its commit, and that it is gone", () => {
155
+ // Guards: the Methods sentence must not cite a decision that no longer stands.
156
+ expect(
157
+ decisionCell({
158
+ mode: DECIDED,
159
+ history: {
160
+ kind: "read",
161
+ decision: D,
162
+ decisionDeleted: true,
163
+ firstOutput: null,
164
+ },
165
+ }),
166
+ ).toBe("dddddddddddd 2026-10-14, deleted since");
167
+ });
168
+
169
+ describe("indexHistory", () => {
170
+ const study = (facts: DecideFirstStudy["facts"]): DecideFirstStudy => ({
171
+ id: "P1-S01",
172
+ folder: "P1-S01-hint",
173
+ plan: "plan.json",
174
+ mode: DECIDED,
175
+ tree: [],
176
+ facts,
177
+ });
178
+
179
+ it("is the decision's first commit and the oldest output's, or what kept git from saying", () => {
180
+ expect([
181
+ indexHistory(study({ kind: "unknown", why: "not a repository" })),
182
+ indexHistory(study({ kind: "shallow" })),
183
+ indexHistory(study({ kind: "not-a-file", what: "directory" })),
184
+ indexHistory(
185
+ study({
186
+ kind: "read",
187
+ decision: D,
188
+ decisionDeleted: false,
189
+ files: [
190
+ { path: "decision.md", ...D, order: "same" },
191
+ { path: "RESULTS.md", ...A, order: "after" },
192
+ ],
193
+ }),
194
+ ),
195
+ indexHistory(
196
+ study({
197
+ kind: "read",
198
+ decision: null,
199
+ decisionDeleted: false,
200
+ files: [],
201
+ }),
202
+ ),
203
+ ]).toEqual([
204
+ { kind: "unread", why: "git could not answer" },
205
+ { kind: "unread", why: "shallow clone" },
206
+ { kind: "unread", why: "the decision is a directory" },
207
+ { kind: "read", decision: D, decisionDeleted: false, firstOutput: A },
208
+ {
209
+ kind: "read",
210
+ decision: null,
211
+ decisionDeleted: false,
212
+ firstOutput: null,
213
+ },
214
+ ]);
215
+ });
216
+ });
@@ -0,0 +1,105 @@
1
+ /**
2
+ * THE MODE COLUMNS OF THE INDEX — three columns of `repro/README.md`'s study index beyond the row:
3
+ * the study's mode, as its plan declares it, and for a study decided before its run the commit that
4
+ * first recorded its decision and the commit that first recorded an output (docs/design/study-modes.md).
5
+ *
6
+ * The mode comes from the plan, which is in the tree, so `repro/study-registered` compares it. The
7
+ * two commits come from git's history as `paperlint studies index` read it when it ran: a squash merge
8
+ * or a rebase gives the same files new commits, and a shallow CI clone has none to read, so the lint
9
+ * never compares those two columns. `repro/outputs-before-decision` judges the order itself. Pure.
10
+ */
11
+ import {
12
+ outputCommits,
13
+ type CommitAt,
14
+ type DecideFirstStudy,
15
+ } from "./decide-first.ts";
16
+ import type { StudyMode } from "./study-mode.ts";
17
+
18
+ /** What git's history says of a decide-first study's decision and outputs. */
19
+ export type IndexHistory =
20
+ /** No order could be read: why, in a few words (`shallow clone`). */
21
+ | { readonly kind: "unread"; readonly why: string }
22
+ | {
23
+ readonly kind: "read";
24
+ /** The decision file's first commit; null: never committed. */
25
+ readonly decision: CommitAt | null;
26
+ /** The decision was committed, then deleted: it is not in HEAD. */
27
+ readonly decisionDeleted: boolean;
28
+ /** The first commit of the oldest output; null: no output was ever committed. */
29
+ readonly firstOutput: CommitAt | null;
30
+ };
31
+
32
+ /** What the index shows of a study beyond its row. */
33
+ export interface IndexFacts {
34
+ /** The mode its plan declares; null when its plan declares none, or there is no plan to read. */
35
+ readonly mode: StudyMode | null;
36
+ /** For a decide-first study, its history; null when it was not asked (the lint does not ask). */
37
+ readonly history: IndexHistory | null;
38
+ }
39
+
40
+ /** The cell of a column that does not apply to a study. */
41
+ export const NOT_APPLICABLE = "—";
42
+
43
+ /** A commit as a cell: its first twelve characters, and its date. */
44
+ const commitCell = (c: CommitAt): string =>
45
+ `${c.commit.slice(0, 12)} ${c.date.slice(0, 10)}`;
46
+
47
+ /** The mode cell: `decide_before_run`, `exploratory: <reason>`, or `—`. */
48
+ export const modeCell = (f: IndexFacts): string => {
49
+ if (f.mode === null) return NOT_APPLICABLE;
50
+ return f.mode.kind === "exploratory"
51
+ ? `exploratory: ${f.mode.reason}`
52
+ : f.mode.kind;
53
+ };
54
+
55
+ /** A history cell of a decide-first study: the text `pick` makes of it, `absent` when it makes none. */
56
+ const historyCell =
57
+ (
58
+ pick: (h: IndexHistory & { readonly kind: "read" }) => string | null,
59
+ absent: string,
60
+ ) =>
61
+ (f: IndexFacts): string => {
62
+ if (f.mode?.kind !== "decide_before_run") return NOT_APPLICABLE;
63
+ if (f.history === null) return "not read";
64
+ if (f.history.kind === "unread") return `unknown (${f.history.why})`;
65
+ return pick(f.history) ?? absent;
66
+ };
67
+
68
+ /** The decision cell: its first commit and date (`, deleted since` when it is not in HEAD), or `not committed`. */
69
+ export const decisionCell = historyCell(
70
+ (h) =>
71
+ h.decision === null
72
+ ? null
73
+ : `${commitCell(h.decision)}${h.decisionDeleted ? ", deleted since" : ""}`,
74
+ "not committed",
75
+ );
76
+
77
+ /** The first-output cell: the commit and date of the first output, or `none`. */
78
+ export const firstOutputCell = historyCell(
79
+ (h) => (h.firstOutput === null ? null : commitCell(h.firstOutput)),
80
+ "none",
81
+ );
82
+
83
+ /** The history the index shows of a decide-first study, from the facts git gave. */
84
+ export const indexHistory = (s: DecideFirstStudy): IndexHistory => {
85
+ switch (s.facts.kind) {
86
+ case "not-a-file":
87
+ return { kind: "unread", why: `the decision is a ${s.facts.what}` };
88
+ case "unknown":
89
+ return { kind: "unread", why: "git could not answer" };
90
+ case "shallow":
91
+ return { kind: "unread", why: "shallow clone" };
92
+ case "read": {
93
+ const [first] = outputCommits(s, s.facts.files);
94
+ return {
95
+ kind: "read",
96
+ decision: s.facts.decision,
97
+ decisionDeleted: s.facts.decisionDeleted,
98
+ firstOutput:
99
+ first === undefined
100
+ ? null
101
+ : { commit: first.commit, date: first.date },
102
+ };
103
+ }
104
+ }
105
+ };