paperlint 2.0.1 → 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 (226) hide show
  1. package/CLAUDE.md +6 -1
  2. package/CONTRIBUTING.md +21 -0
  3. package/README.md +231 -180
  4. package/action.yml +4 -6
  5. package/dist/adapters/references/index.d.ts +13 -0
  6. package/dist/adapters/references/index.d.ts.map +1 -0
  7. package/dist/adapters/references/index.js +55 -0
  8. package/dist/adapters/references/index.js.map +1 -0
  9. package/dist/adapters/references/reach.io.d.ts +3 -0
  10. package/dist/adapters/references/reach.io.d.ts.map +1 -0
  11. package/dist/adapters/references/reach.io.js +14 -0
  12. package/dist/adapters/references/reach.io.js.map +1 -0
  13. package/dist/build-engine.d.ts.map +1 -1
  14. package/dist/build-engine.js +5 -2
  15. package/dist/build-engine.js.map +1 -1
  16. package/dist/build.d.ts +16 -1
  17. package/dist/build.d.ts.map +1 -1
  18. package/dist/build.js +40 -5
  19. package/dist/build.js.map +1 -1
  20. package/dist/cli.d.ts +37 -20
  21. package/dist/cli.d.ts.map +1 -1
  22. package/dist/cli.js +377 -251
  23. package/dist/cli.js.map +1 -1
  24. package/dist/doctor.d.ts.map +1 -1
  25. package/dist/doctor.js +37 -52
  26. package/dist/doctor.js.map +1 -1
  27. package/dist/facts-file.d.ts +33 -9
  28. package/dist/facts-file.d.ts.map +1 -1
  29. package/dist/facts-file.js +117 -17
  30. package/dist/facts-file.js.map +1 -1
  31. package/dist/hooks-settings.d.ts +0 -21
  32. package/dist/hooks-settings.d.ts.map +1 -1
  33. package/dist/hooks-settings.js +17 -135
  34. package/dist/hooks-settings.js.map +1 -1
  35. package/dist/init.d.ts +40 -19
  36. package/dist/init.d.ts.map +1 -1
  37. package/dist/init.js +144 -109
  38. package/dist/init.js.map +1 -1
  39. package/dist/link-skills.d.ts +1 -1
  40. package/dist/link-skills.d.ts.map +1 -1
  41. package/dist/link-skills.js +11 -29
  42. package/dist/link-skills.js.map +1 -1
  43. package/dist/new-paper.d.ts +2 -2
  44. package/dist/new-paper.d.ts.map +1 -1
  45. package/dist/new-paper.js +6 -2
  46. package/dist/new-paper.js.map +1 -1
  47. package/dist/paper-files.d.ts +63 -0
  48. package/dist/paper-files.d.ts.map +1 -0
  49. package/dist/paper-files.js +69 -0
  50. package/dist/paper-files.js.map +1 -0
  51. package/dist/paper-settings.d.ts +40 -0
  52. package/dist/paper-settings.d.ts.map +1 -0
  53. package/dist/paper-settings.js +144 -0
  54. package/dist/paper-settings.js.map +1 -0
  55. package/dist/ports/check-references.d.ts +31 -0
  56. package/dist/ports/check-references.d.ts.map +1 -0
  57. package/dist/ports/check-references.js +7 -0
  58. package/dist/ports/check-references.js.map +1 -0
  59. package/dist/presets.d.ts +84 -0
  60. package/dist/presets.d.ts.map +1 -0
  61. package/dist/presets.js +184 -0
  62. package/dist/presets.js.map +1 -0
  63. package/dist/reference-rules.d.ts +47 -0
  64. package/dist/reference-rules.d.ts.map +1 -0
  65. package/dist/reference-rules.js +156 -0
  66. package/dist/reference-rules.js.map +1 -0
  67. package/dist/references.d.ts +36 -0
  68. package/dist/references.d.ts.map +1 -0
  69. package/dist/references.js +91 -0
  70. package/dist/references.js.map +1 -0
  71. package/dist/rules-config.d.ts +11 -2
  72. package/dist/rules-config.d.ts.map +1 -1
  73. package/dist/rules-config.js +25 -11
  74. package/dist/rules-config.js.map +1 -1
  75. package/dist/structure.d.ts.map +1 -1
  76. package/dist/structure.js +3 -2
  77. package/dist/structure.js.map +1 -1
  78. package/dist/tex-requirements.d.ts +63 -7
  79. package/dist/tex-requirements.d.ts.map +1 -1
  80. package/dist/tex-requirements.js +91 -24
  81. package/dist/tex-requirements.js.map +1 -1
  82. package/dist/types.d.ts +20 -18
  83. package/dist/types.d.ts.map +1 -1
  84. package/dist/venue-rules.d.ts +109 -0
  85. package/dist/venue-rules.d.ts.map +1 -0
  86. package/dist/venue-rules.js +389 -0
  87. package/dist/venue-rules.js.map +1 -0
  88. package/docs/configuration.md +152 -77
  89. package/docs/e2e.md +1 -1
  90. package/docs/install.md +25 -40
  91. package/docs/optional-rules.md +26 -24
  92. package/docs/rules.md +181 -15
  93. package/eslint-rules/bib-reachable-entry.mjs +75 -0
  94. package/eslint-rules/latex-language.mjs +29 -1
  95. package/eslint-rules/paper-stages.harness.mjs +1 -117
  96. package/eslint-rules/paper-stages.mjs +0 -120
  97. package/eslint-rules/paper-stages.mutations.mjs +0 -76
  98. package/eslint-rules/paper-typography.mjs +267 -192
  99. package/eslint-rules/paper-typography.test.mjs +297 -0
  100. package/eslint-rules/papers.harness.mjs +8 -12
  101. package/eslint-rules/papers.mjs +17 -27
  102. package/eslint-rules/pdf-last-page-balance.mjs +23 -5
  103. package/eslint-rules/review-frontmatter.mjs +118 -0
  104. package/eslint-rules/review-frontmatter.schema.json +36 -0
  105. package/eslint-rules/review-frontmatter.test.mjs +118 -0
  106. package/eslint-rules/sibling-frontmatter.mjs +19 -0
  107. package/eslint-rules/sibling-frontmatter.schema.json +14 -0
  108. package/eslint.config.mjs +0 -15
  109. package/fixtures/build-e2e/acmart/paperlint.json +1 -0
  110. package/fixtures/real-markdown-paper/baseline.json +6 -7
  111. package/hooks/hooks.harness.mjs +39 -98
  112. package/hooks/hooks.mutations.mjs +6 -6
  113. package/hooks/paper-edit-guard.hook.d.mts +2 -3
  114. package/hooks/paper-edit-guard.hook.mjs +31 -42
  115. package/hooks/paper-skills-nudge.hook.mjs +21 -19
  116. package/hooks/paper-status-gates.hook.mjs +21 -19
  117. package/hooks/paper-status-gates.sh +8 -13
  118. package/lib/paper-config.d.mts +15 -18
  119. package/lib/paper-config.harness.mjs +18 -35
  120. package/lib/paper-config.mjs +90 -81
  121. package/lib/paper-config.mutations.mjs +2 -2
  122. package/lib/skill-checks.mjs +1 -1
  123. package/lib/skill-trigger-cases.harness.mjs +9 -8
  124. package/lib/skill-trigger-cases.mjs +7 -15
  125. package/package.json +1 -1
  126. package/scripts/eslint-report-guard.mjs +1 -1
  127. package/scripts/harness-api.frozen.json +0 -3
  128. package/scripts/mutation-batteries.frozen.json +4 -13
  129. package/skills/analyze-sibling-paper/SKILL.md +16 -12
  130. package/skills/analyze-sibling-paper/SKILL.md.spec.ts +15 -11
  131. package/skills/cold-read-diff/SKILL.md +20 -1
  132. package/skills/cold-read-diff/SKILL.md.spec.ts +19 -0
  133. package/skills/find-venue/SKILL.md +5 -1
  134. package/skills/find-venue/SKILL.md.spec.ts +4 -0
  135. package/skills/grade-paper-writing/SKILL.md +20 -1
  136. package/skills/grade-paper-writing/SKILL.md.spec.ts +19 -0
  137. package/skills/paper-pipeline/PIPELINE-MAP.md +14 -4
  138. package/skills/paper-pipeline/SKILL.md +4 -3
  139. package/skills/paper-pipeline/SKILL.md.spec.ts +3 -2
  140. package/skills/paper-pipeline/scripts/consumer.d.mts +0 -1
  141. package/skills/paper-pipeline/scripts/consumer.harness.mjs +8 -12
  142. package/skills/paper-pipeline/scripts/consumer.mjs +19 -33
  143. package/skills/paper-pipeline/scripts/extract-ref-facts.harness.mjs +6 -4
  144. package/skills/paper-pipeline/scripts/run-mechanical.mjs +2 -11
  145. package/skills/plan-paper-timeline/SKILL.md +2 -2
  146. package/skills/plan-paper-timeline/SKILL.md.spec.ts +1 -1
  147. package/skills/plan-paper-timeline/plan-paper-timeline.effects.harness.mjs +1 -1
  148. package/skills/plan-paper-timeline/plan-paper-timeline.effects.mutations.mjs +2 -2
  149. package/skills/render-paper/check-render.sh +2 -3
  150. package/skills/render-paper/extract-pdf-facts.harness.mjs +5 -5
  151. package/skills/render-paper/extract-pdf-facts.mjs +4 -4
  152. package/skills/render-paper/render-paper.harness.mjs +2 -2
  153. package/skills/study-accepted-papers/SKILL.md +2 -1
  154. package/skills/study-accepted-papers/SKILL.md.spec.ts +1 -0
  155. package/skills/submit-paper/SKILL.md +9 -1
  156. package/skills/submit-paper/SKILL.md.spec.ts +8 -0
  157. package/skills/submit-paper/references/venues/acm-sigconf.jsonc +86 -0
  158. package/skills/submit-paper/references/venues/agenticdev.jsonc +34 -102
  159. package/skills/submit-paper/references/venues/aisec.jsonc +21 -82
  160. package/skills/submit-paper/references/venues/realm.jsonc +45 -43
  161. package/skills/submit-paper/references/venues/tex-base.jsonc +1 -1
  162. package/skills/submit-paper/references/venues/venue-profile.schema.json +119 -29
  163. package/skills/verify-citations/SKILL.md +10 -1
  164. package/skills/verify-citations/SKILL.md.spec.ts +9 -0
  165. package/skills/verify-citations/scripts/bib-authors.mjs +48 -26
  166. package/src/adapters/references/index.ts +102 -0
  167. package/src/adapters/references/reach.io.ts +12 -0
  168. package/src/build-engine.ts +10 -2
  169. package/src/build.harness.mjs +31 -22
  170. package/src/build.ts +65 -8
  171. package/src/cli.harness.mjs +151 -294
  172. package/src/cli.mutations.mjs +35 -44
  173. package/src/cli.ts +457 -288
  174. package/src/doctor.harness.mjs +41 -84
  175. package/src/doctor.mutations.mjs +9 -9
  176. package/src/doctor.ts +41 -61
  177. package/src/facts-file.test.ts +47 -7
  178. package/src/facts-file.ts +145 -24
  179. package/src/hooks-settings.harness.mjs +7 -92
  180. package/src/hooks-settings.mutations.mjs +2 -17
  181. package/src/hooks-settings.ts +18 -162
  182. package/src/init.test.ts +206 -0
  183. package/src/init.ts +193 -159
  184. package/src/link-skills.harness.mjs +1 -38
  185. package/src/link-skills.mutations.mjs +9 -5
  186. package/src/link-skills.ts +10 -34
  187. package/src/lint-fix.test.ts +120 -0
  188. package/src/new-paper.harness.mjs +9 -6
  189. package/src/new-paper.test.ts +133 -0
  190. package/src/new-paper.ts +7 -2
  191. package/src/paper-files.test.ts +290 -0
  192. package/src/paper-files.ts +144 -0
  193. package/src/paper-settings-commands.test.ts +274 -0
  194. package/src/paper-settings.test.ts +249 -0
  195. package/src/paper-settings.ts +208 -0
  196. package/src/ports/check-references.ts +28 -0
  197. package/src/presets.test.ts +236 -0
  198. package/src/presets.ts +297 -0
  199. package/src/reference-rules.ts +229 -0
  200. package/src/references.test.ts +209 -0
  201. package/src/references.ts +138 -0
  202. package/src/rules-config.harness.mjs +24 -8
  203. package/src/rules-config.ts +30 -13
  204. package/src/structure.harness.mjs +6 -4
  205. package/src/structure.ts +3 -2
  206. package/src/tex-requirements.harness.mjs +11 -17
  207. package/src/tex-requirements.ts +179 -29
  208. package/src/types.ts +30 -18
  209. package/src/venue-rules.test.ts +554 -0
  210. package/src/venue-rules.ts +571 -0
  211. package/templates/paper/paperlint.json +4 -0
  212. package/eslint-rules/doc-fields.harness.mjs +0 -336
  213. package/eslint-rules/doc-fields.mjs +0 -186
  214. package/eslint-rules/doc-fields.mutations.mjs +0 -96
  215. package/eslint-rules/paper-typography.harness.mjs +0 -291
  216. package/eslint-rules/paper-typography.mutations.mjs +0 -131
  217. package/eslint-rules/review-findings-cause.harness.mjs +0 -228
  218. package/eslint-rules/review-findings-cause.mjs +0 -135
  219. package/eslint-rules/review-findings-cause.mutations.mjs +0 -72
  220. package/fixtures/build-e2e/acmart/venue.json +0 -1
  221. package/fixtures/paper-stages/authors-ran/PIPELINE-STATUS.md +0 -16
  222. package/fixtures/paper-stages/marker-in-prose/PIPELINE-STATUS.md +0 -17
  223. package/fixtures/review-findings-cause/clean.md +0 -17
  224. package/fixtures/review-findings-cause/defect.md +0 -14
  225. package/fixtures/review-findings-cause/old-debt.md +0 -14
  226. 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,16 +81,14 @@ import {
85
81
  CONFIG_KEY,
86
82
  DEFAULT_PAPERS_ROOT,
87
83
  PAPERS_DIR_FIELD,
88
- declaredSettings,
89
- renamedFieldMessage,
90
- settingsOf,
84
+ CONFIG_FILE,
91
85
  } from "../lib/paper-config.mjs";
