paperlint 9.0.1 → 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 (150) hide show
  1. package/README.md +68 -128
  2. package/dist/build.d.ts +1 -1
  3. package/dist/build.js +1 -1
  4. package/dist/cli.d.ts +5 -1
  5. package/dist/cli.d.ts.map +1 -1
  6. package/dist/cli.js +52 -6
  7. package/dist/cli.js.map +1 -1
  8. package/dist/domain/layout-tree.d.ts +19 -0
  9. package/dist/domain/layout-tree.d.ts.map +1 -0
  10. package/dist/domain/layout-tree.js +32 -0
  11. package/dist/domain/layout-tree.js.map +1 -0
  12. package/dist/domain/lookup-cache.d.ts +2 -2
  13. package/dist/domain/lookup-cache.js +2 -2
  14. package/dist/domain/paper-layout.d.ts +81 -0
  15. package/dist/domain/paper-layout.d.ts.map +1 -0
  16. package/dist/domain/paper-layout.js +259 -0
  17. package/dist/domain/paper-layout.js.map +1 -0
  18. package/dist/domain/repro-layout.d.ts +80 -0
  19. package/dist/domain/repro-layout.d.ts.map +1 -0
  20. package/dist/domain/repro-layout.js +247 -0
  21. package/dist/domain/repro-layout.js.map +1 -0
  22. package/dist/domain/scaffold-layout.d.ts +28 -0
  23. package/dist/domain/scaffold-layout.d.ts.map +1 -0
  24. package/dist/domain/scaffold-layout.js +81 -0
  25. package/dist/domain/scaffold-layout.js.map +1 -0
  26. package/dist/eslint-rules/prereg-coverage.d.ts +98 -0
  27. package/dist/eslint-rules/prereg-coverage.d.ts.map +1 -0
  28. package/dist/eslint-rules/prereg-coverage.js +274 -0
  29. package/dist/eslint-rules/prereg-coverage.js.map +1 -0
  30. package/dist/folder-layout-hook.d.ts +21 -0
  31. package/dist/folder-layout-hook.d.ts.map +1 -0
  32. package/dist/folder-layout-hook.js +117 -0
  33. package/dist/folder-layout-hook.js.map +1 -0
  34. package/dist/folder-layout-rule.d.ts +22 -0
  35. package/dist/folder-layout-rule.d.ts.map +1 -0
  36. package/dist/folder-layout-rule.js +107 -0
  37. package/dist/folder-layout-rule.js.map +1 -0
  38. package/dist/init.js +1 -1
  39. package/dist/init.js.map +1 -1
  40. package/dist/new-paper.d.ts +4 -1
  41. package/dist/new-paper.d.ts.map +1 -1
  42. package/dist/new-paper.js +40 -6
  43. package/dist/new-paper.js.map +1 -1
  44. package/dist/references.d.ts +1 -1
  45. package/dist/references.d.ts.map +1 -1
  46. package/dist/references.js +2 -2
  47. package/dist/references.js.map +1 -1
  48. package/dist/repro-layout-rule.d.ts +16 -0
  49. package/dist/repro-layout-rule.d.ts.map +1 -0
  50. package/dist/repro-layout-rule.js +81 -0
  51. package/dist/repro-layout-rule.js.map +1 -0
  52. package/docs/ci.md +60 -0
  53. package/docs/configuration.md +2 -0
  54. package/docs/e2e.md +2 -2
  55. package/docs/install.md +20 -16
  56. package/docs/layout.md +154 -0
  57. package/docs/optional-rules.md +5 -4
  58. package/docs/references.md +5 -2
  59. package/docs/rules/paper/allowed-files-only.md +192 -0
  60. package/docs/rules/repro/allowed-files-only.md +128 -0
  61. package/docs/rules/study/declare-sample.md +146 -0
  62. package/docs/rules.md +53 -50
  63. package/docs/skills.md +6 -3
  64. package/docs/venues.md +94 -0
  65. package/eslint-rules/prereg-coverage.test.ts +448 -0
  66. package/eslint-rules/prereg-coverage.ts +419 -0
  67. package/lib/skill-trigger-cases.harness.mjs +51 -5
  68. package/lib/skill-trigger-cases.mjs +53 -15
  69. package/package.json +1 -1
  70. package/plugin/hooks/hooks.json +9 -0
  71. package/skills/analyze-sibling-paper/SKILL.md +2 -2
  72. package/skills/analyze-sibling-paper/SKILL.md.spec.ts +1 -1
  73. package/skills/build-benchmark/SKILL.md +14 -1
  74. package/skills/build-benchmark/SKILL.md.spec.ts +13 -0
  75. package/skills/build-label-page/SKILL.md +118 -0
  76. package/skills/build-label-page/SKILL.md.spec.ts +129 -0
  77. package/skills/build-label-page/build-label-page.eval.mjs +15 -0
  78. package/skills/build-label-page/build-label-page.test.ts +959 -0
  79. package/skills/build-label-page/references/checklist.md +148 -0
  80. package/skills/build-label-page/references/example-generator.py +803 -0
  81. package/skills/find-venue/SKILL.md +5 -3
  82. package/skills/find-venue/SKILL.md.spec.ts +4 -2
  83. package/skills/grade-paper-writing/SKILL.md +7 -7
  84. package/skills/grade-paper-writing/SKILL.md.spec.ts +6 -6
  85. package/skills/map-prior-work/SKILL.md +2 -2
  86. package/skills/map-prior-work/SKILL.md.spec.ts +1 -1
  87. package/skills/paper-pipeline/SKILL.md +8 -6
  88. package/skills/paper-pipeline/SKILL.md.spec.ts +7 -5
  89. package/skills/paper-pipeline/grade-paper-writing-ablation.eval.mjs +19 -9
  90. package/skills/paper-pipeline/references/acceptance-gate.md +19 -19
  91. package/skills/paper-pipeline/references/artifact-checklist.md +5 -0
  92. package/skills/paper-pipeline/references/pipeline-status-template.md +30 -1
  93. package/skills/paper-pipeline/repro/2026-08-07-fork-check.log +1 -1
  94. package/skills/paper-pipeline/repro/2026-08-07-fork-check2.log +1 -1
  95. package/skills/paper-pipeline/repro/2026-08-07-language-eval-pilot.log +1 -1
  96. package/skills/paper-pipeline/repro/2026-08-07-language-eval-raw.log +1 -1
  97. package/skills/paper-pipeline/repro/2026-08-08-framing-vs-vocabulary-oracle.log +48 -48
  98. package/skills/paper-pipeline/repro/2026-08-08-grade-paper-writing-ablation-A6-oracle.log +8 -8
  99. package/skills/paper-pipeline/repro/2026-08-08-grade-paper-writing-ablation-oracle.log +48 -48
  100. package/skills/paper-pipeline/repro/2026-08-08-parent-replication-gpw.log +2 -2
  101. package/skills/paper-pipeline/repro/2026-08-08-parent-replication.mjs +3 -3
  102. package/skills/paper-pipeline/repro/2026-10-13-grade-paper-writing-ablation-preflight-after.log +81 -0
  103. package/skills/paper-pipeline/repro/2026-10-13-grade-paper-writing-ablation-preflight-before.log +81 -0
  104. package/skills/paper-pipeline/repro/2026-10-13-grade-paper-writing-ablation-setupdiff.log +59 -0
  105. package/skills/paper-pipeline/repro/2026-10-13-grade-paper-writing-trigger.jsonl +1 -0
  106. package/skills/paper-pipeline/repro/2026-10-13-grade-paper-writing-trigger.log +22 -0
  107. package/skills/paper-pipeline/repro/README.md +4 -0
  108. package/skills/paper-pipeline/scripts/extract-ref-facts.harness.mjs +2 -2
  109. package/skills/paper-pipeline/scripts/extract-ref-facts.mjs +2 -2
  110. package/skills/paper-pipeline/scripts/extract-ref-facts.test.mjs +4 -2
  111. package/skills/paper-pipeline/scripts/generated-code.harness.mjs +7 -2
  112. package/skills/paper-pipeline/scripts/generated-code.mjs +8 -4
  113. package/skills/paper-pipeline/scripts/generated-code.test.ts +15 -1
  114. package/skills/paper-status/SKILL.md +2 -2
  115. package/skills/paper-status/SKILL.md.spec.ts +1 -1
  116. package/skills/study-accepted-papers/SKILL.md +163 -161
  117. package/skills/study-accepted-papers/SKILL.md.spec.ts +167 -165
  118. package/skills/study-accepted-papers/references/base-rates.md +69 -0
  119. package/skills/study-accepted-papers/references/mechanisms.md +181 -0
  120. package/skills/verify-citations/SKILL.md +2 -2
  121. package/skills/verify-citations/SKILL.md.spec.ts +1 -1
  122. package/src/build.ts +1 -1
  123. package/src/cli.harness.mjs +42 -0
  124. package/src/cli.test.ts +90 -6
  125. package/src/cli.ts +78 -5
  126. package/src/domain/layout-tree.test.ts +32 -0
  127. package/src/domain/layout-tree.ts +47 -0
  128. package/src/domain/lookup-cache.ts +2 -2
  129. package/src/domain/paper-layout.test.ts +264 -0
  130. package/src/domain/paper-layout.ts +336 -0
  131. package/src/domain/repro-layout.test.ts +222 -0
  132. package/src/domain/repro-layout.ts +311 -0
  133. package/src/domain/scaffold-layout.ts +104 -0
  134. package/src/folder-layout-hook.test.ts +214 -0
  135. package/src/folder-layout-hook.ts +141 -0
  136. package/src/folder-layout-rule.test.ts +235 -0
  137. package/src/folder-layout-rule.ts +151 -0
  138. package/src/hooks-settings.harness.ts +9 -8
  139. package/src/init.ts +1 -1
  140. package/src/layout-docs.test.ts +52 -0
  141. package/src/new-paper.harness.mjs +23 -1
  142. package/src/new-paper.test.ts +299 -6
  143. package/src/new-paper.ts +51 -5
  144. package/src/paper-files.test.ts +9 -6
  145. package/src/references.test.ts +5 -5
  146. package/src/references.ts +2 -2
  147. package/src/repro-layout-rule.test.ts +114 -0
  148. package/src/repro-layout-rule.ts +121 -0
  149. package/src/rule-cases.test.ts +98 -0
  150. package/templates/paper/PIPELINE-STATUS.md +41 -7
