paperlint 4.0.0 → 4.1.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 (172) hide show
  1. package/.github/workflows/ci.yml +16 -23
  2. package/CLAUDE.md +31 -11
  3. package/CONTRIBUTING.md +23 -7
  4. package/README.md +11 -8
  5. package/dist/adapters/banal/invocation.d.ts +1 -1
  6. package/dist/adapters/latex/document-class.d.ts +22 -0
  7. package/dist/adapters/latex/document-class.d.ts.map +1 -0
  8. package/dist/adapters/latex/document-class.js +102 -0
  9. package/dist/adapters/latex/document-class.js.map +1 -0
  10. package/dist/adapters/latex/hidden.d.ts +16 -0
  11. package/dist/adapters/latex/hidden.d.ts.map +1 -0
  12. package/dist/adapters/latex/hidden.js +49 -0
  13. package/dist/adapters/latex/hidden.js.map +1 -0
  14. package/dist/adapters/latex/index.d.ts +29 -0
  15. package/dist/adapters/latex/index.d.ts.map +1 -0
  16. package/dist/adapters/latex/index.js +18 -0
  17. package/dist/adapters/latex/index.js.map +1 -0
  18. package/dist/adapters/latex/nodes.d.ts +54 -0
  19. package/dist/adapters/latex/nodes.d.ts.map +1 -0
  20. package/dist/adapters/latex/nodes.js +93 -0
  21. package/dist/adapters/latex/nodes.js.map +1 -0
  22. package/dist/adapters/latex/outline.d.ts +10 -0
  23. package/dist/adapters/latex/outline.d.ts.map +1 -0
  24. package/dist/adapters/latex/outline.js +39 -0
  25. package/dist/adapters/latex/outline.js.map +1 -0
  26. package/dist/adapters/latex/parse.d.ts +42 -0
  27. package/dist/adapters/latex/parse.d.ts.map +1 -0
  28. package/dist/adapters/latex/parse.js +94 -0
  29. package/dist/adapters/latex/parse.js.map +1 -0
  30. package/dist/adapters/latex/rendered.d.ts +10 -0
  31. package/dist/adapters/latex/rendered.d.ts.map +1 -0
  32. package/dist/adapters/latex/rendered.js +39 -0
  33. package/dist/adapters/latex/rendered.js.map +1 -0
  34. package/dist/adapters/pdfjs/fill.d.ts.map +1 -1
  35. package/dist/adapters/pdfjs/fill.js +12 -12
  36. package/dist/adapters/pdfjs/fill.js.map +1 -1
  37. package/dist/build.js +4 -4
  38. package/dist/build.js.map +1 -1
  39. package/dist/cli.d.ts.map +1 -1
  40. package/dist/cli.js +29 -5
  41. package/dist/cli.js.map +1 -1
  42. package/dist/domain/tex-document.d.ts +84 -0
  43. package/dist/domain/tex-document.d.ts.map +1 -0
  44. package/dist/domain/tex-document.js +43 -0
  45. package/dist/domain/tex-document.js.map +1 -0
  46. package/dist/facts-file.d.ts.map +1 -1
  47. package/dist/facts-file.js +2 -2
  48. package/dist/facts-file.js.map +1 -1
  49. package/dist/new-paper.d.ts +5 -0
  50. package/dist/new-paper.d.ts.map +1 -1
  51. package/dist/new-paper.js +3 -0
  52. package/dist/new-paper.js.map +1 -1
  53. package/dist/package-dirs.d.ts +5 -0
  54. package/dist/package-dirs.d.ts.map +1 -0
  55. package/dist/package-dirs.js +14 -0
  56. package/dist/package-dirs.js.map +1 -0
  57. package/dist/ports/latex.d.ts +24 -0
  58. package/dist/ports/latex.d.ts.map +1 -0
  59. package/dist/ports/latex.js +2 -0
  60. package/dist/ports/latex.js.map +1 -0
  61. package/dist/presets.d.ts +15 -2
  62. package/dist/presets.d.ts.map +1 -1
  63. package/dist/presets.js +19 -6
  64. package/dist/presets.js.map +1 -1
  65. package/dist/tex-requirements.d.ts +11 -0
  66. package/dist/tex-requirements.d.ts.map +1 -1
  67. package/dist/tex-requirements.js +14 -8
  68. package/dist/tex-requirements.js.map +1 -1
  69. package/dist/tex-venue-rules.d.ts +83 -0
  70. package/dist/tex-venue-rules.d.ts.map +1 -0
  71. package/dist/tex-venue-rules.js +254 -0
  72. package/dist/tex-venue-rules.js.map +1 -0
  73. package/dist/venue-rules.d.ts +1 -1
  74. package/dist/venue-rules.d.ts.map +1 -1
  75. package/docs/configuration.md +3 -2
  76. package/docs/e2e.md +69 -40
  77. package/docs/install.md +1 -1
  78. package/docs/prior-art/README.md +1 -1
  79. package/docs/prior-art/package-location.md +5 -5
  80. package/docs/prior-art/repro/README.md +1 -1
  81. package/docs/prior-art/test-tooling.md +21 -10
  82. package/docs/rules/tex/required-section.md +73 -0
  83. package/docs/rules/tex/template.md +62 -0
  84. package/docs/rules/tex/venue-leftover.md +71 -0
  85. package/docs/rules.md +70 -48
  86. package/docs/testing.md +17 -4
  87. package/docs/texlive-install-decision.md +1 -1
  88. package/docs/toolchain.md +2 -2
  89. package/eslint.config.mjs +155 -15
  90. package/fixtures/build-e2e/acmart/expect.json +26 -0
  91. package/fixtures/build-e2e/aidc/PIPELINE-STATUS.md +3 -0
  92. package/fixtures/build-e2e/aidc/expect.json +14 -0
  93. package/fixtures/build-e2e/aidc/paper.tex +46 -0
  94. package/fixtures/build-e2e/aidc/paperlint.json +1 -0
  95. package/fixtures/build-e2e/broken/expect.json +11 -0
  96. package/fixtures/build-e2e/cite/expect.json +27 -0
  97. package/fixtures/build-e2e/empty/expect.json +12 -0
  98. package/fixtures/build-e2e/fallback/expect.json +53 -0
  99. package/fixtures/build-e2e/guards/expect.json +6 -0
  100. package/fixtures/build-e2e/no-source/expect.json +10 -0
  101. package/fixtures/build-e2e/unbalanced/expect.json +60 -0
  102. package/fixtures/pdf-facts/README.md +1 -1
  103. package/fixtures/real-markdown-paper/baseline.ts +1 -1
  104. package/fixtures/render-paper/build-clean.sh +3 -5
  105. package/package.json +13 -2
  106. package/{skills/submit-paper/references/venues → presets}/acm-sigconf.jsonc +4 -1
  107. package/{skills/submit-paper/references/venues → presets}/agenticdev.jsonc +4 -0
  108. package/presets/aidc.jsonc +73 -0
  109. package/{skills/submit-paper/references/venues → presets}/aisec.jsonc +4 -0
  110. package/presets/ieee-conference.jsonc +66 -0
  111. package/{skills/submit-paper/references/venues → presets}/realm.jsonc +8 -1
  112. package/{skills/submit-paper/references/venues → presets/tex}/paper-guards.tex +1 -1
  113. package/{skills/submit-paper/references/venues → presets}/venue-profile.schema.json +30 -2
  114. package/scripts/check.harness.ts +64 -10
  115. package/scripts/check.test.ts +60 -13
  116. package/scripts/check.ts +83 -30
  117. package/scripts/test.test.ts +1 -1
  118. package/scripts/test.ts +1 -1
  119. package/skills/paper-pipeline/PIPELINE-MAP.md +2 -2
  120. package/skills/paper-pipeline/SKILL.md +2 -2
  121. package/skills/paper-pipeline/SKILL.md.spec.ts +1 -1
  122. package/skills/paper-pipeline/scripts/consumer.d.mts +0 -2
  123. package/skills/paper-pipeline/scripts/consumer.mjs +1 -15
  124. package/skills/render-paper/SKILL.md +2 -2
  125. package/skills/render-paper/SKILL.md.spec.ts +1 -1
  126. package/skills/render-paper/check-render.sh +16 -63
  127. package/skills/render-paper/render-paper.harness.mjs +17 -59
  128. package/src/adapters/banal/index.test.ts +1 -1
  129. package/src/adapters/banal/invocation.ts +1 -1
  130. package/src/adapters/banal/output.test.ts +1 -1
  131. package/src/adapters/banal/run.test.ts +1 -1
  132. package/src/adapters/banal/xml.test.ts +1 -1
  133. package/src/adapters/latex/document-class.ts +147 -0
  134. package/src/adapters/latex/hidden.ts +70 -0
  135. package/src/adapters/latex/index.test.ts +361 -0
  136. package/src/adapters/latex/index.ts +55 -0
  137. package/src/adapters/latex/nodes.ts +141 -0
  138. package/src/adapters/latex/outline.ts +64 -0
  139. package/src/adapters/latex/parse.ts +136 -0
  140. package/src/adapters/latex/rendered.ts +59 -0
  141. package/src/adapters/pdfjs/fill.test.ts +1 -1
  142. package/src/adapters/pdfjs/fill.ts +18 -10
  143. package/src/build.harness.mjs +5 -7
  144. package/src/build.ts +4 -4
  145. package/src/cli.harness.mjs +1 -1
  146. package/src/cli.test.ts +2 -8
  147. package/src/cli.ts +33 -8
  148. package/src/domain/tex-document.test.ts +80 -0
  149. package/src/domain/tex-document.ts +110 -0
  150. package/src/engine.harness.mjs +1 -1
  151. package/src/facts-file.test.ts +4 -7
  152. package/src/facts-file.ts +2 -2
  153. package/src/latex-loop.harness.ts +1 -1
  154. package/src/new-paper.test.ts +28 -3
  155. package/src/new-paper.ts +8 -0
  156. package/src/package-dirs.ts +17 -0
  157. package/src/paper-settings-commands.test.ts +6 -1
  158. package/src/ports/latex.ts +29 -0
  159. package/src/presets.test.ts +65 -4
  160. package/src/presets.ts +38 -15
  161. package/src/rule-cases.test.ts +474 -0
  162. package/src/rule-docs.test.ts +159 -0
  163. package/src/tex-requirements.harness.ts +13 -23
  164. package/src/tex-requirements.ts +35 -8
  165. package/src/tex-venue-rules.test.ts +659 -0
  166. package/src/tex-venue-rules.ts +429 -0
  167. package/src/toolchain.harness.mjs +1 -1
  168. package/src/venue-rules.test.ts +96 -4
  169. package/src/venue-rules.ts +1 -1
  170. package/templates/paper/paper.tex +2 -1
  171. /package/{skills/submit-paper/references/venues → presets/tex}/agenticdev.tex +0 -0
  172. /package/{skills/submit-paper/references/venues → presets}/tex-base.jsonc +0 -0