92
86
  export { DEFAULT_PAPERS_ROOT };
93
87
 
94
88
  /**
95
89
  * The declared papers root, verified to be on disk.
96
90
  *
97
- * @param pkg the consumer's parsed `package.json`
91
+ * @param settings the project's root `paperlint.json`, parsed (`settingsOf(root)`), or undefined
98
92
  * @param baseDir the directory the root is relative to. PASS `import.meta.dirname` FROM THE
99
93
  * CONFIG rather than relying on the default: `process.cwd()` is wherever the
100
94
  * editor, the hook runtime or the CI step happened to start, and in a git
@@ -104,12 +98,8 @@ export { DEFAULT_PAPERS_ROOT };
104
98
  * globs, which ESLint interprets relative to the config, and an absolute path there
105
99
  * would change their meaning.
106
100
  */
107
- export function papersRoot(pkg, baseDir = process.cwd()) {
108
- const found = declaredSettings(pkg);
109
- if (found.conflict !== null) throw new TypeError(found.conflict);
110
- const renamed = renamedFieldMessage(found.settings);
111
- if (renamed) throw new TypeError(renamed);
112
- const declared = settingsOf(pkg)?.[PAPERS_DIR_FIELD];
101
+ export function papersRoot(settings, baseDir = process.cwd()) {
102
+ const declared = settings?.[PAPERS_DIR_FIELD];
113
103
  // 🔴 `declared === undefined`, NOT `declared ?? DEFAULT`. The two differ on exactly one
114
104
  // input — `"papersDir": null` — and the difference is the whole point: `??` reads an explicit
115
105
  // `null` as "nothing was declared" and silently uses the default, which is a typed keystroke
@@ -126,9 +116,9 @@ export function papersRoot(pkg, baseDir = process.cwd()) {
126
116
  `${CONFIG_KEY}: the papers root "${root}" does not exist under ${baseDir}.\n` +
127
117
  (declared === undefined
128
118
  ? `Nothing was declared, so the default "${DEFAULT_PAPERS_ROOT}" was used. Declare the ` +
129
- `real location in package.json:\n` +
130
- ` "${CONFIG_KEY}": { "${PAPERS_DIR_FIELD}": "path/to/papers" }`
131
- : `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 ` +
132
122
  `directory.`) +
133
123
  `\nThis is thrown rather than ignored on purpose: a papers root that matches nothing ` +
134
124
  `makes every rule lint zero files, and a run with zero findings is indistinguishable ` +
@@ -154,8 +144,8 @@ export function paperFiles(root) {
154
144
  tex: [`${root}/*/paper.tex`],
155
145
  /** The per-paper stage ledger. */
156
146
  status: [`${root}/*/PIPELINE-STATUS.md`],
157
- /** The venue data card. */
158
- venue: [`${root}/*/venue.json`],
147
+ /** The per-paper settings file, `paperlint.json`. */
148
+ venue: [`${root}/*/${CONFIG_FILE}`],
159
149
  /** Facts extracted from the built PDF. */
160
150
  pdfFacts: [`${root}/*/_build/paper.facts.json`],
161
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.)
@@ -29,12 +28,14 @@
29
28
  *
30
29
  * Silent, by design, where there is nothing to judge: a stub last page (a few lines) and a review
31
30
  * build (numbered lines in the margins make both columns measure full height). Loud where the input
32
- * is missing: no facts file, a foreign schema, facts about a PDF that is not on disk or changed
31
+ * is missing: no facts file (unless the paper extends a venue preset — then `pdf/measured` says it),
32
+ * a foreign schema, facts about a PDF that is not on disk or changed
33
33
  * since — a rule that is on and reads nothing must not look like a rule that passed.
34
34
  */
35
35
  import { createHash } from "node:crypto";
36
36
  import { existsSync, readFileSync } from "node:fs";
37
37
  import { basename, dirname, isAbsolute, join } from "node:path";
38
+ import { CONFIG_FILE } from "../lib/paper-config.mjs";
38
39
 
39
40
  /** The difference, in points, that two columns may end apart. See the harness for why 120. */
40
41
  export const DEFAULT_TOLERANCE_PT = 120;
@@ -133,11 +134,28 @@ function reportLine(file) {
133
134
  }
134
135
  }
135
136
 
137
+ /**
138
+ * Whether the paper extends a venue preset. Then the venue rules run on it too, and `pdf/measured`
139
+ * already says — once, as a warning — that a paper with no facts was not checked; this rule
140
+ * repeating it as an error would fail a lint-only CI for every paper of a venue whose preset turns
141
+ * this rule on.
142
+ */
143
+ function extendsPreset(paperDir) {
144
+ try {
145
+ const s = JSON.parse(readFileSync(join(paperDir, CONFIG_FILE), "utf8"));
146
+ return typeof s?.extends === "string" && s.extends !== "";
147
+ } catch {
148
+ return false;
149
+ }
150
+ }
151
+
136
152
  /** Everything the rule decides for one paper, as a finding or null. */
137
153
  function verdict(paperDir, tolerancePt) {
138
154
  const factsFile = join(paperDir, FACTS_REL);
139
155
  if (!existsSync(factsFile))
140
- return { messageId: "noFacts", data: { file: FACTS_REL } };
156
+ return extendsPreset(paperDir)
157
+ ? null
158
+ : { messageId: "noFacts", data: { file: FACTS_REL } };
141
159
  const facts = parseFacts(readFileSync(factsFile, "utf8"));
142
160
  if (!facts.ok) return { messageId: facts.messageId, data: facts.data };
143
161
  return staleness(paperDir, facts) ?? judgeColumns(facts.last, tolerancePt);
@@ -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
+ }