@@ -0,0 +1,181 @@
1
+ # Mechanisms of a strong accept — the generic list (M1–M10)
2
+
3
+ What separates a venue's award winners from its ordinary accepted papers, written so it can be checked
4
+ against any draft. The ids are the rows of the scorecard section in the paper's `PIPELINE-STATUS.md`;
5
+ the skill scores a draft against them in mode C and checks the wording in mode D.
6
+
7
+ **Where the list comes from, and how far it reaches.** One run of this skill on one software-engineering
8
+ venue (zernie/paperlint#207): every award winner the venue listed for seven years against the ordinary
9
+ papers of the same years, then twelve papers read in full, with two award / no-award pairs on the same
10
+ question. It is a list of **hypotheses to re-test on your venue**, not a law. Each entry says what
11
+ supports it and what it is not. The examples below are award papers, named by title; what is said about
12
+ each comes from reading its full text.
13
+
14
+ **What did NOT separate the winners** (the same run, issue #207): form. Body words, figures, tables,
15
+ title shape, "first / gap / doubt" phrasing and statistics markers: 13 features, none significant after
16
+ correction, on 21 winners against 112 ordinary papers. Releasing an artifact did not separate them
17
+ either, because most ordinary papers release one too. Do not rebuild a paper's form to match the
18
+ winners. In that run, an apparent "winners have many figures" pattern came from one year of five papers
19
+ and did not hold across seven.
20
+
21
+ The two published sources on how reviewers in software engineering judge papers point the same way.
22
+ The ICSE 2018 PC report found best-paper nominees furthest above accepted papers on significance and
23
+ soundness, not on presentation. Ernst et al. (EMSE 2021) found negative reviews come from "a mismatch
24
+ between the method and the claims" and "overly grandiose claims". Both are quoted in
25
+ [`base-rates.md`](base-rates.md).
26
+
27
+ ---
28
+
29
+ | id | mechanism | the check |
30
+ | ------- | ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
31
+ | **M1** | a non-model oracle under the headline | what decides the headline number: execution, a developer's action, or people reading, with their agreement reported? |
32
+ | **M2** | the claim is aimed at a belief stated on record | which sentence, by whom, does the result confirm or refute, quoted and cited? |
33
+ | **M3** | the construct is the claim | does what the oracle decides equal what the claim sentence asserts? |
34
+ | **M4** | the denominator in the claim sentence, no drift | does every claim sentence carry its scope, and does the abstract say no more than the body? |
35
+ | **M5** | a negative or "X alone" claim shape | is the claim a counter-example to a stated universal, or "X alone is not Y"? |
36
+ | **M6** | the generalised-over factor is a column | if the claim names a class (models, tools, languages), is that class a manipulated column with every cell printed? |
37
+ | **M7** | the phenomenon measured directly | is the thing the claim is about an outcome of its own, not inferred from a downstream proxy? |
38
+ | **M8** | a reusable thing the field pays for once | which benchmark, method, labelled set or actionable number will a reader reuse? |
39
+ | **M9** | relevance to every reviewer on the PC | would a reviewer outside the topic care, e.g. because it is about how the venue does its own science? |
40
+ | **M10** | where the paper's own result loses | is the case where the method or tool fails reported as prominently as where it wins? |
41
+
42
+ ---
43
+
44
+ ## M1 — a non-model oracle under the headline
45
+
46
+ **What it is.** The number in the title or abstract is decided by something other than a model's
47
+ judgement: running the code, what developers actually did, or people reading, with how much they agreed.
48
+
49
+ **Supports it.** In the run, no winner rested its headline on model-only labels. Award examples:
50
+ _Does Functional Package Management Enable Reproducible Builds at Scale? Yes._ (MSR 2025) rebuilds
51
+ packages and compares the result bit for bit; _A Large-scale Study about Quality and Reproducibility of
52
+ Jupyter Notebooks_ (MSR 2019) re-executes the notebooks; _Can LLMs Replace Manual Annotation of
53
+ Software Engineering Artifacts?_ (MSR 2025) keeps human annotations as the reference the models are
54
+ measured against.
55
+
56
+ **What it is not.** Not sufficient: in the run's pairs, papers with a human anchor still lost on M3 and
57
+ M4. Not "report κ": winners with and without an agreement statistic were indistinguishable. The point is
58
+ what decides the headline, not the statistic beside it.
59
+
60
+ ## M2 — the claim is aimed at a belief stated on record
61
+
62
+ **What it is.** The introduction quotes a position the field has written down (a claim in a cited
63
+ paper, a practice the venue's own papers follow, a widely read critique) and the result confirms or
64
+ refutes it.
65
+
66
+ **Supports it.** All the award papers read in full did this; the non-award papers read mostly did not.
67
+ The reproducible-builds paper answers a doubt that a cited interview study put on record.
68
+
69
+ **What it is not.** Not "no study has done X": a gap is not a belief. Doubt words ("first", "gap",
70
+ "little is known") did not separate winners in the corpus count. What separates is a named position
71
+ with a source.
72
+
73
+ ## M3 — the construct is the claim
74
+
75
+ **What it is.** What the oracle measures is exactly what the claim sentence asserts, so a reviewer
76
+ cannot ask "but is that what you mean?".
77
+
78
+ **Supports it.** Bitwise equality _is_ reproducibility (the reproducible-builds paper); re-execution
79
+ _is_ reproducibility (the notebooks paper). _Clarity Is Not Assumed_ (ASE 2026) defines its construct
80
+ formally before measuring it, so a change in the outcome can mean only one thing. In the run's
81
+ award / no-award pair on one question, this was the deciding difference, not the number of raters.
82
+
83
+ **What it is not.** Not the same as having an oracle (M1): a valid oracle on a nearby construct still
84
+ fails here.
85
+
86
+ ## M4 — the denominator in the claim sentence, no drift
87
+
88
+ **What it is.** Each claim sentence carries its scope ("in the 1,000 repositories that…", "for the
89
+ evaluated models"), and the abstract and introduction claim no more than the body shows.
90
+
91
+ **Supports it.** The reproducible-builds abstract scopes its main sentence to one package manager. _The
92
+ Hidden Cost of Readability_ (ICSE 2026) has a title that asserts only what its oracle measured. In the
93
+ run's pairs, the non-award paper lost where its introduction or abstract said more than its own tables.
94
+
95
+ **What it is not.** Not hedging. A bold title wins when the fragile half of the claim lives, scoped, in
96
+ the body; the defect is drift between the levels, not confidence.
97
+
98
+ ## M5 — a negative or "X alone" claim shape
99
+
100
+ **What it is.** Refute a stated universal with one large counter-example, or show that one factor alone
101
+ does not produce the outcome everyone assumes it does.
102
+
103
+ **Supports it.** The reproducible-builds paper refutes "reproducible builds do not scale" and, with
104
+ equal weight, the opposite myth about its own tool.
105
+
106
+ **What it is not.** A universal positive ("every X is Y") has the opposite logic: no single census can
107
+ carry it, so it either narrows or needs many independent settings.
108
+
109
+ ## M6 — the generalised-over factor is a column
110
+
111
+ **What it is.** When the claim names a class (language models, tools, languages, ecosystems), the paper
112
+ varies that factor alone inside one design, prints every cell, and names the member that deviates.
113
+
114
+ **Supports it.** _The Hidden Cost of Readability_ runs ten models in one design and names the outliers.
115
+ _Understanding Software Engineering Agents: A Study of Thought-Action-Result Trajectories_ (ASE 2025)
116
+ names in its threats that comparing distinct models complicates a fair comparison, and scopes its
117
+ claims accordingly.
118
+
119
+ **What it is not.** Not necessary for an award: most winners in the run were observational. It is
120
+ necessary to use the class noun without bounding it to the members measured.
121
+
122
+ ## M7 — the phenomenon measured directly
123
+
124
+ **What it is.** The thing the claim is about is an outcome of its own (for example, disagreement between
125
+ readings measured as a rate), beside the downstream outcome (for example, correctness) that it affects.
126
+
127
+ **Supports it.** _Clarity Is Not Assumed_ measures conflict between a model's outputs directly, beside
128
+ correctness. In the run, that was what separated it from a no-award paper on the same question, which
129
+ measured correctness alone.
130
+
131
+ **What it is not.** Only applies when the claim is about the phenomenon itself; a claim about an effect
132
+ is carried by M3 and M5.
133
+
134
+ ## M8 — a reusable thing the field pays for once
135
+
136
+ **What it is.** A named benchmark, a method another team can apply, a labelled set, or an actionable
137
+ number a practitioner can use.
138
+
139
+ **Supports it.** The ambiguity benchmark of _Clarity Is Not Assumed_; the trajectory analysis method of
140
+ the agents paper; the decision procedure of the annotation paper.
141
+
142
+ **What it is not.** A released artifact alone is an admission ticket, not a differentiator: most
143
+ ordinary accepted papers release one. The differentiator is that a reader reuses it.
144
+
145
+ ## M9 — relevance to every reviewer on the PC
146
+
147
+ **What it is.** A result that matters to reviewers outside the paper's topic, most often because it is
148
+ about how the venue's community does its own research (its datasets, its labelling practice, its
149
+ reproducibility).
150
+
151
+ **Supports it.** In the run, meta-research was over-represented among winners; examples are the
152
+ annotation paper and _Escaping the Time Pit_ (MSR 2021). This was a post-hoc observation by one coder.
153
+ Treat it as the weakest entry.
154
+
155
+ **What it is not.** Not a form. It is a topic choice, so it is a mode A or B decision, rarely a mode C
156
+ fix.
157
+
158
+ ## M10 — where the paper's own result loses
159
+
160
+ **What it is.** The cases where the paper's method, tool or hypothesis fails are reported in the same
161
+ place and with the same prominence as where it wins.
162
+
163
+ **Supports it.** The reproducible-builds paper names a "false myth" about its own tool; the annotation
164
+ paper reports the tasks where models failed beside the ones where they succeeded.
165
+
166
+ **What it is not.** Not a threats section. This is what keeps M2 and M5 from reading as advocacy.
167
+
168
+ ---
169
+
170
+ ## Using the list
171
+
172
+ - **Mode A (idea) and B (design):** M1, M2, M3, M6 and M7 are design decisions. Write each as a
173
+ requirement for the study before anything runs: what the oracle is, which stated belief is targeted,
174
+ which factor is a column.
175
+ - **Mode C (draft):** score every row of the scorecard section in `PIPELINE-STATUS.md`. Each row gets a
176
+ status (pass / partial / fail), evidence as `file:line` in the paper source, an owner, and a plan or an
177
+ explicit "accept ceiling" with the reason.
178
+ - **Mode D (final):** M4 and M2, sentence by sentence, in the abstract and the introduction.
179
+ - **Re-test the list on your venue.** Add a mechanism your venue's winners share, with its evidence, in
180
+ the venue corpus (`<papers-root>/venues/<venue>/`). Mark one that does not hold there. The ids M1–M10
181
+ stay stable so scorecards stay comparable.
@@ -4,7 +4,7 @@ description: Verify every citation is a real work with correct metadata, and tha
4
4
  allowed-tools: [Read, Write, Edit, Grep, Glob, Bash, WebSearch, WebFetch, Agent]
5
5
  ---
6
6
 
7
- <!-- vigiles:sha256:fdb023ed7c613e1b compiled from skills/verify-citations/SKILL.md.spec.ts -->
7
+ <!-- vigiles:sha256:47a4b53679a716dd compiled from skills/verify-citations/SKILL.md.spec.ts -->
8
8
 
9
9
  # verify-citations — every cite real, the delta explicit
10
10
 
@@ -134,7 +134,7 @@ built and records the verdicts, with the SHA-256 of the bibliography it checked,
134
134
  changed since, and `paper/refs-checked` warns when nothing was recorded or the build had no network. The
135
135
  step never fails the build.
136
136
 
137
- What the services ANSWERED is kept in `<paper>/repro/references-cache.json` — **commit it**. A build asks
137
+ What the services ANSWERED is kept in `<paper>/repro/data/references-cache.json` — **commit it**. A build asks
138
138
  only the questions that file cannot answer (a new or edited entry, or an answer older than 30 days), so an
139
139
  unchanged bibliography builds with no network at all, in CI too, until its answers age out. It holds responses, never verdicts: the verdicts are derived again on
140
140
  every build. A service that refuses (429, HTML instead of JSON, a timeout) is not asked again for the rest of the
@@ -150,7 +150,7 @@ built and records the verdicts, with the SHA-256 of the bibliography it checked,
150
150
  changed since, and \`paper/refs-checked\` warns when nothing was recorded or the build had no network. The
151
151
  step never fails the build.
152
152
 
153
- What the services ANSWERED is kept in \`<paper>/repro/references-cache.json\` — **commit it**. A build asks
153
+ What the services ANSWERED is kept in \`<paper>/repro/data/references-cache.json\` — **commit it**. A build asks
154
154
  only the questions that file cannot answer (a new or edited entry, or an answer older than 30 days), so an
155
155
  unchanged bibliography builds with no network at all, in CI too, until its answers age out. It holds responses, never verdicts: the verdicts are derived again on
156
156
  every build. A service that refuses (429, HTML instead of JSON, a timeout) is not asked again for the rest of the
package/src/build.ts CHANGED
@@ -615,7 +615,7 @@ export const measureStep: BuildStep = {
615
615
  * `_build/references.json` for the offline lint rules (`reference-rules.ts`). OPTIONAL and NEVER
616
616
  * FAILING: without network the PDF is still built, and the record says `not-checked`.
617
617
  *
618
- * What the services answered is read from and added to `<paper>/repro/references-cache.json`
618
+ * What the services answered is read from and added to `<paper>/repro/data/references-cache.json`
619
619
  * (`references.ts`, `domain/lookup-cache.ts`), committed with the paper, so only a new or edited
620
620
  * entry is asked, and an unchanged bibliography is checked with no network at all (#107).
621
621
  */
@@ -1233,6 +1233,48 @@ check(
1233
1233
  }
1234
1234
  }
1235
1235
 
1236
+ // ─────────────────────────────────────────────────────────────────────────────
1237
+ // `paperlint hook paper-folder-layout` the way Claude Code runs it: the payload on stdin, the answer
1238
+ // on stdout, through the real bin. The in-process tests inject both streams; this is the one place
1239
+ // the defaults are read and written for real.
1240
+ {
1241
+ const root = realpathSync(mkdtempSync(join(tmpdir(), "paperlint-hook-")));
1242
+ try {
1243
+ const paper = join(root, "papers", "p");
1244
+ mkdirSync(paper, { recursive: true });
1245
+ writeFileSync(join(paper, "PIPELINE-STATUS.md"), "# S\n");
1246
+ const hook = (file) =>
1247
+ spawnSync(
1248
+ process.execPath,
1249
+ [
1250
+ join(HERE, "..", "bin", "paperlint.mjs"),
1251
+ "hook",
1252
+ "paper-folder-layout",
1253
+ ],
1254
+ {
1255
+ cwd: root,
1256
+ encoding: "utf8",
1257
+ input: JSON.stringify({ tool_input: { file_path: file } }),
1258
+ },
1259
+ );
1260
+ const stray = hook(join(paper, "PLAN.md"));
1261
+ check(
1262
+ "the layout hook refuses a new stray PLAN.md on stdout as a PreToolUse deny, exit 0",
1263
+ stray.status === 0 &&
1264
+ /^\{"hookSpecificOutput":\{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"`PLAN\.md` is at the root/.test(
1265
+ stray.stdout,
1266
+ ),
1267
+ );
1268
+ const outside = hook(join(root, "notes.md"));
1269
+ check(
1270
+ "and says nothing about a file outside every paper folder",
1271
+ outside.status === 0 && outside.stdout === "",
1272
+ );
1273
+ } finally {
1274
+ rmSync(root, { recursive: true, force: true });
1275
+ }
1276
+ }
1277
+
1236
1278
  console.log(
1237
1279
  `✓ ${String(check.count)} assertions passed — paperlint lint, one command instead of hand-rolled config`,
1238
1280
  );
package/src/cli.test.ts CHANGED
@@ -9,7 +9,7 @@ import { spawn } from "node:child_process";
9
9
  import { existsSync, readdirSync } from "node:fs";
10
10
  import { createServer } from "node:http";
11
11
  import { dirname, join } from "node:path";
12
- import { test } from "vitest";
12
+ import { test, vi } from "vitest";
13
13
  import {
14
14
  runNode,
15
15
  useTempDir,
@@ -200,6 +200,70 @@ test("runHook: a runtime with no cli.js beside it, and a hook killed by a signal
200
200
  );
201
201
  });
202
202
 
203
+ test("runHook paper-folder-layout: in-process, on the real disk — a refusal on stdout for a new stray file, nothing for an allowed one, a complaint and exit 0 on a broken payload", () => {
204
+ const dir = join(root, "layout-hook");
205
+ writeTree(dir, {
206
+ "papers/p/PIPELINE-STATUS.md": "# status\n",
207
+ "papers/p/paper.tex": "x",
208
+ "papers/p/NOTES.md": "x",
209
+ });
210
+ const hook = (payload: string) => {
211
+ const out: string[] = [];
212
+ const err: string[] = [];
213
+ const code = runHook("paper-folder-layout", {
214
+ stdin: () => payload,
215
+ out: (t) => out.push(t),
216
+ err: (m: string) => err.push(m),
217
+ run: () => {
218
+ throw new Error("an in-process hook spawns nothing");
219
+ },
220
+ });
221
+ return { code, out, err };
222
+ };
223
+ const write = (file: string) =>
224
+ JSON.stringify({ tool_input: { file_path: join(dir, file) } });
225
+ const stray = hook(write("papers/p/PLAN.md"));
226
+ assert.equal(stray.code, 0);
227
+ assert.match(
228
+ stray.out.join(""),
229
+ /^\{"hookSpecificOutput":\{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"`PLAN\.md` is at the root of a paper folder/,
230
+ );
231
+ assert.deepEqual(hook(write("papers/p/NOTES.md")), {
232
+ code: 0,
233
+ out: [],
234
+ err: [],
235
+ });
236
+ const broken = hook("{ not json");
237
+ assert.equal(broken.code, 0);
238
+ assert.match(
239
+ broken.err.join(""),
240
+ /^paperlint: the paper-folder-layout hook did not run: /,
241
+ );
242
+ });
243
+
244
+ test("runHook paper-folder-layout: by default its output goes to stdout, where Claude Code reads it", () => {
245
+ const dir = join(root, "layout-hook-stdout");
246
+ writeTree(dir, { "papers/p/PIPELINE-STATUS.md": "# status\n" });
247
+ const write = vi
248
+ .spyOn(process.stdout, "write")
249
+ .mockImplementation(() => true);
250
+ try {
251
+ const code = runHook("paper-folder-layout", {
252
+ stdin: () =>
253
+ JSON.stringify({
254
+ tool_input: { file_path: join(dir, "papers/p/PLAN.md") },
255
+ }),
256
+ });
257
+ assert.equal(code, 0);
258
+ assert.match(
259
+ String(write.mock.calls[0]?.[0]),
260
+ /^\{"hookSpecificOutput":\{"hookEventName":"PreToolUse","permissionDecision":"deny"/,
261
+ );
262
+ } finally {
263
+ write.mockRestore();
264
+ }
265
+ });
266
+
203
267
  test("chooseVenue: a preset by path (inside and outside the paper), by prefixed name, and a broken one", async () => {
204
268
  const dir = join(root, "venues");
205
269
  writeTree(dir, {
@@ -447,14 +511,34 @@ test("silentOptionalRules: a rule no block names for any paper is silent — a g
447
511
  );
448
512
  });
449
513
 
514
+ test("silentOptionalRules: a rule that runs on the paper's PIPELINE-STATUS.md is reached through it", async () => {
515
+ const eslint = {
516
+ calculateConfigForFile: (f: string) =>
517
+ Promise.resolve(
518
+ f.endsWith("PIPELINE-STATUS.md")
519
+ ? { rules: { "study/declare-sample": [2] } }
520
+ : { rules: {} },
521
+ ),
522
+ };
523
+ const sampleOn = {
524
+ rules: [
525
+ { basePath: ".", rules: { "study/declare-sample": "error" as const } },
526
+ ],
527
+ };
528
+ assert.deepEqual(
529
+ await silentOptionalRules(eslint, ["p/paper.tex"], sampleOn),
530
+ [],
531
+ );
532
+ });
533
+
450
534
  test("init with no path sets up the current directory, and its first paper is paper.tex", async () => {
451
535
  const dir = writeTree(join(root, "init-here"), { "package.json": "{}" });
452
536
  const r = await cli(["init", "--yes", "--no-hooks", "--paper", "first"], dir);
453
- assert.deepEqual(
454
- readdirSync(join(dir, "papers", "first")).sort(),
455
- ["PIPELINE-STATUS.md", "paper.tex", "paperlint.json"],
456
- r.out + r.err,
457
- );
537
+ const made = readdirSync(join(dir, "papers", "first"));
538
+ // The three files, then the layout's folders (src/new-paper.test.ts holds the exact tree).
539
+ for (const f of ["PIPELINE-STATUS.md", "paper.tex", "paperlint.json"])
540
+ assert.ok(made.includes(f), `${f} is missing:\n${r.out + r.err}`);
541
+ assert.equal(made.includes("main.tex"), false);
458
542
  });
459
543
 
460
544
  test("init and doctor with a declaration that names no directory: nothing to compare", async () => {
package/src/cli.ts CHANGED
@@ -138,7 +138,12 @@ import {
138
138
  init,
139
139
  processInteractivity,
140
140
  } from "./init.ts";
141
- import { newPaper, reportNewPaper, type VenueSetting } from "./new-paper.ts";
141
+ import {
142
+ newPaper,
143
+ reportNewPaper,
144
+ STATUS_FILE,
145
+ type VenueSetting,
146
+ } from "./new-paper.ts";
142
147
  // The one source for the consumer's config key lives in lib/ (the ESLint rules and the skill scripts
143
148
  // import it too). It is imported compiled, from dist/, the one path that resolves the same from src/
144
149
  // and from dist/ — see CONTRIBUTING.md.
@@ -164,12 +169,22 @@ export { init };
164
169
  export { nextSteps } from "./init.ts";
165
170
 
166
171
  import paperStages from "#eslint-rules/paper-stages";
172
+ import preregCoverage from "#eslint-rules/prereg-coverage";
167
173
  import cycleStage from "#eslint-rules/cycle-stage";
168
174
  import { CYCLE_RULE_LEVELS, cycleRules } from "./cycle-rules.ts";
169
175
  import {
170
176
  FOLDER_VENUE_RULE_LEVELS,
171
177
  folderVenueRules,
172
178
  } from "./folder-venue-rule.ts";
179
+ import {
180
+ FOLDER_LAYOUT_RULE_LEVELS,
181
+ folderLayoutRules,
182
+ } from "./folder-layout-rule.ts";
183
+ import { folderLayoutHook } from "./folder-layout-hook.ts";
184
+ import {
185
+ REPRO_LAYOUT_RULE_LEVELS,
186
+ reproLayoutRules,
187
+ } from "./repro-layout-rule.ts";
173
188
  import typography from "#eslint-rules/paper-typography";
174
189
  import texBuild from "#eslint-rules/tex-build";
175
190
  import reviewFrontmatter from "#eslint-rules/review-frontmatter";
@@ -177,6 +192,7 @@ import siblingFrontmatter from "#eslint-rules/sibling-frontmatter";
177
192
  import pdfRules from "#eslint-rules/pdf-last-page-balance";
178
193
  import { messageOf } from "./domain/text.ts";
179
194
  import { isRecord } from "./domain/record.ts";
195
+ import { ESCAPE_HATCH } from "./domain/paper-layout.ts";
180
196
 
181
197
  /** The shipped venue presets, read from the package's venues directory — the one list. */
182
198
  const SHIPPED_VENUES = (): string[] => shippedPresets(presetsDir());
@@ -348,6 +364,9 @@ const FRAGMENT_RULES = {
348
364
  "heading-case": REGISTER_AND_CASE_RULES["heading-case"],
349
365
  };
350
366
 
367
+ /** What the two layout rules read with: the disk and its directory listings. */
368
+ const LAYOUT_RULE_DEPS = { files: nodeFiles, listDir: nodeListDir };
369
+
351
370
  /** What the talk rules read with: the disk, the shipped presets, and the three media readers. */
352
371
  const TALK_RULE_DEPS = {
353
372
  files: nodeFiles,
@@ -380,7 +399,9 @@ export function buildConfig(
380
399
  // its files carry every marker a paper does. Flat config does NOT ignore dot-directories by
381
400
  // default (only `node_modules/` and `.git/`), so without this block `paperlint lint` would lint the
382
401
  // template as a paper — and a richer template with placeholder stages would fail the run.
383
- { ignores: ["**/.template/"] },
402
+ // The same for a paper's `extra/`: the layout promises it is never judged, and an archived
403
+ // snapshot there carries a paper's markers too, so its folder read as a paper root with strays.
404
+ { ignores: ["**/.template/", `**/${ESCAPE_HATCH}/`] },
384
405
  // The `pdf` plugin is registered for EVERY file. A consumer's block (`rules`, appended below)
385
406
  // may name its rules for a glob that also matches markdown files; with the plugin defined only
386
407
  // beside `paper.tex`, ESLint would refuse those files with "could not find plugin". Every rule
@@ -409,8 +430,12 @@ export function buildConfig(
409
430
  rules: {
410
431
  ...paperStages.rules,
411
432
  ...folderVenueRules({ files: nodeFiles, venuesDir: presetsDir() }),
433
+ ...folderLayoutRules(LAYOUT_RULE_DEPS),
412
434
  },
413
435
  },
436
+ repro: { rules: reproLayoutRules(LAYOUT_RULE_DEPS) },
437
+ // Registered, on for no paper: `"study/declare-sample": "error"` in paperlint.json turns it on.
438
+ study: { rules: preregCoverage.rules },
414
439
  cycle: { rules: CYCLE_STATUS_RULES },
415
440
  },
416
441
  ...md,
@@ -418,6 +443,8 @@ export function buildConfig(
418
443
  "paper/stages": "error",
419
444
  "paper/source": "error",
420
445
  ...FOLDER_VENUE_RULE_LEVELS,
446
+ ...FOLDER_LAYOUT_RULE_LEVELS,
447
+ ...REPRO_LAYOUT_RULE_LEVELS,
421
448
  ...CYCLE_RULE_LEVELS,
422
449
  },
423
450
  },
@@ -647,7 +674,7 @@ export const OPTIONAL_RULES: ReadonlySet<string> = new Set(
647
674
  * 🔴 AN OPTIONAL RULE THAT IS ON AND REACHES NO PAPER IS A GREEN ZERO. A `files` glob that matches
648
675
  * nothing — a typo, a path relative to the wrong directory — leaves the rule never invoked, and a
649
676
  * rule that never runs reports exactly like a rule that passed. So for every optional rule the
650
- * consumer turned on, some `paper.tex` must actually have it enabled; the ones none has are
677
+ * consumer turned on, some paper's `paper.tex` or `PIPELINE-STATUS.md` must actually have it enabled; the ones none has are
651
678
  * returned, for the caller to refuse.
652
679
  *
653
680
  * `files` is every `paper.tex` the guard may count: the ones this run linted AND every paper of the
@@ -673,9 +700,16 @@ export async function silentOptionalRules(
673
700
  ),
674
701
  );
675
702
  const reached = new Set<string>();
676
- for (const f of new Set(files.filter((p) => basename(p) === MAIN))) {
703
+ // A paper's rules reach it through two files: `paper.tex` and the `PIPELINE-STATUS.md` beside it,
704
+ // where the rules about the paper folder itself run.
705
+ const probes = [
706
+ ...new Set(files.filter((p) => basename(p) === MAIN)),
707
+ ].flatMap((f) => [f, join(dirname(f), STATUS_FILE)]);
708
+ for (const probe of probes) {
677
709
  // Undefined for a file outside ESLint's cwd or scope: it reaches nothing.
678
- const cfg = ComputedConfig.parse(await eslint.calculateConfigForFile(f));
710
+ const cfg = ComputedConfig.parse(
711
+ await eslint.calculateConfigForFile(probe),
712
+ );
679
713
  // Reached is "some block names the rule for this paper", on OR off: a paper that turns off what
680
714
  // its venue preset turned on is the documented way out, and reads the same as a glob that missed
681
715
  // only if "off" is counted as "not there" — which refused every such paper.
@@ -1084,6 +1118,35 @@ export function toPaths(papers: unknown): string[] {
1084
1118
  return [];
1085
1119
  }
1086
1120
 
1121
+ /** What an in-process hook is handed: the payload, and where its output and complaints go. */
1122
+ interface HookIo {
1123
+ readonly err: typeof console.error;
1124
+ readonly stdin: () => string;
1125
+ readonly out: (text: string) => void;
1126
+ }
1127
+
1128
+ /**
1129
+ * Hooks that run inside this process instead of as a vigiles program, each because it must call the
1130
+ * package's own code (`src/folder-layout-hook.ts` says why for its one). Advisory: whatever goes
1131
+ * wrong, the tool call has already happened, so it says so on stderr and exits 0.
1132
+ */
1133
+ const IN_PROCESS_HOOKS: Readonly<Record<string, (io: HookIo) => number>> = {
1134
+ "paper-folder-layout": ({ err, stdin, out }) => {
1135
+ try {
1136
+ const said = folderLayoutHook(JSON.parse(stdin()), {
1137
+ files: nodeFiles,
1138
+ listDir: nodeListDir,
1139
+ });
1140
+ if (said !== null) out(`${JSON.stringify(said)}\n`);
1141
+ } catch (e) {
1142
+ err(
1143
+ `paperlint: the paper-folder-layout hook did not run: ${messageOf(e)}`,
1144
+ );
1145
+ }
1146
+ return 0;
1147
+ },
1148
+ };
1149
+
1087
1150
  /**
1088
1151
  * `paperlint hook <name>` — run an editor hook. It exists for ONE thing: so that the wiring does not
1089
1152
  * address the runtime from the project root.
@@ -1125,6 +1188,10 @@ export function runHook(
1125
1188
  // the very file the package itself declares as its `bin`.
1126
1189
  resolve = (spec: string): string =>
1127
1190
  createRequire(import.meta.url).resolve(spec),
1191
+ stdin = (): string => readFileSync(0, "utf8"),
1192
+ out = (text: string): void => {
1193
+ process.stdout.write(text);
1194
+ },
1128
1195
  }: {
1129
1196
  err?: typeof console.error;
1130
1197
  /** Runs the hook with the terminal's streams: `spawnSync` is one, and only its status is read. */
@@ -1134,12 +1201,18 @@ export function runHook(
1134
1201
  options: { readonly stdio: "inherit" },
1135
1202
  ) => { readonly status: number | null };
1136
1203
  resolve?: (spec: string) => string;
1204
+ /** The hook's payload, as Claude Code sends it on stdin. */
1205
+ stdin?: () => string;
1206
+ /** Where an in-process hook's output goes: stdout, which Claude Code reads. */
1207
+ out?: (text: string) => void;
1137
1208
  } = {},
1138
1209
  ): number {
1139
1210
  if (!name) {
1140
1211
  err(`\`hook\` needs a name, e.g. \`paperlint hook paper-edit-guard\``);
1141
1212
  return 2;
1142
1213
  }
1214
+ const inProcess = IN_PROCESS_HOOKS[name];
1215
+ if (inProcess !== undefined) return inProcess({ err, stdin, out });
1143
1216
  const program = fileURLToPath(
1144
1217
  new URL(`../hooks/${name}.hook.mjs`, import.meta.url),
1145
1218
  );
@@ -0,0 +1,32 @@
1
+ /**
2
+ * `renderTree` (`src/domain/layout-tree.ts`): the drawing every layout message and rule page shows.
3
+ * Branches drawn as `tree` draws them, nested entries indented under a continuing or a closed branch,
4
+ * every note in one column.
5
+ */
6
+ import { describe, expect, it } from "vitest";
7
+ import { leaf, renderTree } from "./layout-tree.ts";
8
+
9
+ describe("renderTree", () => {
10
+ it("draws nested entries under open and closed branches, notes in one column", () => {
11
+ expect(
12
+ renderTree("root/", [
13
+ { name: "a/", note: "first", children: [leaf("x", "inner")] },
14
+ leaf("bb", "middle"),
15
+ { name: "c/", note: "last", children: [leaf("y", "under last")] },
16
+ ]),
17
+ ).toBe(
18
+ [
19
+ "root/",
20
+ "├── a/ ← first",
21
+ "│ └── x ← inner",
22
+ "├── bb ← middle",
23
+ "└── c/ ← last",
24
+ " └── y ← under last",
25
+ ].join("\n"),
26
+ );
27
+ });
28
+
29
+ it("an empty layout is its root alone", () => {
30
+ expect(renderTree("root/", [])).toBe("root/");
31
+ });
32
+ });
@@ -0,0 +1,47 @@
1
+ /**
2
+ * A layout drawn as a tree — the one picture of what a paper folder may hold.
3
+ *
4
+ * The rules' messages, the hook's refusal and the rules' pages all show the same drawing, rendered by
5
+ * `renderTree` from the constants that decide what is allowed (`paper-layout.ts`, `repro-layout.ts`).
6
+ * The pages embed it between markers and a test compares them with this render, so a page cannot
7
+ * describe a layout the code does not enforce.
8
+ */
9
+
10
+ /** One entry of a layout: its name (a folder's ends in a slash), what it is for, what it holds. */
11
+ export interface TreeNode {
12
+ readonly name: string;
13
+ readonly note: string;
14
+ readonly children: readonly TreeNode[];
15
+ }
16
+
17
+ /** An entry with nothing drawn under it. */
18
+ export const leaf = (name: string, note: string): TreeNode => ({
19
+ name,
20
+ note,
21
+ children: [],
22
+ });
23
+
24
+ interface Row {
25
+ readonly left: string;
26
+ readonly note: string;
27
+ }
28
+
29
+ /** Each entry's line, depth first, with the branch drawing in front of its name. */
30
+ const rowsOf = (nodes: readonly TreeNode[], indent: string): readonly Row[] =>
31
+ nodes.flatMap((n, i) => {
32
+ const last = i === nodes.length - 1;
33
+ return [
34
+ { left: `${indent}${last ? "└── " : "├── "}${n.name}`, note: n.note },
35
+ ...rowsOf(n.children, `${indent}${last ? " " : "│ "}`),
36
+ ];
37
+ });
38
+
39
+ /** `nodes` under `root`, one line each, every note in one column after `←`. Pure. */
40
+ export function renderTree(root: string, nodes: readonly TreeNode[]): string {
41
+ const rows = rowsOf(nodes, "");
42
+ const width = Math.max(0, ...rows.map((r) => r.left.length));
43
+ return [
44
+ root,
45
+ ...rows.map((r) => `${r.left.padEnd(width)} ← ${r.note}`),
46
+ ].join("\n");
47
+ }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * THE LOOKUP CACHE — `<paper>/repro/references-cache.json`: what the citation services ANSWERED,
2
+ * THE LOOKUP CACHE — `<paper>/repro/data/references-cache.json`: what the citation services ANSWERED,
3
3
  * kept so that a build asks each question at most once every MAX_AGE_DAYS, not once per build (#107).
4
4
  *
5
5
  * { "schema": 1,
@@ -15,7 +15,7 @@
15
15
  * responses on every build, by the current version of the checkers. A cached verdict would freeze
16
16
  * whatever the checker believed the day it ran — a bug fixed in the comparison would never reach a
17
17
  * paper whose entries are cached. A cached response is evidence: it cannot be made up, only
18
- * refetched. The precedent is `extract-ref-facts.mjs`'s `repro/refs-cache.json` ("the cache is
18
+ * refetched. The precedent is `extract-ref-facts.mjs`'s `repro/data/refs-cache.json` ("the cache is
19
19
  * evidence, not a second declaration").
20
20
  *
21
21
  * 🔴 COMMITTED, so CI and every other clone start warm: a build whose entries are all here makes no