paperlint 2.1.0 → 3.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 (191) hide show
  1. package/CLAUDE.md +6 -1
  2. package/README.md +233 -224
  3. package/action.yml +4 -6
  4. package/dist/adapters/references/index.d.ts +13 -0
  5. package/dist/adapters/references/index.d.ts.map +1 -0
  6. package/dist/adapters/references/index.js +55 -0
  7. package/dist/adapters/references/index.js.map +1 -0
  8. package/dist/adapters/references/reach.io.d.ts +3 -0
  9. package/dist/adapters/references/reach.io.d.ts.map +1 -0
  10. package/dist/adapters/references/reach.io.js +14 -0
  11. package/dist/adapters/references/reach.io.js.map +1 -0
  12. package/dist/build.d.ts +15 -0
  13. package/dist/build.d.ts.map +1 -1
  14. package/dist/build.js +38 -4
  15. package/dist/build.js.map +1 -1
  16. package/dist/cli.d.ts +28 -25
  17. package/dist/cli.d.ts.map +1 -1
  18. package/dist/cli.js +277 -265
  19. package/dist/cli.js.map +1 -1
  20. package/dist/doctor.d.ts.map +1 -1
  21. package/dist/doctor.js +40 -72
  22. package/dist/doctor.js.map +1 -1
  23. package/dist/facts-file.d.ts +2 -2
  24. package/dist/facts-file.js +2 -2
  25. package/dist/hooks-settings.d.ts +0 -21
  26. package/dist/hooks-settings.d.ts.map +1 -1
  27. package/dist/hooks-settings.js +17 -135
  28. package/dist/hooks-settings.js.map +1 -1
  29. package/dist/init.d.ts +39 -29
  30. package/dist/init.d.ts.map +1 -1
  31. package/dist/init.js +143 -172
  32. package/dist/init.js.map +1 -1
  33. package/dist/link-skills.d.ts +1 -1
  34. package/dist/link-skills.d.ts.map +1 -1
  35. package/dist/link-skills.js +11 -29
  36. package/dist/link-skills.js.map +1 -1
  37. package/dist/new-paper.d.ts.map +1 -1
  38. package/dist/new-paper.js +2 -13
  39. package/dist/new-paper.js.map +1 -1
  40. package/dist/paper-files.d.ts +63 -0
  41. package/dist/paper-files.d.ts.map +1 -0
  42. package/dist/paper-files.js +69 -0
  43. package/dist/paper-files.js.map +1 -0
  44. package/dist/paper-settings.d.ts +13 -41
  45. package/dist/paper-settings.d.ts.map +1 -1
  46. package/dist/paper-settings.js +71 -71
  47. package/dist/paper-settings.js.map +1 -1
  48. package/dist/ports/check-references.d.ts +31 -0
  49. package/dist/ports/check-references.d.ts.map +1 -0
  50. package/dist/ports/check-references.js +7 -0
  51. package/dist/ports/check-references.js.map +1 -0
  52. package/dist/presets.d.ts +1 -1
  53. package/dist/presets.d.ts.map +1 -1
  54. package/dist/presets.js +4 -6
  55. package/dist/presets.js.map +1 -1
  56. package/dist/reference-rules.d.ts +47 -0
  57. package/dist/reference-rules.d.ts.map +1 -0
  58. package/dist/reference-rules.js +156 -0
  59. package/dist/reference-rules.js.map +1 -0
  60. package/dist/references.d.ts +36 -0
  61. package/dist/references.d.ts.map +1 -0
  62. package/dist/references.js +91 -0
  63. package/dist/references.js.map +1 -0
  64. package/dist/rules-config.d.ts +5 -3
  65. package/dist/rules-config.d.ts.map +1 -1
  66. package/dist/rules-config.js +15 -6
  67. package/dist/rules-config.js.map +1 -1
  68. package/dist/structure.js +3 -3
  69. package/dist/structure.js.map +1 -1
  70. package/dist/tex-requirements.js +2 -2
  71. package/dist/tex-requirements.js.map +1 -1
  72. package/dist/types.d.ts +20 -18
  73. package/dist/types.d.ts.map +1 -1
  74. package/dist/venue-rules.d.ts.map +1 -1
  75. package/dist/venue-rules.js +11 -9
  76. package/dist/venue-rules.js.map +1 -1
  77. package/docs/configuration.md +119 -104
  78. package/docs/e2e.md +1 -1
  79. package/docs/install.md +25 -40
  80. package/docs/optional-rules.md +5 -4
  81. package/docs/rules.md +60 -23
  82. package/eslint-rules/bib-reachable-entry.mjs +75 -0
  83. package/eslint-rules/latex-language.mjs +29 -1
  84. package/eslint-rules/paper-stages.harness.mjs +1 -117
  85. package/eslint-rules/paper-stages.mjs +0 -120
  86. package/eslint-rules/paper-stages.mutations.mjs +0 -76
  87. package/eslint-rules/paper-typography.mjs +267 -192
  88. package/eslint-rules/paper-typography.test.mjs +297 -0
  89. package/eslint-rules/papers.harness.mjs +8 -12
  90. package/eslint-rules/papers.mjs +17 -31
  91. package/eslint-rules/pdf-last-page-balance.mjs +4 -7
  92. package/eslint-rules/review-frontmatter.mjs +118 -0
  93. package/eslint-rules/review-frontmatter.schema.json +36 -0
  94. package/eslint-rules/review-frontmatter.test.mjs +118 -0
  95. package/eslint-rules/sibling-frontmatter.mjs +19 -0
  96. package/eslint-rules/sibling-frontmatter.schema.json +14 -0
  97. package/eslint.config.mjs +0 -15
  98. package/fixtures/real-markdown-paper/baseline.json +6 -7
  99. package/hooks/hooks.harness.mjs +39 -98
  100. package/hooks/hooks.mutations.mjs +6 -6
  101. package/hooks/paper-edit-guard.hook.d.mts +2 -3
  102. package/hooks/paper-edit-guard.hook.mjs +31 -42
  103. package/hooks/paper-skills-nudge.hook.mjs +21 -19
  104. package/hooks/paper-status-gates.hook.mjs +21 -19
  105. package/hooks/paper-status-gates.sh +8 -13
  106. package/lib/paper-config.d.mts +15 -25
  107. package/lib/paper-config.harness.mjs +18 -35
  108. package/lib/paper-config.mjs +83 -105
  109. package/lib/paper-config.mutations.mjs +2 -2
  110. package/lib/skill-checks.mjs +1 -1
  111. package/lib/skill-trigger-cases.harness.mjs +9 -8
  112. package/lib/skill-trigger-cases.mjs +7 -15
  113. package/package.json +1 -1
  114. package/scripts/eslint-report-guard.mjs +1 -1
  115. package/scripts/harness-api.frozen.json +0 -3
  116. package/scripts/mutation-batteries.frozen.json +4 -13
  117. package/skills/analyze-sibling-paper/SKILL.md +16 -12
  118. package/skills/analyze-sibling-paper/SKILL.md.spec.ts +15 -11
  119. package/skills/cold-read-diff/SKILL.md +20 -1
  120. package/skills/cold-read-diff/SKILL.md.spec.ts +19 -0
  121. package/skills/grade-paper-writing/SKILL.md +20 -1
  122. package/skills/grade-paper-writing/SKILL.md.spec.ts +19 -0
  123. package/skills/paper-pipeline/scripts/consumer.d.mts +0 -1
  124. package/skills/paper-pipeline/scripts/consumer.harness.mjs +8 -12
  125. package/skills/paper-pipeline/scripts/consumer.mjs +19 -33
  126. package/skills/paper-pipeline/scripts/extract-ref-facts.harness.mjs +6 -4
  127. package/skills/paper-pipeline/scripts/run-mechanical.mjs +2 -11
  128. package/skills/plan-paper-timeline/SKILL.md +2 -2
  129. package/skills/plan-paper-timeline/SKILL.md.spec.ts +1 -1
  130. package/skills/plan-paper-timeline/plan-paper-timeline.effects.harness.mjs +1 -1
  131. package/skills/plan-paper-timeline/plan-paper-timeline.effects.mutations.mjs +2 -2
  132. package/skills/render-paper/check-render.sh +2 -3
  133. package/skills/render-paper/render-paper.harness.mjs +2 -2
  134. package/skills/verify-citations/SKILL.md +10 -1
  135. package/skills/verify-citations/SKILL.md.spec.ts +9 -0
  136. package/skills/verify-citations/scripts/bib-authors.mjs +48 -26
  137. package/src/adapters/references/index.ts +102 -0
  138. package/src/adapters/references/reach.io.ts +12 -0
  139. package/src/build.harness.mjs +8 -4
  140. package/src/build.ts +62 -6
  141. package/src/cli.harness.mjs +147 -292
  142. package/src/cli.mutations.mjs +35 -44
  143. package/src/cli.ts +344 -304
  144. package/src/doctor.harness.mjs +41 -84
  145. package/src/doctor.mutations.mjs +9 -9
  146. package/src/doctor.ts +44 -89
  147. package/src/facts-file.test.ts +0 -7
  148. package/src/facts-file.ts +2 -2
  149. package/src/hooks-settings.harness.mjs +7 -92
  150. package/src/hooks-settings.mutations.mjs +2 -17
  151. package/src/hooks-settings.ts +18 -162
  152. package/src/init.test.ts +206 -0
  153. package/src/init.ts +186 -230
  154. package/src/link-skills.harness.mjs +1 -38
  155. package/src/link-skills.mutations.mjs +9 -5
  156. package/src/link-skills.ts +10 -34
  157. package/src/lint-fix.test.ts +120 -0
  158. package/src/new-paper.test.ts +1 -13
  159. package/src/new-paper.ts +2 -17
  160. package/src/paper-files.test.ts +290 -0
  161. package/src/paper-files.ts +144 -0
  162. package/src/paper-settings-commands.test.ts +84 -121
  163. package/src/paper-settings.test.ts +93 -64
  164. package/src/paper-settings.ts +99 -97
  165. package/src/ports/check-references.ts +28 -0
  166. package/src/presets.ts +5 -15
  167. package/src/reference-rules.ts +229 -0
  168. package/src/references.test.ts +209 -0
  169. package/src/references.ts +138 -0
  170. package/src/rules-config.harness.mjs +24 -8
  171. package/src/rules-config.ts +19 -7
  172. package/src/structure.harness.mjs +3 -1
  173. package/src/structure.ts +3 -3
  174. package/src/tex-requirements.ts +2 -2
  175. package/src/types.ts +30 -18
  176. package/src/venue-rules.test.ts +20 -11
  177. package/src/venue-rules.ts +11 -13
  178. package/eslint-rules/doc-fields.harness.mjs +0 -336
  179. package/eslint-rules/doc-fields.mjs +0 -186
  180. package/eslint-rules/doc-fields.mutations.mjs +0 -96
  181. package/eslint-rules/paper-typography.harness.mjs +0 -291
  182. package/eslint-rules/paper-typography.mutations.mjs +0 -131
  183. package/eslint-rules/review-findings-cause.harness.mjs +0 -228
  184. package/eslint-rules/review-findings-cause.mjs +0 -135
  185. package/eslint-rules/review-findings-cause.mutations.mjs +0 -72
  186. package/fixtures/paper-stages/authors-ran/PIPELINE-STATUS.md +0 -16
  187. package/fixtures/paper-stages/marker-in-prose/PIPELINE-STATUS.md +0 -17
  188. package/fixtures/review-findings-cause/clean.md +0 -17
  189. package/fixtures/review-findings-cause/defect.md +0 -14
  190. package/fixtures/review-findings-cause/old-debt.md +0 -14
  191. package/fixtures/review-findings-cause/quiet-in-fence.md +0 -16
