paperlint 13.0.3 → 13.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.
Files changed (114) hide show
  1. package/CLAUDE.md +9 -1
  2. package/dist/adapters/git/index.d.ts.map +1 -1
  3. package/dist/adapters/git/index.js +13 -0
  4. package/dist/adapters/git/index.js.map +1 -1
  5. package/dist/adapters/study-registry/index.d.ts +142 -1
  6. package/dist/adapters/study-registry/index.d.ts.map +1 -1
  7. package/dist/adapters/study-registry/index.js +160 -1
  8. package/dist/adapters/study-registry/index.js.map +1 -1
  9. package/dist/cli.d.ts.map +1 -1
  10. package/dist/cli.js +3 -0
  11. package/dist/cli.js.map +1 -1
  12. package/dist/domain/decide-first.d.ts +43 -0
  13. package/dist/domain/decide-first.d.ts.map +1 -1
  14. package/dist/domain/decide-first.js +45 -2
  15. package/dist/domain/decide-first.js.map +1 -1
  16. package/dist/domain/study-gates.d.ts +96 -0
  17. package/dist/domain/study-gates.d.ts.map +1 -0
  18. package/dist/domain/study-gates.js +56 -0
  19. package/dist/domain/study-gates.js.map +1 -0
  20. package/dist/domain/study-mode.d.ts +6 -0
  21. package/dist/domain/study-mode.d.ts.map +1 -1
  22. package/dist/domain/study-mode.js.map +1 -1
  23. package/dist/domain/study-registry.d.ts +24 -4
  24. package/dist/domain/study-registry.d.ts.map +1 -1
  25. package/dist/domain/study-registry.js +39 -2
  26. package/dist/domain/study-registry.js.map +1 -1
  27. package/dist/domain/study-result.d.ts +151 -0
  28. package/dist/domain/study-result.d.ts.map +1 -0
  29. package/dist/domain/study-result.js +175 -0
  30. package/dist/domain/study-result.js.map +1 -0
  31. package/dist/outputs-before-decision-rule.d.ts.map +1 -1
  32. package/dist/outputs-before-decision-rule.js +37 -3
  33. package/dist/outputs-before-decision-rule.js.map +1 -1
  34. package/dist/ports/git-history.d.ts +12 -0
  35. package/dist/ports/git-history.d.ts.map +1 -1
  36. package/dist/ports/study-registry-codec.d.ts +3 -0
  37. package/dist/ports/study-registry-codec.d.ts.map +1 -1
  38. package/dist/skills/build-label-page/scripts/label-page-cli.d.ts +6 -0
  39. package/dist/skills/build-label-page/scripts/label-page-commands.d.ts +14 -0
  40. package/dist/skills/build-label-page/scripts/label-page-core.d.ts +149 -0
  41. package/dist/skills/build-label-page/scripts/label-page-html.d.ts +5 -0
  42. package/dist/skills/build-label-page/scripts/label-page.d.ts +2 -0
  43. package/dist/study-modes.d.ts +3 -0
  44. package/dist/study-modes.d.ts.map +1 -1
  45. package/dist/study-modes.js +8 -1
  46. package/dist/study-modes.js.map +1 -1
  47. package/dist/study-registry-rule.d.ts.map +1 -1
  48. package/dist/study-registry-rule.js +1 -0
  49. package/dist/study-registry-rule.js.map +1 -1
  50. package/dist/study-result-rule.d.ts +12 -0
  51. package/dist/study-result-rule.d.ts.map +1 -0
  52. package/dist/study-result-rule.js +87 -0
  53. package/dist/study-result-rule.js.map +1 -0
  54. package/docs/design/study-gates.md +208 -0
  55. package/docs/rules/repro/outputs-before-decision.md +8 -0
  56. package/docs/rules/repro/study-registered.md +38 -15
  57. package/docs/rules/repro/study-result.md +100 -0
  58. package/docs/rules.md +1 -0
  59. package/eslint.config.mjs +1 -1
  60. package/hooks/hooks.harness.ts +51 -13
  61. package/hooks/paper-skills-nudge.hook.d.mts +22 -0
  62. package/hooks/paper-skills-nudge.hook.mjs +85 -23
  63. package/hooks/paper-skills-nudge.test.ts +82 -4
  64. package/package.json +2 -1
  65. package/plugin/hooks/hooks.json +7 -2
  66. package/schemas/plan.v2.schema.json +341 -0
  67. package/skills/argument-arc/SKILL.md.spec.ts +1 -1
  68. package/skills/build-label-page/SKILL.md +85 -54
  69. package/skills/build-label-page/SKILL.md.spec.ts +85 -53
  70. package/skills/build-label-page/build-label-page.test.ts +10 -941
  71. package/skills/build-label-page/references/checklist.md +45 -3
  72. package/skills/build-label-page/references/closed-choice-generator.md +88 -0
  73. package/skills/build-label-page/scripts/label-page-cli.js +72 -0
  74. package/skills/build-label-page/scripts/label-page-cli.ts +85 -0
  75. package/skills/build-label-page/scripts/label-page-commands.js +14 -0
  76. package/skills/build-label-page/scripts/label-page-commands.ts +14 -0
  77. package/skills/build-label-page/scripts/label-page-core.js +850 -0
  78. package/skills/build-label-page/scripts/label-page-core.test.ts +1164 -0
  79. package/skills/build-label-page/scripts/label-page-core.ts +1210 -0
  80. package/skills/build-label-page/scripts/label-page-html.js +33 -0
  81. package/skills/build-label-page/scripts/label-page-html.test.ts +413 -0
  82. package/skills/build-label-page/scripts/label-page-html.ts +43 -0
  83. package/skills/build-label-page/scripts/label-page.js +70 -0
  84. package/skills/build-label-page/scripts/label-page.test.ts +71 -0
  85. package/skills/build-label-page/scripts/label-page.ts +71 -0
  86. package/skills/build-label-page/scripts/page-template.html +513 -0
  87. package/skills/find-venue/SKILL.md.spec.ts +1 -1
  88. package/skills/paper-status/SKILL.md.spec.ts +1 -1
  89. package/skills/render-paper/SKILL.md.spec.ts +1 -1
  90. package/src/adapters/git/index.test.ts +33 -0
  91. package/src/adapters/git/index.ts +19 -0
  92. package/src/adapters/study-registry/gates.test.ts +181 -0
  93. package/src/adapters/study-registry/index.ts +219 -1
  94. package/src/adapters/study-registry/mode.test.ts +6 -0
  95. package/src/cli.ts +3 -0
  96. package/src/domain/decide-first.test.ts +131 -0
  97. package/src/domain/decide-first.ts +92 -2
  98. package/src/domain/study-gates.ts +125 -0
  99. package/src/domain/study-measurements.test.ts +71 -0
  100. package/src/domain/study-mode.ts +6 -0
  101. package/src/domain/study-registry.ts +64 -2
  102. package/src/domain/study-result.test.ts +300 -0
  103. package/src/domain/study-result.ts +358 -0
  104. package/src/outputs-before-decision-rule.test.ts +115 -2
  105. package/src/outputs-before-decision-rule.ts +50 -2
  106. package/src/ports/git-history.ts +12 -0
  107. package/src/ports/study-registry-codec.ts +3 -0
  108. package/src/rule-cases.test.ts +74 -0
  109. package/src/studies.test.ts +3 -0
  110. package/src/study-modes.ts +10 -1
  111. package/src/study-registry-rule.ts +1 -0
  112. package/src/study-result-rule.test.ts +281 -0
  113. package/src/study-result-rule.ts +123 -0
  114. package/skills/build-label-page/references/example-generator.py +0 -803