@@ -0,0 +1,474 @@
1
+ /**
2
+ * Every rule paperlint registers runs through REAL ESLint, the way `paperlint lint` runs it: a small
3
+ * paper tree in a temp directory, `run(["lint", "--json"])` — the CLI's own config, the `tex/latex`
4
+ * language and its `raw`, the `root` visitors, `loc`, message templating, the `files` wiring. The
5
+ * unit tests beside each rule call its `create` directly and cannot see that boundary; the TeX e2e
6
+ * sees it only for the papers a real pdflatex can build.
7
+ *
8
+ * The rule set is read off the registry (`rulePlugins`, as `rule-docs.test.ts` does). Every rule
9
+ * needs a case that REPORTS (its id, severity and line) and one that stays SILENT, or an entry in
10
+ * `EXEMPT` with the reason; a case for a rule that is not registered fails too. A `_build/` file a
11
+ * rule reads is planted as the build would write it — no pdflatex, no network.
12
+ */
13
+ import { readFileSync } from "node:fs";
14
+ import { dirname, join } from "node:path";
15
+ import { fileURLToPath } from "node:url";
16
+ import { describe, expect, it } from "vitest";
17
+ import { z } from "zod";
18
+ import { run, rulePlugins } from "./cli.ts";
19
+ import { sha256Hex } from "./domain/sha256.ts";
20
+ import { bibHash } from "./references.ts";
21
+ import { useTempDir, writeTree } from "../test/support.ts";
22
+
23
+ const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
24
+ const fixture = (p: string): string =>
25
+ readFileSync(join(ROOT, "fixtures", p), "utf8");
26
+
27
+ const P = "papers/p";
28
+ const STATUS = "---\nstages: []\n---\n# PIPELINE-STATUS\n";
29
+ const tex = (body: string, cls = "\\documentclass{article}"): string =>
30
+ `${cls}\n\\begin{document}\n${body}\n\\end{document}\n`;
31
+
32
+ /** A paper: its scorecard and paper.tex, and whatever else the case adds. */
33
+ const paper = (
34
+ paperTex: string,
35
+ extra: Readonly<Record<string, string>> = {},
36
+ ): Record<string, string> => ({
37
+ [`${P}/PIPELINE-STATUS.md`]: STATUS,
38
+ [`${P}/paper.tex`]: paperTex,
39
+ ...extra,
40
+ });
41
+
42
+ // ── the venue rules' inputs: a PDF and the facts `paperlint build` would write about it ─────────
43
+
44
+ const PDF = "%PDF-1.5 a stand-in for the built PDF";
45
+ const AGENTICDEV = { extends: "paperlint:agenticdev", kind: "short" };
46
+ const ACM_TEX = tex("Text.", "\\documentclass[sigconf]{acmart}");
47
+
48
+ /** The facts of a short agenticdev paper that meets every number in its preset. */
49
+ const goodFacts = (): Record<string, unknown> => ({
50
+ schema: 2,
51
+ pdf: "paper.pdf",
52
+ pdf_sha256: sha256Hex(new TextEncoder().encode(PDF)),
53
+ venue: "agenticdev",
54
+ kind: "short",
55
+ npages: 7,
56
+ fonts_source: "pdfjs-drawn",
57
+ fonts: ["LinLibertineT", "LinBiolinumTB"].map((name) => ({
58
+ name,
59
+ type: "Type 1",
60
+ embedded: true,
61
+ program: "Type1",
62
+ })),
63
+ last_page: { kind: "measured", columns_pt: [600, 590] },
64
+ last_page_cols_pt: [600, 590],
65
+ geometry_source: "banal",
66
+ page_w_in: 8.5,
67
+ page_h_in: 11,
68
+ columns: 2,
69
+ body_pt: 9.3,
70
+ ref_pt: 7.3,
71
+ body_pages: 5,
72
+ ref_pages: 2,
73
+ appendix_pages: 0,
74
+ pages_by_type: { body: 5, bib: 2 },
75
+ });
76
+
77
+ /** An agenticdev paper, built: its settings, the PDF, and facts with `patch` applied. */
78
+ const built = (
79
+ patch: Record<string, unknown> = {},
80
+ settings: Record<string, unknown> = AGENTICDEV,
81
+ ): Record<string, string> =>
82
+ paper(ACM_TEX, {
83
+ [`${P}/paperlint.json`]: JSON.stringify(settings),
84
+ [`${P}/paper.pdf`]: PDF,
85
+ [`${P}/_build/paper.facts.json`]: JSON.stringify({
86
+ ...goodFacts(),
87
+ ...patch,
88
+ }),
89
+ });
90
+
91
+ // ── the reference rules' input: refs.bib, and the verdicts `paperlint build` would record ───────
92
+
93
+ const BIB =
94
+ "@article{a,\n title = {A},\n author = {Doe, J.},\n year = {2026},\n doi = {10.1/x}\n}\n";
95
+ const sha = bibHash({ source: "refs.bib", text: BIB, offset: 0 });
96
+ /** A paper citing refs.bib, with its references record (`status`, `entries`) or none. */
97
+ const cited = (
98
+ body: Record<string, unknown> | null,
99
+ bibSha = sha,
100
+ ): Record<string, string> =>
101
+ paper(tex("See~\\cite{a}.\n\\bibliography{refs}"), {
102
+ [`${P}/refs.bib`]: BIB,
103
+ ...(body === null
104
+ ? {}
105
+ : {
106
+ [`${P}/_build/references.json`]: JSON.stringify({
107
+ schema: 1,
108
+ bib: { source: "refs.bib", sha256: bibSha },
109
+ ...body,
110
+ }),
111
+ }),
112
+ });
113
+ /** A paper whose bibliography is written inline, by `filecontents*`, as `bib/reachable-entry` reads it. */
114
+ const inlineBib = (entry: string): string =>
115
+ `\\documentclass{article}\n\\begin{filecontents*}{refs.bib}\n${entry}\n\\end{filecontents*}\n\\begin{document}\nSee~\\cite{a}.\n\\bibliography{refs}\n\\end{document}\n`;
116
+ const checked = (exists: string, authors: string) => ({
117
+ status: "checked",
118
+ entries: [{ key: "a", exists, authors }],
119
+ });
120
+
121
+ // ── an AIDC paper, for the LaTeX venue rules ──────────────────────────────────────────────────
122
+
123
+ const AIDC = {
124
+ [`${P}/paperlint.json`]: JSON.stringify({
125
+ extends: "paperlint:aidc",
126
+ kind: "short",
127
+ }),
128
+ };
129
+ const IEEE = "\\documentclass[conference,compsoc]{IEEEtran}";
130
+ const STATEMENT = "\\section*{LLM Usage Statement}\nNone.";
131
+ const aidc = (body: string, cls = IEEE) =>
132
+ paper(tex(`\\section{Introduction}\n${body}\n${STATEMENT}`, cls), AIDC);
133
+
134
+ // ── the case table ─────────────────────────────────────────────────────────────────────────────
135
+
136
+ interface Reports {
137
+ readonly tree: Record<string, string>;
138
+ /** The file the finding is on, relative to the project. */
139
+ readonly file: string;
140
+ readonly severity: 1 | 2;
141
+ readonly line: number;
142
+ }
143
+ interface RuleCases {
144
+ readonly reports: Reports;
145
+ /** A tree on which the rule says nothing, on any file. */
146
+ readonly silent: Record<string, string>;
147
+ }
148
+
149
+ const TEX_FILE = `${P}/paper.tex`;
150
+ const STATUS_FILE = `${P}/PIPELINE-STATUS.md`;
151
+ const SHIPPED_STATUS =
152
+ "---\nstages:\n - stage: submitted\n date: 2026-07-22\n pdf: versions/a.pdf\n bytes: 1\n---\n# PIPELINE-STATUS\n";
153
+ const onPdf = (tree: Record<string, string>, line = 1) => ({
154
+ tree,
155
+ file: TEX_FILE,
156
+ severity: 2 as const,
157
+ line,
158
+ });
159
+
160
+ const CASES: Readonly<Record<string, RuleCases>> = {
161
+ "paper/stages": {
162
+ reports: {
163
+ tree: { ...paper(tex("x")), [STATUS_FILE]: SHIPPED_STATUS },
164
+ file: STATUS_FILE,
165
+ severity: 2,
166
+ line: 1,
167
+ },
168
+ silent: paper(tex("x")),
169
+ },
170
+ "paper/source": {
171
+ reports: {
172
+ tree: { ...paper(tex("x")), [STATUS_FILE]: SHIPPED_STATUS },
173
+ file: STATUS_FILE,
174
+ severity: 2,
175
+ line: 1,
176
+ },
177
+ silent: paper(tex("x")),
178
+ },
179
+ "paper/research-question": {
180
+ reports: {
181
+ tree: {
182
+ [STATUS_FILE]: fixture(
183
+ "paper-research-question/shipped-no-rq/PIPELINE-STATUS.md",
184
+ ),
185
+ [TEX_FILE]: fixture("paper-research-question/shipped-no-rq/paper.tex"),
186
+ },
187
+ file: TEX_FILE,
188
+ severity: 1,
189
+ line: 1,
190
+ },
191
+ silent: {
192
+ [STATUS_FILE]: fixture(
193
+ "paper-research-question/shipped-with-rq/PIPELINE-STATUS.md",
194
+ ),
195
+ [TEX_FILE]: fixture("paper-research-question/shipped-with-rq/paper.tex"),
196
+ },
197
+ },
198
+ "paper/section-word": {
199
+ reports: {
200
+ tree: paper(tex("As in §2.")),
201
+ file: TEX_FILE,
202
+ severity: 1,
203
+ line: 3,
204
+ },
205
+ silent: paper(tex("As in Section~2.")),
206
+ },
207
+ "paper/leading-zero": {
208
+ reports: {
209
+ tree: paper(tex("We found $p < .05$.")),
210
+ file: TEX_FILE,
211
+ severity: 1,
212
+ line: 3,
213
+ },
214
+ silent: paper(tex("We found $p < 0.05$.")),
215
+ },
216
+ "paper/figure-ref-style": {
217
+ reports: {
218
+ tree: paper(tex("Fig.~\\ref{f1} and Figure~\\ref{f2}.")),
219
+ file: TEX_FILE,
220
+ severity: 1,
221
+ line: 3,
222
+ },
223
+ silent: paper(tex("Figure~\\ref{f1} and Figure~\\ref{f2}.")),
224
+ },
225
+ "paper/author-list": {
226
+ reports: {
227
+ tree: cited(checked("true", "mismatch")),
228
+ file: TEX_FILE,
229
+ severity: 2,
230
+ line: 1,
231
+ },
232
+ silent: cited(checked("true", "match")),
233
+ },
234
+ "paper/cite-exists": {
235
+ reports: {
236
+ tree: cited(checked("false", "match")),
237
+ file: TEX_FILE,
238
+ severity: 2,
239
+ line: 1,
240
+ },
241
+ silent: cited(checked("true", "match")),
242
+ },
243
+ "paper/refs-checked": {
244
+ reports: { tree: cited(null), file: TEX_FILE, severity: 1, line: 1 },
245
+ silent: cited(checked("true", "match")),
246
+ },
247
+ "paper/refs-fresh": {
248
+ reports: {
249
+ tree: cited(checked("true", "match"), "0".repeat(64)),
250
+ file: TEX_FILE,
251
+ severity: 2,
252
+ line: 1,
253
+ },
254
+ silent: cited(checked("true", "match")),
255
+ },
256
+ "review/frontmatter": {
257
+ reports: {
258
+ tree: paper(tex("x"), {
259
+ [`${P}/reviews/r1.md`]:
260
+ "---\nfindings:\n - id: 1\n status: open\n---\n# Review\n",
261
+ }),
262
+ file: `${P}/reviews/r1.md`,
263
+ severity: 2,
264
+ line: 1,
265
+ },
266
+ silent: paper(tex("x"), {
267
+ [`${P}/reviews/r1.md`]:
268
+ "---\nfindings:\n - id: 1\n status: fixed\n---\n# Review\n",
269
+ }),
270
+ },
271
+ "sibling/frontmatter": {
272
+ reports: {
273
+ tree: paper(tex("x"), {
274
+ [`${P}/siblings/s.md`]: "---\nread: skimmed\n---\n# A sibling\n",
275
+ }),
276
+ file: `${P}/siblings/s.md`,
277
+ severity: 1,
278
+ line: 1,
279
+ },
280
+ silent: paper(tex("x"), {
281
+ [`${P}/siblings/s.md`]: "---\nread: full\n---\n# A sibling\n",
282
+ }),
283
+ },
284
+ "tex/future-promise": {
285
+ reports: {
286
+ tree: paper(fixture("tex-build/defect.tex")),
287
+ file: TEX_FILE,
288
+ severity: 1,
289
+ line: 15,
290
+ },
291
+ silent: paper(fixture("tex-build/clean.tex")),
292
+ },
293
+ "tex/acm-frontmatter-override": {
294
+ reports: {
295
+ tree: paper(fixture("tex-build/frontmatter-defect.tex")),
296
+ file: TEX_FILE,
297
+ severity: 2,
298
+ line: 10,
299
+ },
300
+ silent: paper(fixture("tex-build/frontmatter-clean.tex")),
301
+ },
302
+ "tex/template": {
303
+ reports: {
304
+ tree: aidc("Text.", "\\documentclass[conference]{IEEEtran}"),
305
+ file: TEX_FILE,
306
+ severity: 2,
307
+ line: 1,
308
+ },
309
+ silent: aidc("Text."),
310
+ },
311
+ "tex/required-section": {
312
+ reports: {
313
+ tree: paper(tex("\\section{Introduction}\nText.", IEEE), AIDC),
314
+ file: TEX_FILE,
315
+ severity: 2,
316
+ line: 5,
317
+ },
318
+ silent: aidc("Text."),
319
+ },
320
+ "tex/venue-leftover": {
321
+ reports: {
322
+ tree: aidc("First written for AISec."),
323
+ file: TEX_FILE,
324
+ severity: 1,
325
+ line: 4,
326
+ },
327
+ silent: aidc("Text."),
328
+ },
329
+ "bib/reachable-entry": {
330
+ reports: {
331
+ tree: paper(inlineBib("@book{a,\n title = {A},\n year = {2026}\n}")),
332
+ file: TEX_FILE,
333
+ severity: 1,
334
+ line: 3,
335
+ },
336
+ silent: paper(inlineBib(BIB)),
337
+ },
338
+ "pdf/fresh": {
339
+ reports: onPdf(built({ pdf_sha256: "0".repeat(64) })),
340
+ silent: built(),
341
+ },
342
+ "pdf/profile": {
343
+ reports: onPdf(built({}, { extends: "paperlint:nope" })),
344
+ silent: built(),
345
+ },
346
+ "pdf/measured": {
347
+ reports: { ...onPdf(built({}, { kind: "short" })), severity: 1 },
348
+ silent: built(),
349
+ },
350
+ "pdf/fonts": {
351
+ reports: onPdf(
352
+ built({
353
+ fonts: [
354
+ { name: "CMR10", type: "Type 1", embedded: true, program: "Type1" },
355
+ ],
356
+ }),
357
+ ),
358
+ silent: built(),
359
+ },
360
+ "pdf/geometry": {
361
+ reports: onPdf(built({ page_w_in: 8.264 })),
362
+ silent: built(),
363
+ },
364
+ "pdf/limits": {
365
+ reports: onPdf(
366
+ built({ body_pages: 9, pages_by_type: { body: 9, bib: 2 } }),
367
+ ),
368
+ silent: built(),
369
+ },
370
+ "pdf/body-size": {
371
+ reports: { ...onPdf(built({ body_pt: 12 })), severity: 1 },
372
+ silent: built(),
373
+ },
374
+ "pdf/last-page-balance": {
375
+ reports: onPdf(
376
+ built(
377
+ { last_page: { kind: "measured", columns_pt: [621.5, 264.8] } },
378
+ { ...AGENTICDEV, rules: { "pdf/last-page-balance": "error" } },
379
+ ),
380
+ ),
381
+ silent: built(
382
+ {},
383
+ { ...AGENTICDEV, rules: { "pdf/last-page-balance": "error" } },
384
+ ),
385
+ },
386
+ };
387
+
388
+ /**
389
+ * Rules whose honest case needs what this test cannot give, each with the reason. Checked against
390
+ * the registry like the cases are: an entry for a rule that no longer exists fails.
391
+ */
392
+ const EXEMPT: Readonly<Record<string, string>> = {};
393
+
394
+ // ── the run ─────────────────────────────────────────────────────────────────────────────────────
395
+
396
+ const LintResults = z.array(
397
+ z.looseObject({
398
+ filePath: z.string(),
399
+ messages: z.array(
400
+ z.looseObject({
401
+ ruleId: z.string().nullable(),
402
+ severity: z.number(),
403
+ line: z.number().optional(),
404
+ message: z.string(),
405
+ }),
406
+ ),
407
+ }),
408
+ );
409
+ type Message = z.infer<typeof LintResults>[number]["messages"][number] & {
410
+ readonly file: string;
411
+ };
412
+
413
+ /** `paperlint lint --json` over the tree, in-process through the CLI: every message, by file. */
414
+ async function lintTree(tree: Record<string, string>): Promise<Message[]> {
415
+ const dir = writeTree(useTempDir("paperlint-rule-cases-"), {
416
+ "package.json": '{"name":"consumer","private":true}',
417
+ ...tree,
418
+ });
419
+ const out: string[] = [];
420
+ await run(["lint", "--json"], {
421
+ cwd: dir,
422
+ log: (...a: unknown[]) => out.push(a.join(" ")),
423
+ err: () => undefined,
424
+ });
425
+ return LintResults.parse(JSON.parse(out.join("\n"))).flatMap((r) =>
426
+ r.messages.map((m) => ({ ...m, file: r.filePath.slice(dir.length + 1) })),
427
+ );
428
+ }
429
+
430
+ /** Every rule id the registry holds. */
431
+ const registered = Object.entries(rulePlugins()).flatMap(([plugin, p]) =>
432
+ Object.keys(p.rules ?? {}).map((rule) => `${plugin}/${rule}`),
433
+ );
434
+
435
+ describe("every registered rule has cases, and every case names a registered rule", () => {
436
+ it.each(registered)(
437
+ "%s has a reporting and a silent case, or a reason it cannot",
438
+ (id) => {
439
+ expect(
440
+ id in CASES || id in EXEMPT,
441
+ `${id}: no cases in rule-cases.test.ts`,
442
+ ).toBe(true);
443
+ },
444
+ );
445
+
446
+ it("no case or exemption names a rule the registry does not hold", () => {
447
+ expect(
448
+ [...Object.keys(CASES), ...Object.keys(EXEMPT)].filter(
449
+ (id) => !registered.includes(id),
450
+ ),
451
+ ).toEqual([]);
452
+ });
453
+ });
454
+
455
+ describe.each(Object.entries(CASES))("%s, through real ESLint", (id, c) => {
456
+ it(`reports on ${c.reports.file}:${String(c.reports.line)} at severity ${String(c.reports.severity)}`, async () => {
457
+ const got = (await lintTree(c.reports.tree)).filter((m) => m.ruleId === id);
458
+ expect(
459
+ got.some(
460
+ (m) =>
461
+ m.file === c.reports.file &&
462
+ m.line === c.reports.line &&
463
+ m.severity === c.reports.severity,
464
+ ),
465
+ JSON.stringify(got),
466
+ ).toBe(true);
467
+ });
468
+
469
+ it("stays silent on a conforming paper", async () => {
470
+ expect((await lintTree(c.silent)).filter((m) => m.ruleId === id)).toEqual(
471
+ [],
472
+ );
473
+ });
474
+ });
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Every rule paperlint ships has a page: `meta.docs.url` names `docs/rules/<group>/<rule>.md` on the
3
+ * default branch, and that file exists. The rule set is read off paperlint's own config
4
+ * (`rulePlugins`), so a new rule is checked the day it is registered.
5
+ *
6
+ * The rules older than this convention are listed in `AWAITING_PAGE` until #131 writes their
7
+ * pages. The list only shrinks: a listed rule that now has a page fails here too, so the list is
8
+ * trimmed in the same change.
9
+ */
10
+ import { existsSync } from "node:fs";
11
+ import { dirname, join } from "node:path";
12
+ import { fileURLToPath } from "node:url";
13
+ import { describe, expect, it } from "vitest";
14
+ import { rulePlugins } from "./cli.ts";
15
+ import { fieldOf } from "./domain/record.ts";
16
+
17
+ const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
18
+ const PAGES = "https://github.com/zernie/paperlint/blob/main/";
19
+
20
+ /** Rules shipped before per-rule pages, each waiting for its page in #131. Only ever shrinks. */
21
+ const AWAITING_PAGE: ReadonlySet<string> = new Set([
22
+ "bib/reachable-entry",
23
+ "paper/author-list",
24
+ "paper/cite-exists",
25
+ "paper/figure-ref-style",
26
+ "paper/leading-zero",
27
+ "paper/refs-checked",
28
+ "paper/refs-fresh",
29
+ "paper/research-question",
30
+ "paper/section-word",
31
+ "paper/source",
32
+ "paper/stages",
33
+ "pdf/body-size",
34
+ "pdf/fonts",
35
+ "pdf/fresh",
36
+ "pdf/geometry",
37
+ "pdf/last-page-balance",
38
+ "pdf/limits",
39
+ "pdf/measured",
40
+ "pdf/profile",
41
+ "review/frontmatter",
42
+ "sibling/frontmatter",
43
+ "tex/acm-frontmatter-override",
44
+ "tex/future-promise",
45
+ ]);
46
+
47
+ /** A rule's `meta.docs.url`, or null when it has none. */
48
+ const urlOf = (rule: unknown): string | null => {
49
+ const url = fieldOf(fieldOf(fieldOf(rule, "meta"), "docs"), "url");
50
+ return typeof url === "string" ? url : null;
51
+ };
52
+
53
+ /**
54
+ * What is wrong with the rules' pages, one line per rule: a page URL that names no file under
55
+ * `docs/rules/`, a rule with no page that is not awaiting one, and one awaiting a page it has.
56
+ */
57
+ export function pageProblems(
58
+ rules: Readonly<Record<string, unknown>>,
59
+ awaiting: ReadonlySet<string>,
60
+ exists: (repoPath: string) => boolean,
61
+ ): string[] {
62
+ return Object.entries(rules).flatMap(([id, rule]) => {
63
+ const url = urlOf(rule);
64
+ if (url === null)
65
+ return awaiting.has(id)
66
+ ? []
67
+ : [
68
+ `${id}: no meta.docs.url — write docs/rules/${id}.md and point at it`,
69
+ ];
70
+ if (awaiting.has(id))
71
+ return [`${id}: has a page now — take it off AWAITING_PAGE`];
72
+ const path = url.startsWith(PAGES) ? url.slice(PAGES.length) : null;
73
+ return path !== null && path.startsWith("docs/rules/") && exists(path)
74
+ ? []
75
+ : [`${id}: meta.docs.url ${url} names no file under docs/rules/`];
76
+ });
77
+ }
78
+
79
+ /** Every rule paperlint's config registers, by `<plugin>/<rule>`. */
80
+ const shipped = (): Record<string, unknown> =>
81
+ Object.fromEntries(
82
+ Object.entries(rulePlugins()).flatMap(([plugin, p]) =>
83
+ Object.entries(p.rules ?? {}).map(([name, rule]) => [
84
+ `${plugin}/${name}`,
85
+ rule,
86
+ ]),
87
+ ),
88
+ );
89
+
90
+ const inRepo = (p: string): boolean => existsSync(join(ROOT, p));
91
+
92
+ describe("every shipped rule has a page", () => {
93
+ it("the rule set is read off the config, and the new rules are in it", () => {
94
+ expect(Object.keys(shipped())).toEqual(
95
+ expect.arrayContaining([
96
+ "tex/template",
97
+ "tex/required-section",
98
+ "tex/venue-leftover",
99
+ ]),
100
+ );
101
+ });
102
+
103
+ it("each page named exists; every rule without one is awaiting it (#131)", () => {
104
+ expect(pageProblems(shipped(), AWAITING_PAGE, inRepo)).toEqual([]);
105
+ });
106
+
107
+ it("every awaiting rule is still shipped — a removed rule leaves the list", () => {
108
+ expect([...AWAITING_PAGE].filter((id) => !(id in shipped()))).toEqual([]);
109
+ });
110
+ });
111
+
112
+ describe("pageProblems — both halves, on planted rules", () => {
113
+ const withPage = (path: string) => ({
114
+ meta: { docs: { url: PAGES + path } },
115
+ });
116
+ const exists = (p: string) => p === "docs/rules/x/ok.md";
117
+
118
+ it("a brand-new rule without a page fails", () => {
119
+ expect(pageProblems({ "x/new": { meta: {} } }, new Set(), exists)).toEqual([
120
+ "x/new: no meta.docs.url — write docs/rules/x/new.md and point at it",
121
+ ]);
122
+ });
123
+
124
+ it("a page that does not exist, outside docs/rules, or not on the default branch fails", () => {
125
+ expect(
126
+ pageProblems(
127
+ {
128
+ "x/gone": withPage("docs/rules/x/gone.md"),
129
+ "x/readme": withPage("README.md"),
130
+ "x/elsewhere": {
131
+ meta: { docs: { url: "https://example.org/x.md" } },
132
+ },
133
+ },
134
+ new Set(),
135
+ exists,
136
+ ),
137
+ ).toHaveLength(3);
138
+ });
139
+
140
+ it("a listed rule that has a page now fails, so the list shrinks", () => {
141
+ expect(
142
+ pageProblems(
143
+ { "x/ok": withPage("docs/rules/x/ok.md") },
144
+ new Set(["x/ok"]),
145
+ exists,
146
+ ),
147
+ ).toEqual(["x/ok: has a page now — take it off AWAITING_PAGE"]);
148
+ });
149
+
150
+ it("a rule with an existing page, and a listed rule without one, pass", () => {
151
+ expect(
152
+ pageProblems(
153
+ { "x/ok": withPage("docs/rules/x/ok.md"), "x/old": {} },
154
+ new Set(["x/old"]),
155
+ exists,
156
+ ),
157
+ ).toEqual([]);
158
+ });
159
+ });
@@ -16,22 +16,12 @@ import {
16
16
  writeFileSync,
17
17
  } from "node:fs";