@@ -0,0 +1,297 @@
1
+ /**
2
+ * paper/section-word · paper/leading-zero · paper/figure-ref-style · bib/reachable-entry — each
3
+ * FIRES on a bad input, stays SILENT on a good one, reports every occurrence at its own place,
4
+ * and (the first three) fixes to an exact output. Plus the escape hatch: a disable directive in a
5
+ * `%` comment, including inside the `filecontents` bibliography, silences exactly one finding,
6
+ * and a directive that silences nothing is itself reported.
7
+ *
8
+ * The leading-zero cases are the ones paperlint#44 measured: a regex over the raw source found 22
9
+ * decimals on a real corpus and none was a defect, so the SILENT list is as load-bearing as the
10
+ * CAUGHT one.
11
+ */
12
+ import { describe, expect, it } from "vitest";
13
+ import { ESLint } from "eslint";
14
+ import markdown from "@eslint/markdown";
15
+ import { readFileSync } from "node:fs";
16
+ import { dirname, join } from "node:path";
17
+ import { fileURLToPath } from "node:url";
18
+ import { texLanguage } from "./latex-language.mjs";
19
+ import typography from "./paper-typography.mjs";
20
+ import bib from "./bib-reachable-entry.mjs";
21
+
22
+ const HERE = dirname(fileURLToPath(import.meta.url));
23
+ const FIX = join(HERE, "..", "fixtures", "paper-typography");
24
+
25
+ const RULES = {
26
+ "paper/section-word": "warn",
27
+ "paper/leading-zero": "warn",
28
+ "paper/figure-ref-style": "warn",
29
+ "bib/reachable-entry": "warn",
30
+ };
31
+
32
+ const eslint = (fix = false) =>
33
+ new ESLint({
34
+ cwd: FIX,
35
+ overrideConfigFile: true,
36
+ fix,
37
+ overrideConfig: [
38
+ {
39
+ files: ["**/*.tex"],
40
+ plugins: {
41
+ tex: { languages: { latex: texLanguage } },
42
+ paper: typography,
43
+ bib,
44
+ },
45
+ language: "tex/latex",
46
+ linterOptions: { reportUnusedDisableDirectives: "error" },
47
+ rules: RULES,
48
+ },
49
+ {
50
+ files: ["**/*.md"],
51
+ plugins: { markdown, paper: typography },
52
+ language: "markdown/gfm",
53
+ languageOptions: { frontmatter: "yaml" },
54
+ rules: {
55
+ "paper/section-word": "warn",
56
+ "paper/leading-zero": "warn",
57
+ },
58
+ },
59
+ ],
60
+ });
61
+
62
+ /** Lint `text` as `file`; no crash allowed, only findings. */
63
+ async function lint(text, file = "paper.tex", fix = false) {
64
+ const [res] = await eslint(fix).lintText(text, {
65
+ filePath: join(FIX, "inline", file),
66
+ });
67
+ expect(res.messages.filter((m) => m.fatal)).toEqual([]);
68
+ return res;
69
+ }
70
+ const ids = (res, id) => res.messages.filter((m) => !id || m.ruleId === id);
71
+ const fixed = async (text, file) =>
72
+ (await lint(text, file, true)).output ?? text;
73
+ const doc = (body) =>
74
+ `\\documentclass{acmart}\n\\begin{document}\n${body}\n\\end{document}\n`;
75
+
76
+ describe("the fixtures: messy fires on each rule, clean is silent", () => {
77
+ it("messy-paper — every occurrence, at its own line", async () => {
78
+ const res = await lint(
79
+ readFileSync(join(FIX, "messy-paper", "paper.tex"), "utf8"),
80
+ );
81
+ const count = (id) => ids(res, id).length;
82
+ expect(count("paper/section-word")).toBe(3);
83
+ expect(count("paper/leading-zero")).toBe(2);
84
+ expect(count("paper/figure-ref-style")).toBe(1);
85
+ expect(count("bib/reachable-entry")).toBe(2);
86
+ expect(ids(res, "bib/reachable-entry").map((m) => m.line)).toEqual([3, 8]);
87
+ });
88
+
89
+ it("clean-paper — nothing", async () => {
90
+ const res = await lint(
91
+ readFileSync(join(FIX, "clean-paper", "paper.tex"), "utf8"),
92
+ );
93
+ expect(res.messages).toEqual([]);
94
+ });
95
+ });
96
+
97
+ describe("paper/section-word", () => {
98
+ it("🔴 the MACRO form counts, not only the glyph — and each is fixed exactly", async () => {
99
+ const src = doc("See §~\\ref{a}, \\S\\ref{b}, \\S~\\ref{c}, \\S 4 and §5.");
100
+ expect(ids(await lint(src), "paper/section-word")).toHaveLength(5);
101
+ expect(await fixed(src)).toBe(
102
+ doc(
103
+ "See Section~\\ref{a}, Section~\\ref{b}, Section~\\ref{c}, Section 4 and Section 5.",
104
+ ),
105
+ );
106
+ });
107
+
108
+ it("a glyph before prose is reported but not rewritten", async () => {
109
+ const src = doc("As §§ above.");
110
+ expect(ids(await lint(src), "paper/section-word")).toHaveLength(2);
111
+ expect(await fixed(src)).toBe(src);
112
+ });
113
+
114
+ it("silent on Section written out, on comments, on the bibliography", async () => {
115
+ const src =
116
+ "\\documentclass{acmart}\n\\begin{filecontents*}{refs.bib}\n@misc{k, note = {see §5}, url = {https://x.org}}\n\\end{filecontents*}\n" +
117
+ "\\begin{document}\nSection~\\ref{a}.\n% §5 in an old draft\n\\end{document}\n";
118
+ expect(ids(await lint(src), "paper/section-word")).toEqual([]);
119
+ });
120
+
121
+ it("markdown: `§5` → `Section 5`, code untouched", async () => {
122
+ const src = "See §5 and `§3` here.\n";
123
+ expect(ids(await lint(src, "paper.md"), "paper/section-word")).toHaveLength(
124
+ 1,
125
+ );
126
+ expect(await fixed(src, "paper.md")).toBe("See Section 5 and `§3` here.\n");
127
+ });
128
+ });
129
+
130
+ describe("paper/leading-zero", () => {
131
+ const caught = [
132
+ ["a p-value in inline math", "We call it significant at $p < .05$."],
133
+ ["a p-value after other math", "(bugfix $-31\\%$, $p=.002$)"],
134
+ [
135
+ "a table cell",
136
+ "\\begin{tabular}{ll}\ntask & p \\\\\nbugfix & .037 \\\\\n\\end{tabular}",
137
+ ],
138
+ ["plain prose", "We use a threshold of .05 throughout."],
139
+ ];
140
+ it.each(caught)("CAUGHT, exactly one: %s", async (_, body) => {
141
+ expect(ids(await lint(doc(body)), "paper/leading-zero")).toHaveLength(1);
142
+ });
143
+
144
+ const silent = [
145
+ ["a figure width option", "\\includegraphics[width=.48\\columnwidth]{f}"],
146
+ [
147
+ "a tabular column spec",
148
+ "\\begin{tabular}{p{.25\\linewidth}l}\na & b \\\\\n\\end{tabular}",
149
+ ],
150
+ ["a comment", "Text.\n% p<.01 in the old draft\nMore text."],
151
+ ["a listing", "\\begin{lstlisting}\nx = .25\n\\end{lstlisting}"],
152
+ ["inline listing", "Call \\lstinline{.25} here."],
153
+ ["a length argument", "A\\hspace{.3em}B\\hspace{.35em}C"],
154
+ ["a macro definition body", "\\def\\x{.85}"],
155
+ [
156
+ "tikz coordinates",
157
+ "\\begin{tikzpicture}\\draw (.35,.65);\\end{tikzpicture}",
158
+ ],
159
+ ["an arXiv id", "See arXiv 2310.05736 for details."],
160
+ [
161
+ "an unsigned environment's width",
162
+ "\\begin{subfigure}[b]{.48\\textwidth}\nPanel.\n\\end{subfigure}",
163
+ ],
164
+ [
165
+ "an unsigned table's column spec",
166
+ "\\begin{longtable}{p{.25\\linewidth}l}\na & b \\\\\n\\end{longtable}",
167
+ ],
168
+ [
169
+ "an unknown macro's options",
170
+ "\\adjustbox{width=.48\\linewidth}{x} \\foo[scale=.75]{y}",
171
+ ],
172
+ ];
173
+ it.each(silent)("SILENT: %s", async (_, body) => {
174
+ expect(ids(await lint(doc(body)), "paper/leading-zero")).toEqual([]);
175
+ });
176
+
177
+ it("the preamble and the inline bibliography are not the reader's text", async () => {
178
+ const src =
179
+ "\\documentclass{acmart}\n\\renewcommand{\\arraystretch}{.85}\n" +
180
+ "\\begin{filecontents*}{refs.bib}\n@misc{k, note = {p = .05}, url = {https://x.org}}\n" +
181
+ "\\end{filecontents*}\n\\begin{document}\nNothing here.\n\\end{document}\n";
182
+ expect(ids(await lint(src), "paper/leading-zero")).toEqual([]);
183
+ });
184
+
185
+ it("the fix inserts the zero at the dot — in math, in a table, in prose", async () => {
186
+ const src = doc(
187
+ "At $p < .05$ and $d=.21$; cell & .037 \\\\ and .5 or .25.",
188
+ );
189
+ expect(await fixed(src)).toBe(
190
+ doc("At $p < 0.05$ and $d=0.21$; cell & 0.037 \\\\ and .5 or 0.25."),
191
+ );
192
+ });
193
+
194
+ it("markdown: prose and a table cell are fixed, code is not", async () => {
195
+ const src =
196
+ "Significant at p<.05.\n\n| task | p |\n| --- | --- |\n| bugfix | .002 |\n\nRun `.25`.\n";
197
+ expect(ids(await lint(src, "paper.md"), "paper/leading-zero")).toHaveLength(
198
+ 2,
199
+ );
200
+ expect(await fixed(src, "paper.md")).toBe(
201
+ "Significant at p<0.05.\n\n| task | p |\n| --- | --- |\n| bugfix | 0.002 |\n\nRun `.25`.\n",
202
+ );
203
+ });
204
+ });
205
+
206
+ describe("paper/figure-ref-style", () => {
207
+ it("the minority form is reported and rewritten to the majority", async () => {
208
+ const src = doc("Figure~\\ref{a}, Figure~\\ref{b} and Fig.~\\ref{c}.");
209
+ const found = ids(await lint(src), "paper/figure-ref-style");
210
+ expect(found).toHaveLength(1);
211
+ expect(found[0].column).toBe(src.split("\n")[2].indexOf("Fig.") + 1);
212
+ expect(await fixed(src)).toBe(
213
+ doc("Figure~\\ref{a}, Figure~\\ref{b} and Figure~\\ref{c}."),
214
+ );
215
+ });
216
+
217
+ it("the majority can be the short form", async () => {
218
+ const src = doc("Fig.~\\ref{a}, Fig.~\\ref{b} and Figure~\\ref{c}.");
219
+ expect(await fixed(src)).toBe(
220
+ doc("Fig.~\\ref{a}, Fig.~\\ref{b} and Fig.~\\ref{c}."),
221
+ );
222
+ });
223
+
224
+ it("silent when one form is used throughout", async () => {
225
+ expect(
226
+ ids(
227
+ await lint(doc("Fig.~\\ref{a} and Fig.~\\ref{b}.")),
228
+ "paper/figure-ref-style",
229
+ ),
230
+ ).toEqual([]);
231
+ });
232
+ });
233
+
234
+ describe("bib/reachable-entry", () => {
235
+ const bibDoc = (entries) =>
236
+ `\\documentclass{acmart}\n\\begin{filecontents*}{refs.bib}\n${entries}\n\\end{filecontents*}\n\\begin{document}\nx\n\\end{document}\n`;
237
+
238
+ it("a doi, a url or an arXiv id each make an entry reachable", async () => {
239
+ const res = await lint(
240
+ bibDoc(
241
+ "@article{a, doi = {10.1/x}}\n@misc{b, url = {https://x.org}}\n@misc{c, note = {arXiv:2310.05736}}\n@string{v = {x}}",
242
+ ),
243
+ );
244
+ expect(ids(res, "bib/reachable-entry")).toEqual([]);
245
+ });
246
+
247
+ it("reports on the entry itself, naming its key", async () => {
248
+ const res = await lint(
249
+ bibDoc("@misc{ok, url = {https://x.org}}\n@article{lost, title = {x}}"),
250
+ );
251
+ const [m] = ids(res, "bib/reachable-entry");
252
+ expect(m.line).toBe(4);
253
+ expect(m.column).toBe(1);
254
+ expect(m.message).toMatch(/`lost`/);
255
+ });
256
+ });
257
+
258
+ describe("the escape hatch — a disable directive in a `%` comment", () => {
259
+ it("🔴 inside the filecontents bibliography, it silences exactly the next entry", async () => {
260
+ const res = await lint(
261
+ `\\documentclass{acmart}\n\\begin{filecontents*}{refs.bib}\n` +
262
+ "% eslint-disable-next-line bib/reachable-entry -- an invited talk, no recording exists\n" +
263
+ "@misc{talk, title = {x}}\n@misc{lost, title = {y}}\n" +
264
+ "\\end{filecontents*}\n\\begin{document}\nx\n\\end{document}\n",
265
+ );
266
+ const found = ids(res, "bib/reachable-entry");
267
+ expect(found).toHaveLength(1);
268
+ expect(found[0].message).toMatch(/`lost`/);
269
+ });
270
+
271
+ it("in the body: next-line, same-line, and a disable/enable region", async () => {
272
+ const src = doc(
273
+ [
274
+ "% eslint-disable-next-line paper/leading-zero -- quoted from the reviewer",
275
+ "They wrote .05 here.",
276
+ "And .06 here. % eslint-disable-line paper/leading-zero -- also quoted",
277
+ "% eslint-disable paper/leading-zero -- a quoted table",
278
+ "Region .07 and .08.",
279
+ "% eslint-enable paper/leading-zero",
280
+ "But .09 counts.",
281
+ ].join("\n"),
282
+ );
283
+ const found = ids(await lint(src), "paper/leading-zero");
284
+ expect(found.map((m) => m.line)).toEqual([9]);
285
+ });
286
+
287
+ it("a directive that silences nothing is itself reported", async () => {
288
+ const res = await lint(
289
+ doc(
290
+ "% eslint-disable-next-line paper/leading-zero -- stale\nNo decimals here.",
291
+ ),
292
+ );
293
+ expect(
294
+ res.messages.filter((m) => /Unused eslint-disable/.test(m.message)),
295
+ ).toHaveLength(1);
296
+ });
297
+ });
@@ -47,9 +47,8 @@ const repoWith = (...dirs) => {
47
47
  for (const d of dirs) mkdirSync(join(base, d), { recursive: true });
48
48
  return base;
49
49
  };
