paperlint 9.0.0 → 10.0.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 (190) hide show
  1. package/.github/CLAUDE.md +22 -0
  2. package/.github/CLAUDE.md.spec.ts +20 -0
  3. package/CLAUDE.md +59 -515
  4. package/CONTRIBUTING.md +2 -2
  5. package/README.md +68 -128
  6. package/dist/CLAUDE.md.spec.d.ts +3 -0
  7. package/dist/CLAUDE.md.spec.d.ts.map +1 -0
  8. package/dist/CLAUDE.md.spec.js +62 -0
  9. package/dist/CLAUDE.md.spec.js.map +1 -0
  10. package/dist/build.d.ts +1 -1
  11. package/dist/build.js +2 -2
  12. package/dist/cli.d.ts +5 -1
  13. package/dist/cli.d.ts.map +1 -1
  14. package/dist/cli.js +52 -6
  15. package/dist/cli.js.map +1 -1
  16. package/dist/domain/layout-tree.d.ts +19 -0
  17. package/dist/domain/layout-tree.d.ts.map +1 -0
  18. package/dist/domain/layout-tree.js +32 -0
  19. package/dist/domain/layout-tree.js.map +1 -0
  20. package/dist/domain/lookup-cache.d.ts +2 -2
  21. package/dist/domain/lookup-cache.js +2 -2
  22. package/dist/domain/paper-layout.d.ts +81 -0
  23. package/dist/domain/paper-layout.d.ts.map +1 -0
  24. package/dist/domain/paper-layout.js +259 -0
  25. package/dist/domain/paper-layout.js.map +1 -0
  26. package/dist/domain/repro-layout.d.ts +80 -0
  27. package/dist/domain/repro-layout.d.ts.map +1 -0
  28. package/dist/domain/repro-layout.js +247 -0
  29. package/dist/domain/repro-layout.js.map +1 -0
  30. package/dist/domain/scaffold-layout.d.ts +28 -0
  31. package/dist/domain/scaffold-layout.d.ts.map +1 -0
  32. package/dist/domain/scaffold-layout.js +81 -0
  33. package/dist/domain/scaffold-layout.js.map +1 -0
  34. package/dist/eslint-rules/CLAUDE.md.spec.d.ts +3 -0
  35. package/dist/eslint-rules/CLAUDE.md.spec.d.ts.map +1 -0
  36. package/dist/eslint-rules/CLAUDE.md.spec.js +39 -0
  37. package/dist/eslint-rules/CLAUDE.md.spec.js.map +1 -0
  38. package/dist/eslint-rules/install-path-literals.d.ts +1 -1
  39. package/dist/eslint-rules/install-path-literals.js +4 -4
  40. package/dist/eslint-rules/install-path-literals.js.map +1 -1
  41. package/dist/eslint-rules/pdf-last-page-balance.js +1 -1
  42. package/dist/eslint-rules/prereg-coverage.d.ts +98 -0
  43. package/dist/eslint-rules/prereg-coverage.d.ts.map +1 -0
  44. package/dist/eslint-rules/prereg-coverage.js +274 -0
  45. package/dist/eslint-rules/prereg-coverage.js.map +1 -0
  46. package/dist/folder-layout-hook.d.ts +21 -0
  47. package/dist/folder-layout-hook.d.ts.map +1 -0
  48. package/dist/folder-layout-hook.js +117 -0
  49. package/dist/folder-layout-hook.js.map +1 -0
  50. package/dist/folder-layout-rule.d.ts +22 -0
  51. package/dist/folder-layout-rule.d.ts.map +1 -0
  52. package/dist/folder-layout-rule.js +107 -0
  53. package/dist/folder-layout-rule.js.map +1 -0
  54. package/dist/init.js +1 -1
  55. package/dist/init.js.map +1 -1
  56. package/dist/lib/agent-cli-version.js +1 -1
  57. package/dist/lib/agent-cli-version.js.map +1 -1
  58. package/dist/new-paper.d.ts +4 -1
  59. package/dist/new-paper.d.ts.map +1 -1
  60. package/dist/new-paper.js +40 -6
  61. package/dist/new-paper.js.map +1 -1
  62. package/dist/references.d.ts +1 -1
  63. package/dist/references.d.ts.map +1 -1
  64. package/dist/references.js +2 -2
  65. package/dist/references.js.map +1 -1
  66. package/dist/repro-layout-rule.d.ts +16 -0
  67. package/dist/repro-layout-rule.d.ts.map +1 -0
  68. package/dist/repro-layout-rule.js +81 -0
  69. package/dist/repro-layout-rule.js.map +1 -0
  70. package/dist/tex-requirements.js +1 -1
  71. package/dist/toolchain.js +1 -1
  72. package/docs/ci.md +60 -0
  73. package/docs/configuration.md +2 -0
  74. package/docs/e2e.md +3 -3
  75. package/docs/incidents.md +5 -5
  76. package/docs/install.md +20 -16
  77. package/docs/layout.md +154 -0
  78. package/docs/optional-rules.md +5 -4
  79. package/docs/references.md +5 -2
  80. package/docs/rules/paper/allowed-files-only.md +192 -0
  81. package/docs/rules/repro/allowed-files-only.md +128 -0
  82. package/docs/rules/study/declare-sample.md +146 -0
  83. package/docs/rules.md +53 -50
  84. package/docs/skills.md +6 -3
  85. package/docs/venues.md +94 -0
  86. package/eslint-rules/CLAUDE.md +44 -0
  87. package/eslint-rules/CLAUDE.md.spec.ts +43 -0
  88. package/eslint-rules/install-path-literals.harness.mjs +1 -1
  89. package/eslint-rules/install-path-literals.ts +4 -4
  90. package/eslint-rules/pdf-last-page-balance.ts +1 -1
  91. package/eslint-rules/prereg-coverage.test.ts +448 -0
  92. package/eslint-rules/prereg-coverage.ts +419 -0
  93. package/eslint.config.mjs +6 -3
  94. package/hooks/CLAUDE.md +34 -0
  95. package/hooks/CLAUDE.md.spec.ts +30 -0
  96. package/lib/agent-cli-version.ts +1 -1
  97. package/lib/skill-trigger-cases.harness.mjs +51 -5
  98. package/lib/skill-trigger-cases.mjs +53 -15
  99. package/package.json +2 -2
  100. package/plugin/CLAUDE.md +22 -0
  101. package/plugin/CLAUDE.md.spec.ts +22 -0
  102. package/plugin/hooks/hooks.json +9 -0
  103. package/scripts/coverage-config.test.ts +2 -1
  104. package/skills/CLAUDE.md +19 -0
  105. package/skills/CLAUDE.md.spec.ts +18 -0
  106. package/skills/analyze-sibling-paper/SKILL.md +2 -2
  107. package/skills/analyze-sibling-paper/SKILL.md.spec.ts +1 -1
  108. package/skills/build-benchmark/SKILL.md +14 -1
  109. package/skills/build-benchmark/SKILL.md.spec.ts +13 -0
  110. package/skills/build-label-page/SKILL.md +118 -0
  111. package/skills/build-label-page/SKILL.md.spec.ts +129 -0
  112. package/skills/build-label-page/build-label-page.eval.mjs +15 -0
  113. package/skills/build-label-page/build-label-page.test.ts +959 -0
  114. package/skills/build-label-page/references/checklist.md +148 -0
  115. package/skills/build-label-page/references/example-generator.py +803 -0
  116. package/skills/find-venue/SKILL.md +5 -3
  117. package/skills/find-venue/SKILL.md.spec.ts +4 -2
  118. package/skills/grade-paper-writing/SKILL.md +7 -7
  119. package/skills/grade-paper-writing/SKILL.md.spec.ts +6 -6
  120. package/skills/map-prior-work/SKILL.md +2 -2
  121. package/skills/map-prior-work/SKILL.md.spec.ts +1 -1
  122. package/skills/paper-pipeline/SKILL.md +8 -6
  123. package/skills/paper-pipeline/SKILL.md.spec.ts +7 -5
  124. package/skills/paper-pipeline/grade-paper-writing-ablation.eval.mjs +19 -9
  125. package/skills/paper-pipeline/references/acceptance-gate.md +19 -19
  126. package/skills/paper-pipeline/references/artifact-checklist.md +5 -0
  127. package/skills/paper-pipeline/references/pipeline-status-template.md +30 -1
  128. package/skills/paper-pipeline/repro/2026-08-07-fork-check.log +1 -1
  129. package/skills/paper-pipeline/repro/2026-08-07-fork-check2.log +1 -1
  130. package/skills/paper-pipeline/repro/2026-08-07-language-eval-pilot.log +1 -1
  131. package/skills/paper-pipeline/repro/2026-08-07-language-eval-raw.log +1 -1
  132. package/skills/paper-pipeline/repro/2026-08-08-framing-vs-vocabulary-oracle.log +48 -48
  133. package/skills/paper-pipeline/repro/2026-08-08-grade-paper-writing-ablation-A6-oracle.log +8 -8
  134. package/skills/paper-pipeline/repro/2026-08-08-grade-paper-writing-ablation-oracle.log +48 -48
  135. package/skills/paper-pipeline/repro/2026-08-08-parent-replication-gpw.log +2 -2
  136. package/skills/paper-pipeline/repro/2026-08-08-parent-replication.mjs +3 -3
  137. package/skills/paper-pipeline/repro/2026-10-13-grade-paper-writing-ablation-preflight-after.log +81 -0
  138. package/skills/paper-pipeline/repro/2026-10-13-grade-paper-writing-ablation-preflight-before.log +81 -0
  139. package/skills/paper-pipeline/repro/2026-10-13-grade-paper-writing-ablation-setupdiff.log +59 -0
  140. package/skills/paper-pipeline/repro/2026-10-13-grade-paper-writing-trigger.jsonl +1 -0
  141. package/skills/paper-pipeline/repro/2026-10-13-grade-paper-writing-trigger.log +22 -0
  142. package/skills/paper-pipeline/repro/README.md +4 -0
  143. package/skills/paper-pipeline/scripts/extract-ref-facts.harness.mjs +2 -2
  144. package/skills/paper-pipeline/scripts/extract-ref-facts.mjs +2 -2
  145. package/skills/paper-pipeline/scripts/extract-ref-facts.test.mjs +4 -2
  146. package/skills/paper-pipeline/scripts/generated-code.harness.mjs +7 -2
  147. package/skills/paper-pipeline/scripts/generated-code.mjs +8 -4
  148. package/skills/paper-pipeline/scripts/generated-code.test.ts +15 -1
  149. package/skills/paper-status/SKILL.md +2 -2
  150. package/skills/paper-status/SKILL.md.spec.ts +1 -1
  151. package/skills/plan-paper-timeline/plan-paper-timeline.effects.harness.mjs +21 -102
  152. package/skills/study-accepted-papers/SKILL.md +163 -161
  153. package/skills/study-accepted-papers/SKILL.md.spec.ts +167 -165
  154. package/skills/study-accepted-papers/references/base-rates.md +69 -0
  155. package/skills/study-accepted-papers/references/mechanisms.md +181 -0
  156. package/skills/verify-citations/SKILL.md +2 -2
  157. package/skills/verify-citations/SKILL.md.spec.ts +1 -1
  158. package/src/CLAUDE.md +23 -1
  159. package/src/CLAUDE.md.spec.ts +72 -0
  160. package/src/build.ts +2 -2
  161. package/src/cli.harness.mjs +42 -0
  162. package/src/cli.test.ts +90 -6
  163. package/src/cli.ts +78 -5
  164. package/src/domain/layout-tree.test.ts +32 -0
  165. package/src/domain/layout-tree.ts +47 -0
  166. package/src/domain/lookup-cache.ts +2 -2
  167. package/src/domain/paper-layout.test.ts +264 -0
  168. package/src/domain/paper-layout.ts +336 -0
  169. package/src/domain/repro-layout.test.ts +222 -0
  170. package/src/domain/repro-layout.ts +311 -0
  171. package/src/domain/scaffold-layout.ts +104 -0
  172. package/src/folder-layout-hook.test.ts +214 -0
  173. package/src/folder-layout-hook.ts +141 -0
  174. package/src/folder-layout-rule.test.ts +235 -0
  175. package/src/folder-layout-rule.ts +151 -0
  176. package/src/hooks-settings.harness.ts +9 -8
  177. package/src/init.ts +1 -1
  178. package/src/layout-docs.test.ts +52 -0
  179. package/src/new-paper.harness.mjs +23 -1
  180. package/src/new-paper.test.ts +299 -6
  181. package/src/new-paper.ts +51 -5
  182. package/src/paper-files.test.ts +9 -6
  183. package/src/references.test.ts +5 -5
  184. package/src/references.ts +2 -2
  185. package/src/repro-layout-rule.test.ts +114 -0
  186. package/src/repro-layout-rule.ts +121 -0
  187. package/src/rule-cases.test.ts +98 -0
  188. package/src/tex-requirements.ts +1 -1
  189. package/src/toolchain.ts +1 -1
  190. package/templates/paper/PIPELINE-STATUS.md +41 -7