18
18
  import { tmpdir } from "node:os";
19
- import { dirname, join } from "node:path";
20
- import { fileURLToPath } from "node:url";
21
- import ts from "typescript";
22
- import { z } from "zod";
19
+ import { join } from "node:path";
23
20
  import { createChecker } from "../lib/check.ts";
24
21
 
25
- const HERE = dirname(fileURLToPath(import.meta.url));
26
- const VENUES = join(
27
- HERE,
28
- "..",
29
- "skills",
30
- "submit-paper",
31
- "references",
32
- "venues",
33
- );
34
22
  const R = await import("./tex-requirements.ts");
23
+ const { parseTemplate } = await import("./adapters/latex/index.ts");
24
+ const VENUES = (await import("./package-dirs.ts")).presetsDir();
35
25
 
36
26
  const check = createChecker();
37
27
  const throws = (fn: () => unknown): string => {
@@ -70,15 +60,15 @@ check(
70
60
  "cm-super" in base.packages,
71
61
  );
72
62
 
73
- // The whole profile, parsed the way the other harnesses read these files — never a line of text.
74
- const TemplateField = z.object({ template: z.unknown().optional() });
75
- const templateOf = (v: string): unknown =>
76
- TemplateField.parse(
77
- ts.parseConfigFileTextToJson(
78
- v,
79
- readFileSync(join(VENUES, `${v}.jsonc`), "utf8"),
80
- ).config,
81
- ).template;
63
+ // The class each profile's template names, as the parser reads it — never a line of text.
64
+ const presetOf = (v: string) =>
65
+ R.parsePreset(
66
+ readFileSync(join(VENUES, `${v}.jsonc`), "utf8"),
67
+ `${v}.jsonc`,
68
+ VENUES,
69
+ );
70
+ const templateOf = (v: string): string | undefined =>
71
+ parseTemplate(presetOf(v).template ?? "")?.cls;
82
72
  const acm = venues.filter((v) => templateOf(v) === "acmart");
83
73
  check(
84
74
  "at least one acmart venue is shipped (else the next checks see nothing)",
@@ -108,7 +98,7 @@ for (const v of acm) {
108
98
  // declares the template's packages, so this holds by construction; the check stays for any two
109
99
  // standalone presets that name the same template.
110
100
  const byTemplate = new Map<unknown, string[]>();
111
- for (const v of venues) {
101
+ for (const v of venues.filter((x) => presetOf(x).extends === null)) {
112
102
  const t = templateOf(v);
113
103
  byTemplate.set(t, [...(byTemplate.get(t) ?? []), v]);
114
104
  }