50
- const declaring = (papers) => ({
51
- paperlint: { [PAPERS_DIR_FIELD]: papers },
52
- });
50
+ /** The root `paperlint.json`, parsed, declaring `papers`. */
51
+ const declaring = (papers) => ({ [PAPERS_DIR_FIELD]: papers });
53
52
 
54
53
  // ═════════════════════════════════════════════════════════════════════════════
55
54
  // I. THE DEFAULT AND THE DECLARATION
@@ -57,10 +56,9 @@ const declaring = (papers) => ({
57
56
  {
58
57
  const repo = repoWith(DEFAULT_PAPERS_ROOT);
59
58
  for (const [label, pkg] of [
60
- ["no package.json content at all", undefined],
61
- ["an empty package.json", {}],
62
- ["a package.json with an unrelated key", { name: "x", dependencies: {} }],
63
- ["our key present but empty", { paperlint: {} }],
59
+ ["no paperlint.json at all", undefined],
60
+ ["an empty paperlint.json", {}],
61
+ ["a paperlint.json with only other keys", { kind: "short", rules: {} }],
64
62
  ])
65
63
  assert.equal(
66
64
  papersRoot(pkg, repo),
@@ -97,8 +95,8 @@ for (const declared of ["papers", "docs/papers", "writing/drafts", "a/b/c/d"]) {
97
95
  e.message.includes('"writing/drafts"') &&
98
96
  // names WHERE it looked,
99
97
  e.message.includes(repo) &&
100
- // tells the reader it came from package.json rather than from a default,
101
- /declared in package\.json/.test(e.message) &&
98
+ // tells the reader it came from paperlint.json rather than from a default,
99
+ /declared in paperlint\.json/.test(e.message) &&
102
100
  // and says WHY this is fatal instead of ignorable.
103
101
  /indistinguishable/.test(e.message),
104
102
  "a declared-but-missing root must throw, and the message must name the value, the place " +
@@ -113,9 +111,7 @@ for (const declared of ["papers", "docs/papers", "writing/drafts", "a/b/c/d"]) {
113
111
  (e) =>
114
112
  /does not exist/.test(e.message) &&
115
113
  /Nothing was declared/.test(e.message) &&
116
- e.message.includes(
117
- `"paperlint": { "${PAPERS_DIR_FIELD}": "path/to/papers" }`,
118
- ),
114
+ e.message.includes(`{ "${PAPERS_DIR_FIELD}": "path/to/papers" }`),
119
115
  "a missing root reached by DEFAULTING must say so and show the declaration to add",
120
116
  );
121
117
  }
@@ -10,25 +10,21 @@
10
10
  * that forbids imports), the PROSE of a skill (read by a model, not resolved by code), and
11
11
  * LaTeX (`TEXINPUTS`, kpathsea).
12
12
  *
13
- * ── THE DECISION (2026-09-11) ───────────────────────────────────────────────
14
- * ONE declaration, in the consumer's `package.json`, with a default:
13
+ * ── THE DECISION ────────────────────────────────────────────────────────────
14
+ * ONE declaration, in the project's root `paperlint.json`, with a default:
15
15
  *
16
- * "paperlint": { "papersDir": "docs/papers" }
16
+ * { "papersDir": "docs/papers" }
17
17
  *
18
- * (The field was called `papers` until 2026-09-24; the old name is now refused.)
19
- * No key → `papers`. Every carrier then reads that one value with its OWN standard mechanism:
18
+ * No file, or no key → `papers`. Every carrier then reads that one value with its OWN standard
19
+ * mechanism:
20
20
  *
21
- * ESLint → `import pkg from "./package.json" with { type: "json" }` + this module
22
- * hook → `needs: [provide("pkg", "cat package.json")]`, JSON.parse inside `decide`
21
+ * ESLint → `settingsOf(root)` (lib/paper-config.mjs) + this module
22
+ * hook → `needs: [provide("config", …)]` cats the file, JSON.parse inside `decide`
23
23
  * prose → the skill names a COMMAND, not a path:
24
- * `node -p "require('./package.json')['paperlint']?.papersDir ?? 'papers'"`
24
+ * `node -p "(() => { try { return require('./paperlint.json').papersDir } catch {} })() ?? 'papers'"`
25
25
  * LaTeX → `TEXINPUTS` built FROM THE SCRIPT (`$(dirname "$0")/../tex//:`), so the
26
26
  * consumer declares nothing at all for this carrier
27
27
  *
28
- * `package.json` is the right home rather than a new dotfile for one measured reason: it is
29
- * the file Node already opens to resolve this package, so it cannot be absent, cannot be
30
- * gitignored, and needs no discovery rules of its own.
31
- *
32
28
  * ── WHAT WAS MEASURED AND KILLED, so it is not proposed again ───────────────
33
29
  * (full measurements: consumer repo, `idei/paper-pipeline-extraction/`, 2026-09-11)
34
30
  *
@@ -85,17 +81,14 @@ import {
85
81
  CONFIG_KEY,
86
82
  DEFAULT_PAPERS_ROOT,
87
83
  PAPERS_DIR_FIELD,
88
- PAPER_SETTINGS_FILE,
89
- declaredSettings,
90
- renamedFieldMessage,
91
- settingsOf,
84
+ CONFIG_FILE,
92
85
  } from "../lib/paper-config.mjs";
93
86
  export { DEFAULT_PAPERS_ROOT };
94
87
 
95
88
  /**
96
89
  * The declared papers root, verified to be on disk.
97
90
  *
98
- * @param pkg the consumer's parsed `package.json`
91
+ * @param settings the project's root `paperlint.json`, parsed (`settingsOf(root)`), or undefined
99
92
  * @param baseDir the directory the root is relative to. PASS `import.meta.dirname` FROM THE
100
93
  * CONFIG rather than relying on the default: `process.cwd()` is wherever the
101
94
  * editor, the hook runtime or the CI step happened to start, and in a git
@@ -105,12 +98,8 @@ export { DEFAULT_PAPERS_ROOT };
105
98
  * globs, which ESLint interprets relative to the config, and an absolute path there
106
99
  * would change their meaning.
107
100
  */
108
- export function papersRoot(pkg, baseDir = process.cwd()) {
109
- const found = declaredSettings(pkg);
110
- if (found.conflict !== null) throw new TypeError(found.conflict);
111
- const renamed = renamedFieldMessage(found.settings);
112
- if (renamed) throw new TypeError(renamed);
113
- const declared = settingsOf(pkg)?.[PAPERS_DIR_FIELD];
101
+ export function papersRoot(settings, baseDir = process.cwd()) {
102
+ const declared = settings?.[PAPERS_DIR_FIELD];
114
103
  // 🔴 `declared === undefined`, NOT `declared ?? DEFAULT`. The two differ on exactly one
115
104
  // input — `"papersDir": null` — and the difference is the whole point: `??` reads an explicit
116
105
  // `null` as "nothing was declared" and silently uses the default, which is a typed keystroke
@@ -127,9 +116,9 @@ export function papersRoot(pkg, baseDir = process.cwd()) {
127
116
  `${CONFIG_KEY}: the papers root "${root}" does not exist under ${baseDir}.\n` +
128
117
  (declared === undefined
129
118
  ? `Nothing was declared, so the default "${DEFAULT_PAPERS_ROOT}" was used. Declare the ` +
130
- `real location in package.json:\n` +
131
- ` "${CONFIG_KEY}": { "${PAPERS_DIR_FIELD}": "path/to/papers" }`
132
- : `It is declared in package.json as "${declared}". Fix it there, or create the ` +
119
+ `real location in ${CONFIG_FILE}:\n` +
120
+ ` { "${PAPERS_DIR_FIELD}": "path/to/papers" }`
121
+ : `It is declared in ${CONFIG_FILE} as "${declared}". Fix it there, or create the ` +
133
122
  `directory.`) +
134
123
  `\nThis is thrown rather than ignored on purpose: a papers root that matches nothing ` +
135
124
  `makes every rule lint zero files, and a run with zero findings is indistinguishable ` +
@@ -155,11 +144,8 @@ export function paperFiles(root) {
155
144
  tex: [`${root}/*/paper.tex`],
156
145
  /** The per-paper stage ledger. */
157
146
  status: [`${root}/*/PIPELINE-STATUS.md`],
158
- /**
159
- * The per-paper settings file, `paperlint.json` (`venue.json` before 2.1.0). The key keeps its
160
- * old name so a consumer's config that lints these files keeps matching them.
161
- */
162
- venue: [`${root}/*/${PAPER_SETTINGS_FILE}`],
147
+ /** The per-paper settings file, `paperlint.json`. */
148
+ venue: [`${root}/*/${CONFIG_FILE}`],
163
149
  /** Facts extracted from the built PDF. */
164
150
  pdfFacts: [`${root}/*/_build/paper.facts.json`],
165
151
  /** Facts extracted from the bibliography. */
@@ -6,10 +6,9 @@
6
6
  * ICSE/ASE/SIGSOFT/SIGPLAN proceedings; Sheridan for ACM SIG conferences such as CCS), IEEEtran
7
7
  * advises it for camera-ready work, and ACL, USENIX, AAAI and ICML say nothing. No standard format
8
8
  * checker (HotCRP's banal, aclpubcheck, IEEE PDF eXpress) tests it. So a consumer turns it on for
9
- * the papers whose venue asks, in package.json:
9
+ * the papers whose venue asks, in that paper's paperlint.json:
10
10
  *
11
- * "paperlint": { "rules": [ { "files": ["papers/my-paper/**"],
12
- * "rules": { "pdf/last-page-balance": ["error", { "tolerancePt": 120 }] } } ] }
11
+ * { "rules": { "pdf/last-page-balance": ["error", { "tolerancePt": 120 }] } }
13
12
  *
14
13
  * (That is a line comment's worth of JSON in a block comment only because it has no asterisk
15
14
  * followed by a slash; see rule 8 of CLAUDE.md before editing it.)
@@ -36,7 +35,7 @@
36
35
  import { createHash } from "node:crypto";
37
36
  import { existsSync, readFileSync } from "node:fs";
38
37
  import { basename, dirname, isAbsolute, join } from "node:path";
39
- import { PAPER_SETTINGS_FILE } from "../lib/paper-config.mjs";
38
+ import { CONFIG_FILE } from "../lib/paper-config.mjs";
40
39
 
41
40
  /** The difference, in points, that two columns may end apart. See the harness for why 120. */
42
41
  export const DEFAULT_TOLERANCE_PT = 120;
@@ -143,9 +142,7 @@ function reportLine(file) {
143
142
  */
144
143
  function extendsPreset(paperDir) {
145
144
  try {
146
- const s = JSON.parse(
147
- readFileSync(join(paperDir, PAPER_SETTINGS_FILE), "utf8"),
148
- );
145
+ const s = JSON.parse(readFileSync(join(paperDir, CONFIG_FILE), "utf8"));
149
146
  return typeof s?.extends === "string" && s.extends !== "";
150
147
  } catch {
151
148
  return false;
@@ -0,0 +1,118 @@
1
+ /**
2
+ * `review/frontmatter` — a review report's frontmatter is a RECORD, validated by a JSON Schema.
3
+ *
4
+ * ── WHAT IT CHECKS ───────────────────────────────────────────────────────────────
5
+ * A review under `reviews/` declares what it found in its YAML frontmatter:
6
+ *
7
+ * findings:
8
+ * - id: 1
9
+ * status: open # open | fixed | wontfix
10
+ * cause: missing-skill # skill-defect | missing-skill | hook | rule — required when open
11
+ *
12
+ * The pipeline convention behind it: a review does not end with a list of findings, it ends with
13
+ * what in the PIPELINE let each one through. Otherwise the text gets fixed while the tool that
14
+ * missed it stays the same. The schema is `review-frontmatter.schema.json` beside this file, and
15
+ * "an open finding names its cause" is its `if`/`then`, not code here.
16
+ *
17
+ * The same mechanism, with a schema of its own, is `sibling/frontmatter`
18
+ * (`sibling-frontmatter.mjs`): `frontmatterRule` below is the one implementation.
19
+ *
20
+ * ── WHY THIS REPLACED TWO RULES IN 3.0.0 ────────────────────────────────────────────
21
+ * `review/findings-cause` GUESSED the findings from the prose: it counted numbered table rows and
22
+ * list items starting in bold, looked for a magic word (`causeMarker`, "Cause:") anywhere in the
23
+ * text, spoke only above a threshold (`minFindings`), and skipped reports older than a date
24
+ * (`sinceCreated`) — a date ratchet its own header said belonged in `ignores`. Every one of
25
+ * those was a convention in prose standing in for a field nobody had declared.
26
+ * `doc/fields` was a hand-rolled schema language in the config (`{ field: { values: [...] } }`),
27
+ * and its one real use was the `read:` field of sibling cards — which this package's own
28
+ * `analyze-sibling-paper` skill writes, so the package now ships that schema too. The record is
29
+ * data, the schema is JSON Schema, the validator is ajv.
30
+ *
31
+ * A review with no `findings` key is not a findings record and is not checked for findings — so
32
+ * there is no date to compare: old reports simply have no records. A file with no frontmatter is
33
+ * validated as `{}`, so a field a schema requires is still reported on it: a missing header must
34
+ * not be a way around the check.
35
+ *
36
+ * YAML is read with js-yaml's CORE schema, which leaves `2026-09-20` a string. The default schema
37
+ * turns it into a Date, and a schema asking for `"type": "string"` would then reject a date the
38
+ * author wrote correctly.
39
+ */
40
+ import Ajv from "ajv";
41
+ import { CORE_SCHEMA, load } from "js-yaml";
42
+ import { readFileSync } from "node:fs";
43
+
44
+ /** One ajv error as a line a person can act on. `if` errors only repeat their `then`. */
45
+ function describe(e) {
46
+ const where = e.dataPath
47
+ ? `\`${e.dataPath.replace(/^\./, "")}\``
48
+ : "the frontmatter";
49
+ if (e.keyword === "enum")
50
+ return `${where} must be one of: ${e.params.allowedValues.join(", ")}`;
51
+ if (e.keyword === "additionalProperties")
52
+ return `${where} has an unknown field \`${e.params.additionalProperty}\``;
53
+ return `${where} ${e.message}`;
54
+ }
55
+
56
+ const errorsOf = (validate, data) =>
57
+ validate(data)
58
+ ? []
59
+ : (validate.errors ?? []).filter((e) => e.keyword !== "if").map(describe);
60
+
61
+ /**
62
+ * A rule that validates a markdown file's YAML frontmatter against the JSON Schema in `schemaUrl`
63
+ * (a file shipped beside the rule). A file with no frontmatter is validated as `{}`.
64
+ */
65
+ export function frontmatterRule(schemaUrl, description) {
66
+ const validate = new Ajv({ allErrors: true }).compile(
67
+ JSON.parse(readFileSync(schemaUrl, "utf8")),
68
+ );
69
+ return {
70
+ meta: {
71
+ type: "problem",
72
+ docs: { description },
73
+ schema: [],
74
+ messages: {
75
+ malformed:
76
+ "the frontmatter does not parse as YAML ({{why}}) — there is no record to read",
77
+ invalid: "{{problem}}",
78
+ },
79
+ },
80
+ create(context) {
81
+ let frontmatter = null;
82
+ let data = {};
83
+ return {
84
+ yaml(node) {
85
+ frontmatter = node;
86
+ try {
87
+ data = load(node.value ?? "", { schema: CORE_SCHEMA }) ?? {};
88
+ } catch (e) {
89
+ data = null;
90
+ context.report({
91
+ node,
92
+ messageId: "malformed",
93
+ data: { why: e.reason ?? e.message ?? "unparseable" },
94
+ });
95
+ }
96
+ },
97
+ "root:exit"(root) {
98
+ if (data === null) return;
99
+ for (const problem of errorsOf(validate, data))
100
+ context.report({
101
+ node: frontmatter ?? root,
102
+ messageId: "invalid",
103
+ data: { problem },
104
+ });
105
+ },
106
+ };
107
+ },
108
+ };
109
+ }
110
+
111
+ export default {
112
+ rules: {
113
+ frontmatter: frontmatterRule(
114
+ new URL("./review-frontmatter.schema.json", import.meta.url),
115
+ "a review's frontmatter is a record: its findings, each with a status and, when open, the pipeline cause — validated by JSON Schema",
116
+ ),
117
+ },
118
+ };
@@ -0,0 +1,36 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "urn:paperlint:review-frontmatter",
4
+ "title": "paperlint review frontmatter",
5
+ "description": "The YAML frontmatter of a review report under reviews/. A review with no `findings` key is not a findings record and is not checked for findings. A project adds its own required fields with the `reviewSchema` setting; its schema is applied together with this one.",
6
+ "type": "object",
7
+ "properties": {
8
+ "findings": {
9
+ "description": "What the review found, one record per finding.",
10
+ "type": "array",
11
+ "items": { "$ref": "#/definitions/finding" }
12
+ }
13
+ },
14
+ "definitions": {
15
+ "finding": {
16
+ "type": "object",
17
+ "required": ["id", "status"],
18
+ "properties": {
19
+ "id": {
20
+ "description": "How the review body refers to this finding.",
21
+ "type": ["integer", "string"]
22
+ },
23
+ "status": { "enum": ["open", "fixed", "wontfix"] },
24
+ "cause": {
25
+ "description": "What in the PIPELINE let this through: a defective skill, a missing skill, a hook, or a rule. Fix that, and the text edit falls out of running the fixed tool.",
26
+ "enum": ["skill-defect", "missing-skill", "hook", "rule"]
27
+ },
28
+ "title": { "type": "string" },
29
+ "note": { "type": "string" }
30
+ },
31
+ "additionalProperties": false,
32
+ "if": { "properties": { "status": { "const": "open" } } },
33
+ "then": { "required": ["cause"] }
34
+ }
35
+ }
36
+ }