@@ -0,0 +1,419 @@
1
+ /**
2
+ * `study/declare-sample` — a study's pre-registration says which population the paper's claim is
3
+ * about, what the screens between that population and the sample removed, what share of it the
4
+ * sample can represent, and that the author looked at the drawn units before the run.
5
+ *
6
+ * ── THE FAILURE IT PREVENTS ──────────────────────────────────────────────────────
7
+ * A measurement study of how language models read coding rules drew its rules from three pools,
8
+ * kept only the rules "judgeable from one code excerpt of at most 40 lines", restricted one pool to a
9
+ * classifier's "enforceable" lane, and let a model pick short excerpts. Each screen was reasonable
10
+ * alone; together they left a sample of crisp rules. The labeller found 93 % of the items designed as
11
+ * borderline clear-cut, and the rules looked nothing like the ones met in practice. A spot-check of
12
+ * ten items flagged it (seven of ten clear-cut) and the run went ahead: nothing in the design made
13
+ * anyone state what share of the population the sample covered, or which screens removed which kinds
14
+ * of rules. The four fields below are those statements, and this rule makes them part of the record.
15
+ *
16
+ * ── WHERE IT LOOKS ───────────────────────────────────────────────────────────────
17
+ * A study lives in a folder at the top of the paper's `repro/` (`repro/allowed-files-only` names the
18
+ * layout); `tools/` and `data/` beside them are shared, not studies. Every study folder that holds a
19
+ * `prereg.json` is judged; a study without one owes nothing here. The findings are reported on the paper's `PIPELINE-STATUS.md`, first line, as the other rules
20
+ * about a paper's folders are: `prereg.json` is not a file paperlint lints, and every paper has a
21
+ * status file.
22
+ *
23
+ * ── THE FIELDS ───────────────────────────────────────────────────────────────────
24
+ * population { about, source, size } what the claim is about, where it comes from, how many
25
+ * strata { by, values } the one stratification the screens are counted by
26
+ * screens [{ name, removed: {value: n} }] each screen, and what it removed per stratum value
27
+ * coverage { share, reason?, unreachable } 0–1, or null with a reason; strata it cannot reach
28
+ * author_check { who, when, units_seen, units_drawn, verdict, decision? }
29
+ *
30
+ * A pre-registration holds much more (hypotheses, arms, models, budget); every other key is the
31
+ * author's and is not read. The shape is `PREREG` below — a zod schema whose every field carries the
32
+ * text a finding gives for it — and the relations between fields that a schema of one field cannot
33
+ * see (`removed` keyed by the declared strata, a null share with a reason, a doubtful verdict with a
34
+ * decision) are `crossProblems`.
35
+ */
36
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
37
+ import { basename, dirname, join } from "node:path";
38
+ import { z } from "zod";
39
+ import type { RuleContext } from "./rule-context.ts";
40
+
41
+ /**
42
+ * The folder of a paper that holds its studies, the folders in it that are shared rather than a
43
+ * study's, and the file a study pre-registers in. The first two are `repro/allowed-files-only`'s
44
+ * (`src/domain/repro-layout.ts`, `REPRO_DIR` and `REPRO_DIRS`), copied because a module here is
45
+ * compiled before `src/`; the test beside this file holds the copies equal.
46
+ */
47
+ export const REPRO_DIR = "repro";
48
+ export const SHARED_DIRS: readonly string[] = ["tools", "data"];
49
+ export const PREREG_FILE = "prereg.json";
50
+ const STATUS_FILE = "PIPELINE-STATUS.md";
51
+ const PAGE =
52
+ "https://github.com/zernie/paperlint/blob/main/docs/rules/study/declare-sample.md";
53
+
54
+ /** The one sentence every finding ends with: why the fields exist. */
55
+ export const WHY =
56
+ "Why: a study once screened its sample down to rules that are easy to judge — its labeller found 93 % of the items designed as borderline clear-cut — and nothing in its design made anyone state what share of the real population the sample covered or which screens removed which kinds of units.";
57
+
58
+ /** A zod error option: `what` is the text a finding gives for the field, missing or malformed. */
59
+ const field = (
60
+ what: string,
61
+ ): { readonly error: (iss: { readonly input?: unknown }) => string } => ({
62
+ error: (iss) =>
63
+ iss.input === undefined
64
+ ? `is missing — add ${what}`
65
+ : `is not valid — it must be ${what}`,
66
+ });
67
+
68
+ /** Text that says something: a string with more than white space in it. */
69
+ const text = (what: string) =>
70
+ z
71
+ .string(field(what))
72
+ .trim()
73
+ .min(1, { error: `is empty — write ${what}` });
74
+
75
+ /** A whole number above 0. */
76
+ const positive = (what: string) =>
77
+ z.number(field(what)).int(field(what)).positive(field(what));
78
+
79
+ const POPULATION = z.object(
80
+ {
81
+ about: text(
82
+ "what the paper's claim is about, in the paper's own words (e.g. the rules written in agent instruction files of public repositories)",
83
+ ),
84
+ source: text(
85
+ "where the population's units come from — the census, crawl or registry, with its date",
86
+ ),
87
+ size: positive(
88
+ "the number of units in the population, a whole number above 0 (an estimate is fine; say how it was made in `source`)",
89
+ ),
90
+ },
91
+ field(
92
+ "`population`: what the paper's claim is about (`about`), where its units come from (`source`) and how many there are (`size`)",
93
+ ),
94
+ );
95
+
96
+ const STRATA = z.object(
97
+ {
98
+ by: text("the property the units are stratified by, e.g. rule type"),
99
+ values: z
100
+ .array(
101
+ text("a value of the stratum"),
102
+ field("the stratum's values, a list of names"),
103
+ )
104
+ .min(1, {
105
+ error: "is empty — list the stratum's values, at least one",
106
+ }),
107
+ },
108
+ field(
109
+ "`strata`: the property the screens are counted by (`by`, e.g. rule type) and its values (`values`)",
110
+ ),
111
+ );
112
+
113
+ const REMOVED_WHAT =
114
+ "the number of candidates the screen removed in each declared stratum value, an object {<value>: <count>} with 0 where it removed none";
115
+
116
+ const SCREEN = z.object(
117
+ {
118
+ name: text(
119
+ "what the screen keeps, as it was applied (e.g. judgeable from one code excerpt of at most 40 lines)",
120
+ ),
121
+ removed: z.record(
122
+ z.string(),
123
+ z
124
+ .number(field("a count, a whole number from 0"))
125
+ .int(field("a count, a whole number from 0"))
126
+ .nonnegative(field("a count, a whole number from 0")),
127
+ field(REMOVED_WHAT),
128
+ ),
129
+ },
130
+ field("a screen: `name` and `removed`"),
131
+ );
132
+
133
+ const SCREENS = z.array(
134
+ SCREEN,
135
+ field(
136
+ "`screens`: one entry per screen applied between the population and the sample, each with `name` and `removed` — [] when none was",
137
+ ),
138
+ );
139
+
140
+ const SHARE_WHAT =
141
+ "the share of the population the sample can represent, a number from 0 to 1, or null with a `reason`";
142
+
143
+ const COVERAGE = z.object(
144
+ {
145
+ share: z
146
+ .number(field(SHARE_WHAT))
147
+ .min(0, field(SHARE_WHAT))
148
+ .max(1, field(SHARE_WHAT))
149
+ .nullable(),
150
+ reason: text("why the share cannot be stated").optional(),
151
+ unreachable: z.array(
152
+ text("a stratum or kind of unit"),
153
+ field(
154
+ "the strata (or kinds of units) the sample cannot reach, a list of names — [] only when it reaches them all",
155
+ ),
156
+ ),
157
+ },
158
+ field(
159
+ "`coverage`: the share of the population the sample can represent (`share`, 0 to 1, or null with a `reason`) and the strata it cannot reach (`unreachable`)",
160
+ ),
161
+ );
162
+
163
+ /** The answers to "is this what you meet in practice?"; any but the first needs a decision. */
164
+ export const VERDICTS = ["representative", "partly", "not-representative"];
165
+
166
+ const DECISION = z.object(
167
+ {
168
+ proceed: z.boolean(
169
+ field("whether the study runs on this sample, true or false"),
170
+ ),
171
+ by: text("who decided"),
172
+ why: text("why, in a sentence"),
173
+ },
174
+ field(
175
+ "the decision taken after the verdict: `proceed` (true or false), `by` whom, and `why`",
176
+ ),
177
+ );
178
+
179
+ const AUTHOR_CHECK = z.object(
180
+ {
181
+ who: text("who looked at the drawn units (a name or a role)"),
182
+ when: z.iso.date(field("the date of the check, YYYY-MM-DD")),
183
+ units_seen: positive(
184
+ "how many of the drawn units — the rules or cases drawn, not the items made from them — the author looked at, a whole number above 0",
185
+ ),
186
+ units_drawn: positive("how many units the sample drew"),
187
+ verdict: z.enum(
188
+ VERDICTS,
189
+ field(
190
+ `the author's answer to "is this what you meet in practice?" — one of ${VERDICTS.join(", ")}`,
191
+ ),
192
+ ),
193
+ decision: DECISION.optional(),
194
+ },
195
+ field(
196
+ "`author_check`: who looked at the drawn units before the run (`who`), when (`when`), how many of how many (`units_seen`, `units_drawn`), and what they said (`verdict`)",
197
+ ),
198
+ );
199
+
200
+ /** The declaration a `prereg.json` must carry. Every other key of the file is the author's. */
201
+ export const PREREG = z.object(
202
+ {
203
+ population: POPULATION,
204
+ strata: STRATA,
205
+ screens: SCREENS,
206
+ coverage: COVERAGE,
207
+ author_check: AUTHOR_CHECK,
208
+ },
209
+ field(
210
+ "a JSON object holding `population`, `strata`, `screens`, `coverage` and `author_check`",
211
+ ),
212
+ );
213
+
214
+ export type Prereg = z.infer<typeof PREREG>;
215
+
216
+ /** A zod path as it is written in the file: `screens[0].removed.vague`. */
217
+ export const pathText = (path: readonly PropertyKey[]): string =>
218
+ path
219
+ .map((k) => (typeof k === "number" ? `[${String(k)}]` : `.${String(k)}`))
220
+ .join("")
221
+ .replace(/^\./u, "");
222
+
223
+ /** One problem: the field it is about, and what is wrong with it. */
224
+ export interface Problem {
225
+ readonly at: string;
226
+ readonly text: string;
227
+ }
228
+
229
+ /** The values that occur more than once in `values`, each once. */
230
+ const repeated = (values: readonly string[]): readonly string[] => [
231
+ ...new Set(values.filter((v, i) => values.indexOf(v) !== i)),
232
+ ];
233
+
234
+ /** What one screen's `removed` lacks and holds beyond the declared strata. */
235
+ const removedProblems = (
236
+ removed: Readonly<Record<string, number>>,
237
+ i: number,
238
+ values: readonly string[],
239
+ ): readonly Problem[] => [
240
+ ...values
241
+ .filter((v) => !Object.hasOwn(removed, v))
242
+ .map((v) => ({
243
+ at: `screens[${String(i)}].removed.${v}`,
244
+ text: `is missing — every screen states how many candidates it removed in each declared stratum value (${values.join(", ")}), 0 where it removed none`,
245
+ })),
246
+ ...Object.keys(removed)
247
+ .filter((k) => !values.includes(k))
248
+ .map((k) => ({
249
+ at: `screens[${String(i)}].removed.${k}`,
250
+ text: `is not a declared stratum value — \`strata.values\` declares ${values.join(", ")}; add it there or count it under one of them`,
251
+ })),
252
+ ];
253
+
254
+ /** A problem when `when` holds, else none. */
255
+ const problemIf = (
256
+ when: boolean,
257
+ at: string,
258
+ text: string,
259
+ ): readonly Problem[] => (when ? [{ at, text }] : []);
260
+
261
+ /** The author's check against itself: no more units seen than drawn, a doubtful verdict decided. */
262
+ const checkProblems = (
263
+ check: Readonly<Prereg["author_check"]>,
264
+ ): readonly Problem[] => [
265
+ ...problemIf(
266
+ check.units_seen > check.units_drawn,
267
+ "author_check.units_seen",
268
+ `is ${String(check.units_seen)}, more than the ${String(check.units_drawn)} units drawn (\`units_drawn\`)`,
269
+ ),
270
+ ...problemIf(
271
+ check.verdict !== "representative" && check.decision === undefined,
272
+ "author_check.decision",
273
+ `is missing — the verdict is «${check.verdict}», and a doubtful verdict with no recorded decision is how a sample the author doubted gets run anyway: add \`proceed\` (true or false), \`by\` whom and \`why\``,
274
+ ),
275
+ ];
276
+
277
+ /** What a schema of one field cannot see: how the fields of a parsed declaration relate. */
278
+ export function crossProblems(p: Readonly<Prereg>): readonly Problem[] {
279
+ const values = p.strata.values;
280
+ return [
281
+ ...repeated(values).map((v) => ({
282
+ at: "strata.values",
283
+ text: `names «${v}» more than once — each stratum value once`,
284
+ })),
285
+ ...p.screens.flatMap((s, i) => removedProblems(s.removed, i, values)),
286
+ ...problemIf(
287
+ p.coverage.share === null && p.coverage.reason === undefined,
288
+ "coverage.reason",
289
+ "is missing — `share` is null, so say why the share cannot be stated",
290
+ ),
291
+ ...problemIf(
292
+ p.coverage.share === 1 && p.coverage.unreachable.length > 0,
293
+ "coverage.unreachable",
294
+ `contradicts \`share\` 1 — a sample that cannot reach ${p.coverage.unreachable.map((u) => `«${u}»`).join(", ")} does not represent the whole population: lower \`share\`, or empty the list`,
295
+ ),
296
+ ...checkProblems(p.author_check),
297
+ ];
298
+ }
299
+
300
+ /** Everything wrong with the parsed JSON of one `prereg.json`, in the file's field order. */
301
+ export function judgePrereg(data: unknown): readonly Problem[] {
302
+ const parsed = PREREG.safeParse(data);
303
+ return parsed.success
304
+ ? crossProblems(parsed.data)
305
+ : parsed.error.issues.map((i) => ({
306
+ at: pathText(i.path),
307
+ text: i.message,
308
+ }));
309
+ }
310
+
311
+ /** What `JSON.parse` says about the text it refused. */
312
+ const Thrown = z
313
+ .object({ message: z.string() })
314
+ .catch({ message: "unparseable" });
315
+
316
+ /** The problems of one `prereg.json` whose text is `source`: a parse failure, or the judgement. */
317
+ export function preregProblems(source: string): readonly Problem[] {
318
+ const parsed = ((): { readonly data: unknown } | { readonly why: string } => {
319
+ try {
320
+ const data: unknown = JSON.parse(source);
321
+ return { data };
322
+ } catch (e) {
323
+ return { why: Thrown.parse(e).message };
324
+ }
325
+ })();
326
+ return "why" in parsed
327
+ ? [
328
+ {
329
+ at: "",
330
+ text: `does not parse as JSON (${parsed.why}) — there is no declaration to read`,
331
+ },
332
+ ]
333
+ : judgePrereg(parsed.data);
334
+ }
335
+
336
+ /** The one text a problem in the file `file` (relative to the paper) is reported with. */
337
+ export const problemMessage = (file: string, p: Problem): string =>
338
+ [
339
+ p.at === ""
340
+ ? `\`${file}\` ${p.text}.`
341
+ : `\`${file}\`: \`${p.at}\` ${p.text}.`,
342
+ WHY,
343
+ `Rule study/declare-sample: ${PAGE}`,
344
+ ].join(" ");
345
+
346
+ /**
347
+ * The `prereg.json` of every study folder at the top of `<paperDir>/repro/`, relative to the paper:
348
+ * every folder there but the shared ones, whatever its name — a study folder misnamed is
349
+ * `repro/allowed-files-only`'s finding, and its pre-registration is still judged here.
350
+ */
351
+ export function preregFiles(paperDir: string): readonly string[] {
352
+ const repro = join(paperDir, REPRO_DIR);
353
+ return existsSync(repro) && statSync(repro).isDirectory()
354
+ ? readdirSync(repro, { withFileTypes: true })
355
+ // stat, not the entry's own type: a study folder linked into repro/ is a folder to
356
+ // repro/allowed-files-only too, and must not escape the check by being a link
357
+ .filter(
358
+ (e) =>
359
+ !SHARED_DIRS.includes(e.name) &&
360
+ (statSync(join(repro, e.name), {
361
+ throwIfNoEntry: false,
362
+ })?.isDirectory() ??
363
+ false),
364
+ )
365
+ .map((e) => `${REPRO_DIR}/${e.name}/${PREREG_FILE}`)
366
+ .filter((rel) => existsSync(join(paperDir, rel)))
367
+ .sort()
368
+ : [];
369
+ }
370
+
371
+ /** A `prereg.json` that is not a regular file (a folder, a socket): reading it would abort the run. */
372
+ const NOT_A_FILE: readonly Problem[] = [
373
+ {
374
+ at: "",
375
+ text: "is not a file — the declaration is one JSON file; move what the folder holds elsewhere",
376
+ },
377
+ ];
378
+
379
+ /** Every finding for the paper folder `paperDir`, one text each. */
380
+ export const paperFindings = (paperDir: string): readonly string[] =>
381
+ preregFiles(paperDir).flatMap((rel) => {
382
+ const path = join(paperDir, rel);
383
+ const problems = statSync(path).isFile()
384
+ ? preregProblems(readFileSync(path, "utf8"))
385
+ : NOT_A_FILE;
386
+ return problems.map((p) => problemMessage(rel, p));
387
+ });
388
+
389
+ export default {
390
+ rules: {
391
+ "declare-sample": {
392
+ meta: {
393
+ type: "problem" as const,
394
+ docs: {
395
+ description:
396
+ "every study's prereg.json declares its population, the screens and what each removed per stratum, the sample's coverage, and the author's check of the drawn units",
397
+ url: PAGE,
398
+ },
399
+ schema: [],
400
+ messages: { coverage: "{{text}}" },
401
+ },
402
+ create(context: RuleContext) {
403
+ if (basename(context.filename) !== STATUS_FILE) return {};
404
+ return {
405
+ root() {
406
+ const at = context.sourceCode.getLocFromIndex(0);
407
+ paperFindings(dirname(context.filename)).forEach((t) => {
408
+ context.report({
409
+ loc: { start: at, end: at },
410
+ messageId: "coverage",
411
+ data: { text: t },
412
+ });
413
+ });
414
+ },
415
+ };
416
+ },
417
+ },
418
+ },
419
+ };
package/eslint.config.mjs CHANGED
@@ -250,6 +250,9 @@ const ROOT = { file: { categories: "root" } };
250
250
  */