@@ -0,0 +1,281 @@
1
+ /**
2
+ * `repro/study-result` (`src/study-result-rule.ts`) on paper folders held in memory, run the way
3
+ * ESLint runs it. Both halves: a done study on a ready plan v2 whose result shows every gate is
4
+ * silent, as is every study the rule does not judge (not done, no plan, a plan v1, a draft, a plan
5
+ * that does not parse); a missing or short result is reported on the scorecard's first line.
6
+ */
7
+ import { describe, expect, it } from "vitest";
8
+ import {
9
+ memoryFiles,
10
+ memoryListDir,
11
+ memoryListEntries,
12
+ } from "./adapters/memory/index.ts";
13
+ import { studyRegistryCodec } from "./adapters/study-registry/index.ts";
14
+ import { PLAN_V2 } from "./domain/study-gates.ts";
15
+ import {
16
+ planText,
17
+ registryText,
18
+ type Study,
19
+ type StudyPlan,
20
+ } from "./domain/study-registry.ts";
21
+ import { resultMessage } from "./domain/study-result.ts";
22
+ import {
23
+ RESULT_RULE_LEVELS,
24
+ resultStudies,
25
+ studyResultRules,
26
+ } from "./study-result-rule.ts";
27
+
28
+ const DIR = "/work/papers/example";
29
+ const FOLDER = "P1-S01-readings";
30
+
31
+ const row = (over: Partial<Study> = {}): Study => ({
32
+ id: "P1-S01",
33
+ folder: FOLDER,
34
+ title: "Readings",
35
+ question: "Do readings disagree?",
36
+ kind: "measurement",
37
+ status: "done",
38
+ date: "2026-11-01",
39
+ in_paper: "no",
40
+ result: "0.31",
41
+ result_file: "result.json",
42
+ plan: "plan.json",
43
+ ...over,
44
+ });
45
+
46
+ const V2: StudyPlan = {
47
+ $schema: PLAN_V2,
48
+ question: "Do readings disagree?",
49
+ population: { source: "repos", draw: { kind: "all", method: "census" } },
50
+ arms: [],
51
+ primary_outcome: "share",
52
+ author_check: null,
53
+ mode: {
54
+ kind: "decide_before_run",
55
+ outputs: ["result.json"],
56
+ decision: { file: `repro/${FOLDER}/decision.md`, note: "the draw" },
57
+ we_lose_if: { result: "share_upper95", below: 0.2 },
58
+ },
59
+ second_check: [{ step: "admissible", min_kappa: 0.6 }],
60
+ self_test: { made_by: "selftest.mjs" },
61
+ funnel: ["clone failed"],
62
+ recall_audit: { n_files: 20, min_recall: 0.6 },
63
+ };
64
+
65
+ const GOOD = {
66
+ share_upper95: 0.31,
67
+ kappa: { admissible: 0.64 },
68
+ self_test: { passed: 9, failed: 0 },
69
+ funnel: { "clone failed": 1 },
70
+ recall_audit: { files: 20, units_in_files: 40, extracted: 30 },
71
+ };
72
+
73
+ /** A paper whose registry has `s`, its plan `plan` (text), and `result` as result.json (none when null). */
74
+ const tree = (
75
+ s: Study,
76
+ plan: string | null,
77
+ result: string | null,
78
+ ): Record<string, string> => ({
79
+ [`${DIR}/PIPELINE-STATUS.md`]: "# status\n",
80
+ [`${DIR}/repro/studies.json`]: registryText({ prefix: "P1", studies: [s] }),
81
+ ...(plan === null ? {} : { [`${DIR}/repro/${FOLDER}/plan.json`]: plan }),
82
+ ...(result === null
83
+ ? {}
84
+ : { [`${DIR}/repro/${FOLDER}/result.json`]: result }),
85
+ });
86
+
87
+ const deps = (t: Readonly<Record<string, string>>) => {
88
+ const files = memoryFiles(t);
89
+ return {
90
+ files,
91
+ listDir: memoryListDir(files),
92
+ entries: memoryListEntries(files),
93
+ codec: studyRegistryCodec,
94
+ };
95
+ };
96
+
97
+ /** The rule's messages over `t`, reached through `file`. */
98
+ const lint = (
99
+ t: Readonly<Record<string, string>>,
100
+ file = `${DIR}/PIPELINE-STATUS.md`,
101
+ ): readonly {
102
+ readonly id: string;
103
+ readonly text: string;
104
+ readonly line: number;
105
+ }[] => {
106
+ const rule = studyResultRules(deps(t))["study-result"];
107
+ const out: { id: string; text: string; line: number }[] = [];
108
+ rule
109
+ .create({
110
+ filename: file,
111
+ sourceCode: { getLocFromIndex: () => ({ line: 1, column: 0 }) },
112
+ report: (d) => {
113
+ out.push({
114
+ id: d.messageId,
115
+ text: String(d.data.text),
116
+ line: d.loc.start.line,
117
+ });
118
+ },
119
+ })
120
+ .root?.();
121
+ return out;
122
+ };
123
+
124
+ describe("repro/study-result — silent", () => {
125
+ it("on a done study whose result shows every gate", () => {
126
+ expect(lint(tree(row(), planText(V2), JSON.stringify(GOOD)))).toEqual([]);
127
+ });
128
+
129
+ it("on every study it does not judge", () => {
130
+ const v1: StudyPlan = {
131
+ ...V2,
132
+ $schema: undefined,
133
+ mode: undefined,
134
+ second_check: undefined,
135
+ self_test: undefined,
136
+ funnel: undefined,
137
+ recall_audit: undefined,
138
+ };
139
+ const exploratoryNone: StudyPlan = {
140
+ ...V2,
141
+ mode: { kind: "exploratory", reason: "a look" },
142
+ second_check: "none",
143
+ self_test: "none",
144
+ funnel: "none",
145
+ recall_audit: "none",
146
+ };
147
+ const cases = [
148
+ tree(row({ status: "running" }), planText(V2), null),
149
+ tree(row({ plan: undefined }), null, null),
150
+ tree(row(), null, null),
151
+ tree(row({ kind: "spike" }), planText(v1), null),
152
+ tree(row(), planText({ ...V2, funnel: null }), null),
153
+ tree(row(), "not json", null),
154
+ tree(row(), planText(exploratoryNone), "{}"),
155
+ ];
156
+ // Guards: only a done study on a ready plan v2 is asked for a result.
157
+ expect(cases.map((t) => lint(t))).toEqual(cases.map(() => []));
158
+ });
159
+
160
+ it("on any file but the scorecard", () => {
161
+ expect(lint(tree(row(), planText(V2), null), `${DIR}/paper.tex`)).toEqual(
162
+ [],
163
+ );
164
+ });
165
+ });
166
+
167
+ describe("repro/study-result — reported", () => {
168
+ it("a done study with no result.json, on the scorecard's first line, with the domain's text", () => {
169
+ expect(lint(tree(row(), planText(V2), null))).toEqual([
170
+ {
171
+ id: "no-result-file",
172
+ text: resultMessage({ problem: "no-result-file", id: "P1-S01" }),
173
+ line: 1,
174
+ },
175
+ ]);
176
+ });
177
+
178
+ it("a result.json that is not one, and one whose claim lost unsaid", () => {
179
+ expect(
180
+ lint(tree(row(), planText(V2), '{"kappa": 3}')).map((r) => r.id),
181
+ ).toEqual(["result-unreadable"]);
182
+ expect(
183
+ lint(
184
+ tree(
185
+ row(),
186
+ planText(V2),
187
+ JSON.stringify({ ...GOOD, share_upper95: 0.1 }),
188
+ ),
189
+ ).map((r) => r.id),
190
+ ).toEqual(["claim-lost"]);
191
+ });
192
+
193
+ it("a kappa outside -1..1 (a percentage written as 75) makes result.json unreadable, naming its path", () => {
194
+ const kappa = (k: number) =>
195
+ lint(
196
+ tree(
197
+ row(),
198
+ planText(V2),
199
+ JSON.stringify({ ...GOOD, kappa: { ...GOOD.kappa, admissible: k } }),
200
+ ),
201
+ );
202
+ const [high] = kappa(75);
203
+ // Guards: both ends of the range, and the edges themselves, which are kappas.
204
+ expect([
205
+ high?.id,
206
+ high?.text.includes("kappa.admissible"),
207
+ kappa(-1.5).map((r) => r.id),
208
+ kappa(1).map((r) => r.id),
209
+ kappa(-1).map((r) => r.id),
210
+ ]).toEqual([
211
+ "result-unreadable",
212
+ true,
213
+ ["result-unreadable"],
214
+ [],
215
+ ["kappa"],
216
+ ]);
217
+ });
218
+
219
+ it("resultStudies reads the gates of the plan and the state of the result", () => {
220
+ expect(
221
+ resultStudies(DIR, deps(tree(row(), planText(V2), "x"))).map((s) => [
222
+ s.we_lose_if,
223
+ s.state.kind,
224
+ ]),
225
+ ).toEqual([[{ result: "share_upper95", below: 0.2 }, "unreadable"]]);
226
+ });
227
+
228
+ it("is an error in paperlint's config", () => {
229
+ expect(RESULT_RULE_LEVELS).toEqual({ "repro/study-result": "error" });
230
+ });
231
+ });
232
+
233
+ describe("repro/study-result — the recall audit", () => {
234
+ it("a recall audit on too few files or below its bar is reported; one that counts more extracted than found is unreadable", () => {
235
+ const audit = (c: Record<string, number>) =>
236
+ lint(
237
+ tree(row(), planText(V2), JSON.stringify({ ...GOOD, recall_audit: c })),
238
+ );
239
+ const [over] = audit({ files: 20, units_in_files: 10, extracted: 11 });
240
+ // Guards: 12 of 20 is exactly the bar of 0.6 and passes; 11 of 20 does not.
241
+ expect([
242
+ audit({ files: 20, units_in_files: 20, extracted: 12 }).map((r) => r.id),
243
+ audit({ files: 20, units_in_files: 20, extracted: 11 }).map((r) => r.id),
244
+ audit({ files: 19, units_in_files: 20, extracted: 12 }).map((r) => r.id),
245
+ over?.id,
246
+ over?.text.includes("recall_audit"),
247
+ audit({ files: 20, units_in_files: 0, extracted: 0 }).map((r) => r.id),
248
+ ]).toEqual([
249
+ [],
250
+ ["recall"],
251
+ ["recall-sample"],
252
+ "result-unreadable",
253
+ true,
254
+ ["result-unreadable"],
255
+ ]);
256
+ });
257
+ });
258
+
259
+ describe("repro/study-result — a plan in a subfolder", () => {
260
+ it("reads result.json beside the plan, not at the top of the study's folder", () => {
261
+ const nested = {
262
+ [`${DIR}/PIPELINE-STATUS.md`]: "# status\n",
263
+ [`${DIR}/repro/studies.json`]: registryText({
264
+ prefix: "P1",
265
+ studies: [row({ plan: "design/plan.json" })],
266
+ }),
267
+ [`${DIR}/repro/${FOLDER}/design/plan.json`]: planText(V2),
268
+ [`${DIR}/repro/${FOLDER}/design/result.json`]: JSON.stringify(GOOD),
269
+ };
270
+ expect(lint(nested)).toEqual([]);
271
+ const atTop = {
272
+ ...Object.fromEntries(
273
+ Object.entries(nested).filter(
274
+ ([k]) => !k.endsWith("design/result.json"),
275
+ ),
276
+ ),
277
+ [`${DIR}/repro/${FOLDER}/result.json`]: JSON.stringify(GOOD),
278
+ };
279
+ expect(lint(atTop).map((m) => m.id)).toEqual(["no-result-file"]);
280
+ });
281
+ });
@@ -0,0 +1,123 @@
1
+ /**
2
+ * `repro/study-result` — a `done` study whose plan is v2 shows, in its `result.json`, what its plan
3
+ * fixed before the run: the number its claim stands or falls by, each labelling step's kappa, its
4
+ * self-test, its funnel and its extractor's recall audit. What is judged, and why, is `src/domain/study-result.ts` and
5
+ * docs/rules/repro/study-result.md; this file finds those studies, reads their result through the
6
+ * study folder's reader (no link followed), and reports.
7
+ *
8
+ * A plan that does not parse, is a draft, or is v1 is `repro/study-registered`'s to report; here it
9
+ * asks nothing. Reported on `PIPELINE-STATUS.md`, first line, like the other `repro/` rules.
10
+ */
11
+ import { basename, dirname, posix } from "node:path";
12
+ import { filter, map, pipe } from "remeda";
13
+ import { RESULT_FILE } from "./domain/study-gates.ts";
14
+ import { studiesOf, type Study } from "./domain/study-registry.ts";
15
+ import {
16
+ RESULT_PROBLEMS,
17
+ gatesOf,
18
+ judgeResult,
19
+ resultMessage,
20
+ type ResultState,
21
+ type ResultStudy,
22
+ } from "./domain/study-result.ts";
23
+ import type {
24
+ FolderRuleContext,
25
+ FolderRuleModule,
26
+ } from "./folder-venue-rule.ts";
27
+ import type { StudyDeps } from "./ports/study-deps.ts";
28
+ import type { StudyFolder } from "./ports/study-folder.ts";
29
+ import type { StudyRegistryCodec } from "./ports/study-registry-codec.ts";
30
+ import { studyFolder } from "./study-folder.ts";
31
+ import { registryOf } from "./study-registry-rule.ts";
32
+ import { rulePageUrl } from "./venue-rules.ts";
33
+
34
+ const STATUS_FILE = "PIPELINE-STATUS.md";
35
+ const NAME = "repro/study-result";
36
+
37
+ /** What the folder holds of a study's `result.json`, beside its plan (`plan`, relative to the folder). */
38
+ const resultState = (
39
+ folder: StudyFolder,
40
+ of: string,
41
+ plan: string,
42
+ codec: StudyRegistryCodec,
43
+ ): ResultState => {
44
+ const read = folder.read(of, posix.join(posix.dirname(plan), RESULT_FILE));
45
+ return read.kind === "text" ? codec.parseResult(read.text) : read;
46
+ };
47
+
48
+ /** The row `s` as a study to judge: `done`, its plan ready, every gate decided; else null. */
49
+ const resultStudy = (
50
+ s: Study,
51
+ folder: StudyFolder,
52
+ codec: StudyRegistryCodec,
53
+ ): ResultStudy | null => {
54
+ if (s.status !== "done" || s.plan === undefined) return null;
55
+ const read = folder.read(s.folder, s.plan);
56
+ if (read.kind !== "text") return null;
57
+ const parsed = codec.parsePlan(read.text, s.kind);
58
+ const gates = parsed.kind === "ready" ? gatesOf(parsed.plan) : null;
59
+ return gates === null
60
+ ? null
61
+ : {
62
+ id: s.id,
63
+ folder: s.folder,
64
+ ...gates,
65
+ state: resultState(folder, s.folder, s.plan, codec),
66
+ };
67
+ };
68
+
69
+ /** Every done study of a ready plan v2 in the paper folder `paper`. */
70
+ export const resultStudies = (
71
+ paper: string,
72
+ deps: StudyDeps,
73
+ ): readonly ResultStudy[] => {
74
+ const folder = studyFolder(paper, deps);
75
+ return pipe(
76
+ studiesOf(registryOf(paper, deps)),
77
+ map((s) => resultStudy(s, folder, deps.codec)),
78
+ filter((s): s is ResultStudy => s !== null),
79
+ );
80
+ };
81
+
82
+ /** On `PIPELINE-STATUS.md`, every finding of every such study of its paper, at its first line. */
83
+ const reportOn = (context: FolderRuleContext, deps: StudyDeps) => {
84
+ if (basename(context.filename) !== STATUS_FILE) return {};
85
+ return {
86
+ root() {
87
+ const at = context.sourceCode.getLocFromIndex(0);
88
+ resultStudies(dirname(context.filename), deps)
89
+ .flatMap(judgeResult)
90
+ .forEach((f) => {
91
+ context.report({
92
+ loc: { start: at, end: at },
93
+ messageId: f.problem,
94
+ data: { text: resultMessage(f) },
95
+ });
96
+ });
97
+ },
98
+ };
99
+ };
100
+
101
+ /** The rule, reading the folder through `deps`. */
102
+ export const studyResultRules = (
103
+ deps: StudyDeps,
104
+ ): Readonly<Record<"study-result", FolderRuleModule>> => ({
105
+ "study-result": {
106
+ meta: {
107
+ type: "suggestion",
108
+ docs: {
109
+ description:
110
+ "a done study whose plan is v2 shows in its result.json what its plan fixed before the run: the number its claim stands or falls by, each labelling step's kappa, its self-test, its funnel and its extractor's recall audit",
111
+ url: rulePageUrl(NAME),
112
+ },
113
+ schema: [],
114
+ messages: Object.fromEntries(RESULT_PROBLEMS.map((p) => [p, "{{text}}"])),
115
+ },
116
+ create: (context) => reportOn(context, deps),
117
+ },
118
+ });
119
+
120
+ /** Its level in paperlint's own config: an error. */
121
+ export const RESULT_RULE_LEVELS: Readonly<{ "repro/study-result": "error" }> = {
122
+ "repro/study-result": "error",
123
+ };