251
251
  export const layerBoundaries = (root) => ({
252
252
  files: ["src/**/*.ts"],
253
+ // The source of src/CLAUDE.md sits beside it because `vigiles compile` requires that; it is an
254
+ // instruction file, not a layer of the package.
255
+ ignores: ["src/CLAUDE.md.spec.ts"],
253
256
  plugins: { boundaries },
254
257
  settings: {
255
258
  "boundaries/root-path": root,
@@ -681,7 +684,7 @@ export default [
681
684
  languageOptions: { ecmaVersion: 2024, sourceType: "module" },
682
685
  plugins: { local: localRules, port: portRules },
683
686
  rules: {
684
- // Rule 10's mechanical half, code side. `warn` and not `error`, unlike its neighbour
687
+ // The install-location rule's mechanical half (root CLAUDE.md), code side. `warn` and not `error`, unlike its neighbour
685
688
  // above: this one does NOT open on a clean corpus. Two shipped modules print a command
686
689
  // for a human that names an install-specific path, and neither is fixable by the
687
690
  // answer the skills get — a printed command has to be resolved through the port at
@@ -741,7 +744,7 @@ export default [
741
744
  rules: { "n/no-missing-import": "error" },
742
745
  },
743
746
  /**
744
- * Rule 10's mechanical half, prose side — and this is where the debt actually is: 76
747
+ * The install-location rule's mechanical half (root CLAUDE.md), prose side — and this is where the debt actually is: 76
745
748
  * findings across 29 skills on a healthy checkout, every one of them a command that
746
749
  * resolves in one delivery channel and is absent in another.
747
750
  *
@@ -789,7 +792,7 @@ export default [
789
792
  // ⚠️ `npx eslint .` therefore reports six warnings on a healthy checkout: the four defect
790
793
  // fixtures are DEFECTIVE ON PURPOSE, and that is what makes the fire half of every harness
791
794
  // real. Do not silence them by adding an ignore — a rule that lints only clean inputs is
792
- // one whose firing path nothing exercises, which is rule 4 wearing a different hat. The
795
+ // one whose firing path nothing exercises, which is the green zero of scripts/rules-see-files.mjs wearing a different hat. The
793
796
  // pass/fail signal lives in `npm test`, not in the warning count of `npm run lint`.
794
797
  },
795
798
  {
@@ -0,0 +1,34 @@
1
+ <!-- vigiles:sha256:0407b53b2036133e compiled from hooks/CLAUDE.md.spec.ts -->
2
+
3
+ # CLAUDE.md
4
+
5
+ ## `hooks/`
6
+
7
+ The hooks this package ships into a consumer's Claude Code session. Loaded when working under
8
+ `hooks/`. Every shipped hook is run in both halves, firing and silent, by `hooks/hooks.harness.ts`.
9
+
10
+ A bug here does not break this repository; it breaks somebody else's whole session. The Bash gate
11
+ fails closed, so a hook that cannot read its config denies every command, including the one that
12
+ would undo it.
13
+
14
+ ## Rules
15
+
16
+ ### Ship Hook Mjs Never Hook Ts
17
+
18
+ **Guidance only** — A `.ts` hook inside `node_modules` fails to load and blocks every Bash command. Rejected, so not proposed again: a `.hook.ts` twin compiled to the `.mjs` (a twin drifts from its build, and nothing would notice), and a thin `.hook.ts` in the consumer importing the logic from here (`vigiles compile` refuses the import, so the hook can never be re-stamped, and the runtime fails closed on the stale stamp). [Measurements](https://github.com/zernie/paperlint/blob/b4a3dc3a81d436186f41db2d69b65fd93f1af3b6/CLAUDE.md#L272-L357).
19
+
20
+ ### No Sibling Imports
21
+
22
+ **Guidance only** — A shipped hook cannot import a sibling module. The papers-root resolver is repeated in each hook, and `hooks.harness.ts` compares the copies.
23
+
24
+ ### No Import Shaped Text
25
+
26
+ **Guidance only** — `checkHookImports` (run by `hooks.harness.ts`) is a text regex. Describe a forbidden import in words; never write one, not even in a comment.
27
+
28
+ ### One Wiring File
29
+
30
+ **Guidance only** — The wiring is `plugin/hooks/hooks.json`. `paperlint init` merges it into the consumer's `.claude/settings.json`; there is no second copy.
31
+
32
+ ### Vigiles Moves With The Consumer
33
+
34
+ **Guidance only** — `vigiles` moves with the consumer's pin, in one pass. A major mismatch makes `PreToolUse` refuse every command. `npm ls vigiles` compares what is installed with the manifest, not with the registry; Dependabot (`.github/dependabot.yml`) carries the registry half.
@@ -0,0 +1,30 @@
1
+ // Compiled to hooks/CLAUDE.md by `npx vigiles compile`. Edit THIS file, never the markdown.
2
+ import { file, guidance, instructionFile, prose } from "vigiles/spec";
3
+
4
+ export default instructionFile({
5
+ sections: {
6
+ "`hooks/`": prose`The hooks this package ships into a consumer's Claude Code session. Loaded when working under
7
+ \`hooks/\`. Every shipped hook is run in both halves, firing and silent, by ${file("hooks/hooks.harness.ts")}.
8
+
9
+ A bug here does not break this repository; it breaks somebody else's whole session. The Bash gate
10
+ fails closed, so a hook that cannot read its config denies every command, including the one that
11
+ would undo it.`,
12
+ },
13
+ rules: {
14
+ "ship-hook-mjs-never-hook-ts": guidance(
15
+ "A `.ts` hook inside `node_modules` fails to load and blocks every Bash command. Rejected, so not proposed again: a `.hook.ts` twin compiled to the `.mjs` (a twin drifts from its build, and nothing would notice), and a thin `.hook.ts` in the consumer importing the logic from here (`vigiles compile` refuses the import, so the hook can never be re-stamped, and the runtime fails closed on the stale stamp). [Measurements](https://github.com/zernie/paperlint/blob/b4a3dc3a81d436186f41db2d69b65fd93f1af3b6/CLAUDE.md#L272-L357).",
16
+ ),
17
+ "no-sibling-imports": guidance(
18
+ "A shipped hook cannot import a sibling module. The papers-root resolver is repeated in each hook, and `hooks.harness.ts` compares the copies.",
19
+ ),
20
+ "no-import-shaped-text": guidance(
21
+ "`checkHookImports` (run by `hooks.harness.ts`) is a text regex. Describe a forbidden import in words; never write one, not even in a comment.",
22
+ ),
23
+ "one-wiring-file": guidance(
24
+ "The wiring is `plugin/hooks/hooks.json`. `paperlint init` merges it into the consumer's `.claude/settings.json`; there is no second copy.",
25
+ ),
26
+ "vigiles-moves-with-the-consumer": guidance(
27
+ "`vigiles` moves with the consumer's pin, in one pass. A major mismatch makes `PreToolUse` refuse every command. `npm ls vigiles` compares what is installed with the manifest, not with the registry; Dependabot (`.github/dependabot.yml`) carries the registry half.",
28
+ ),
29
+ },
30
+ });
@@ -95,7 +95,7 @@ export function observeAgentCli({
95
95
 
96
96
  // ⚠️ EXIT 0 AND NOTHING ON STDOUT IS NOT A CLEAN OBSERVATION — it is exactly what the discarded
97
97
  // probe looked like, and reporting `characterized against ""` would be the same lie in a new
98
- // place. This is rule 4 of CLAUDE.md applied to the check that enforces it.
98
+ // place. This is the green zero of scripts/rules-see-files.mjs, applied to the check that enforces it.
99
99
  if (version === "")
100
100
  throw new Error(
101
101
  `\`${program} --version\` exited 0 but printed no version on stdout (got ${JSON.stringify(raw)}` +
@@ -21,7 +21,7 @@ import { mkdtempSync, writeFileSync, mkdirSync, realpathSync } from "node:fs";
21
21
  import { tmpdir } from "node:os";
22
22
  import { dirname, join } from "node:path";
23
23
  import { fileURLToPath } from "node:url";
24
- import { recordCheck } from "vigiles";
24
+ import { assertPromptDiversity, recordCheck } from "vigiles";
25
25
  import { CASES, BY_SKILL, irrelevantFor } from "./skill-trigger-cases.mjs";
26
26
 
27
27
  const HERE = dirname(fileURLToPath(import.meta.url));
@@ -86,10 +86,9 @@ const LOCAL_ONE = `export const CASES = [{
86
86
  soft("I. every case is shaped and every collider resolves", () => {
87
87
  for (const c of CASES) {
88
88
  assert.equal(typeof c.skill, "string", `a case has no skill name`);
89
- assert.equal(
90
- c.prompts.length,
91
- 4,
92
- `${c.skill}: ${c.prompts.length} prompts, expected 4`,
89
+ assert.ok(
90
+ c.prompts.length >= 4,
91
+ `${c.skill}: ${c.prompts.length} prompts, expected at least 4`,
93
92
  );
94
93
  for (const sib of c.collides)
95
94
  assert.ok(
@@ -112,6 +111,53 @@ soft("I. irrelevantFor still yields a full set for a one-collider case", () => {
112
111
  );
113
112
  });
114
113
 
114
+ // A collider that contributes nothing to the irrelevant set is named in the table and never
115
+ // measured: the case reads as "must not take X's requests" and no request of X's is ever sent.
116
+ // `irrelevantFor` used to cut the set at four, so a case with more than four colliders silently
117
+ // lost the rest — grade-paper-writing, which names seven, lost three (2026-10-13).
118
+ soft(
119
+ "I. every collider of every case contributes to its irrelevant set",
120
+ () => {
121
+ for (const c of CASES) {
122
+ const got = new Set(irrelevantFor(c.skill));
123
+ const silent = c.collides.filter(
124
+ (sib) => !BY_SKILL.get(sib).prompts.some((p) => got.has(p)),
125
+ );
126
+ assert.deepEqual(silent, [], `${c.skill}: colliders never measured`);
127
+ }
128
+ },
129
+ );
130
+
131
+ // The paid tier refuses a set that is too small or near-duplicate (vigiles' diversity pre-flight,
132
+ // run by `measureTriggerRate` on both sides) — but only when someone pays to run it. The same gate,
133
+ // with the same options `lib/skill-eval-kit.mjs` passes, decided here for free on every push.
134
+ soft(
135
+ "I. every case would pass the paid tier's prompt-diversity pre-flight",
136
+ () => {
137
+ for (const c of CASES) {
138
+ assertPromptDiversity(c.prompts, {
139
+ minPrompts: 4,
140
+ label: `${c.skill} prompts`,
141
+ });
142
+ assertPromptDiversity(irrelevantFor(c.skill), {
143
+ minPrompts: 4,
144
+ label: `${c.skill} irrelevantPrompts`,
145
+ });
146
+ }
147
+ },
148
+ );
149
+
150
+ // A prompt listed under two skills says it must fire both, and each case's precision run then
151
+ // counts the other's correct answer as a false positive.
152
+ soft("I. no prompt is claimed by two cases", () => {
153
+ const owners = new Map();
154
+ for (const c of CASES)
155
+ for (const p of c.prompts)
156
+ owners.set(p, [...(owners.get(p) ?? []), c.skill]);
157
+ const shared = [...owners].filter(([, o]) => o.length > 1);
158
+ assert.deepEqual(shared, []);
159
+ });
160
+
115
161
  // ── II. The carrier, all four states ─────────────────────────────────────────
116
162
  soft(
117
163
  "II. undeclared and absent is SILENT — a consumer with no skills of its own",