paperlint 4.1.0 → 5.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 (202) hide show
  1. package/CLAUDE.md +26 -2
  2. package/CONTRIBUTING.md +7 -0
  3. package/README.md +15 -4
  4. package/action.harness.mjs +54 -36
  5. package/action.yml +12 -27
  6. package/dist/adapters/latex/document-class.d.ts +6 -5
  7. package/dist/adapters/latex/document-class.d.ts.map +1 -1
  8. package/dist/adapters/latex/document-class.js +18 -10
  9. package/dist/adapters/latex/document-class.js.map +1 -1
  10. package/dist/adapters/latex/includes.d.ts +16 -0
  11. package/dist/adapters/latex/includes.d.ts.map +1 -0
  12. package/dist/adapters/latex/includes.js +48 -0
  13. package/dist/adapters/latex/includes.js.map +1 -0
  14. package/dist/adapters/latex/index.d.ts +9 -5
  15. package/dist/adapters/latex/index.d.ts.map +1 -1
  16. package/dist/adapters/latex/index.js +8 -1
  17. package/dist/adapters/latex/index.js.map +1 -1
  18. package/dist/adapters/latex/nodes.d.ts +5 -1
  19. package/dist/adapters/latex/nodes.d.ts.map +1 -1
  20. package/dist/adapters/latex/nodes.js +6 -2
  21. package/dist/adapters/latex/nodes.js.map +1 -1
  22. package/dist/adapters/latex/parse.d.ts.map +1 -1
  23. package/dist/adapters/latex/parse.js +1 -0
  24. package/dist/adapters/latex/parse.js.map +1 -1
  25. package/dist/adapters/latex/prose.d.ts +8 -0
  26. package/dist/adapters/latex/prose.d.ts.map +1 -0
  27. package/dist/adapters/latex/prose.js +353 -0
  28. package/dist/adapters/latex/prose.js.map +1 -0
  29. package/dist/build.d.ts.map +1 -1
  30. package/dist/build.js +7 -3
  31. package/dist/build.js.map +1 -1
  32. package/dist/claim-provenance.d.ts +34 -0
  33. package/dist/claim-provenance.d.ts.map +1 -0
  34. package/dist/claim-provenance.js +146 -0
  35. package/dist/claim-provenance.js.map +1 -0
  36. package/dist/cli.d.ts +2 -8
  37. package/dist/cli.d.ts.map +1 -1
  38. package/dist/cli.js +81 -31
  39. package/dist/cli.js.map +1 -1
  40. package/dist/domain/paper-source.d.ts +58 -0
  41. package/dist/domain/paper-source.d.ts.map +1 -0
  42. package/dist/domain/paper-source.js +109 -0
  43. package/dist/domain/paper-source.js.map +1 -0
  44. package/dist/domain/sentences.d.ts +26 -0
  45. package/dist/domain/sentences.d.ts.map +1 -0
  46. package/dist/domain/sentences.js +48 -0
  47. package/dist/domain/sentences.js.map +1 -0
  48. package/dist/domain/tex-document.d.ts +39 -4
  49. package/dist/domain/tex-document.d.ts.map +1 -1
  50. package/dist/domain/tex-document.js.map +1 -1
  51. package/dist/eslint-rules/latex-language.d.ts +5 -1
  52. package/dist/eslint-rules/latex-language.d.ts.map +1 -1
  53. package/dist/eslint-rules/latex-language.js +6 -3
  54. package/dist/eslint-rules/latex-language.js.map +1 -1
  55. package/dist/eslint-rules/paper-context.d.ts +20 -0
  56. package/dist/eslint-rules/paper-context.d.ts.map +1 -0
  57. package/dist/eslint-rules/paper-context.js +44 -0
  58. package/dist/eslint-rules/paper-context.js.map +1 -0
  59. package/dist/eslint-rules/paper-typography.d.ts +8 -1
  60. package/dist/eslint-rules/paper-typography.d.ts.map +1 -1
  61. package/dist/eslint-rules/paper-typography.js +85 -19
  62. package/dist/eslint-rules/paper-typography.js.map +1 -1
  63. package/dist/eslint-rules/tex-build.d.ts.map +1 -1
  64. package/dist/eslint-rules/tex-build.js +7 -1
  65. package/dist/eslint-rules/tex-build.js.map +1 -1
  66. package/dist/init.d.ts +4 -19
  67. package/dist/init.d.ts.map +1 -1
  68. package/dist/init.js +24 -29
  69. package/dist/init.js.map +1 -1
  70. package/dist/package-dirs.d.ts +2 -0
  71. package/dist/package-dirs.d.ts.map +1 -1
  72. package/dist/package-dirs.js +8 -0
  73. package/dist/package-dirs.js.map +1 -1
  74. package/dist/paper-files.d.ts +2 -2
  75. package/dist/paper-includes.d.ts +17 -0
  76. package/dist/paper-includes.d.ts.map +1 -0
  77. package/dist/paper-includes.js +51 -0
  78. package/dist/paper-includes.js.map +1 -0
  79. package/dist/ports/latex.d.ts +12 -1
  80. package/dist/ports/latex.d.ts.map +1 -1
  81. package/dist/register.d.ts +40 -0
  82. package/dist/register.d.ts.map +1 -0
  83. package/dist/register.js +134 -0
  84. package/dist/register.js.map +1 -0
  85. package/dist/rules-config.d.ts +1 -0
  86. package/dist/rules-config.d.ts.map +1 -1
  87. package/dist/rules-config.js.map +1 -1
  88. package/dist/structure.js +1 -1
  89. package/dist/structure.js.map +1 -1
  90. package/dist/tex-paper.d.ts +33 -0
  91. package/dist/tex-paper.d.ts.map +1 -0
  92. package/dist/tex-paper.js +103 -0
  93. package/dist/tex-paper.js.map +1 -0
  94. package/dist/tex-venue-rules.d.ts +7 -2
  95. package/dist/tex-venue-rules.d.ts.map +1 -1
  96. package/dist/tex-venue-rules.js +31 -17
  97. package/dist/tex-venue-rules.js.map +1 -1
  98. package/docs/configuration.md +5 -4
  99. package/docs/install.md +8 -8
  100. package/docs/prior-art/paper-folder-scaffolding.md +4 -7
  101. package/docs/rules/tex/claim-provenance.md +83 -0
  102. package/docs/rules/tex/register.md +77 -0
  103. package/docs/rules/tex/required-section.md +3 -1
  104. package/docs/rules/tex/template.md +12 -0
  105. package/docs/rules/tex/venue-leftover.md +4 -2
  106. package/docs/rules.md +17 -10
  107. package/eslint-rules/latex-language.ts +10 -4
  108. package/eslint-rules/paper-context.test.ts +47 -0
  109. package/eslint-rules/paper-context.ts +57 -0
  110. package/eslint-rules/paper-typography.test.mjs +48 -1
  111. package/eslint-rules/paper-typography.ts +139 -24
  112. package/eslint-rules/tex-build.ts +8 -1
  113. package/eslint.config.mjs +1 -0
  114. package/fixtures/real-markdown-paper/baseline.json +1 -2
  115. package/fixtures/real-markdown-paper/baseline.ts +8 -7
  116. package/package.json +2 -1
  117. package/scripts/check.ts +2 -2
  118. package/scripts/eslint-report-guard.mjs +5 -2
  119. package/scripts/harness-api.frozen.json +0 -1
  120. package/skills/cold-read-diff/SKILL.md +31 -10
  121. package/skills/cold-read-diff/SKILL.md.spec.ts +30 -9
  122. package/skills/draft-paper/SKILL.md +2 -2
  123. package/skills/draft-paper/SKILL.md.spec.ts +1 -1
  124. package/skills/grade-paper-writing/SKILL.md +109 -44
  125. package/skills/grade-paper-writing/SKILL.md.spec.ts +108 -43
  126. package/skills/harden-paper/SKILL.md +35 -36
  127. package/skills/harden-paper/SKILL.md.spec.ts +34 -35
  128. package/skills/paper-adversarial-review/SKILL.md +2 -2
  129. package/skills/paper-adversarial-review/SKILL.md.spec.ts +1 -1
  130. package/skills/paper-pipeline/SKILL.md +3 -3
  131. package/skills/paper-pipeline/SKILL.md.spec.ts +2 -2
  132. package/skills/paper-pipeline/grade-paper-writing-ablation.eval.mjs +6 -6
  133. package/skills/paper-pipeline/references/pipeline-status-template.md +2 -16
  134. package/skills/paper-pipeline/references/writing-craft.md +53 -18
  135. package/skills/pc-panel-review/SKILL.md +36 -11
  136. package/skills/pc-panel-review/SKILL.md.spec.ts +35 -10
  137. package/src/adapters/latex/document-class.ts +20 -12
  138. package/src/adapters/latex/includes.ts +74 -0
  139. package/src/adapters/latex/index.test.ts +183 -1
  140. package/src/adapters/latex/index.ts +14 -4
  141. package/src/adapters/latex/nodes.ts +11 -2
  142. package/src/adapters/latex/parse.ts +1 -0
  143. package/src/adapters/latex/prose.ts +484 -0
  144. package/src/build.harness.mjs +4 -2
  145. package/src/build.test.ts +18 -1
  146. package/src/build.ts +7 -3
  147. package/src/claim-provenance.test.ts +352 -0
  148. package/src/claim-provenance.ts +217 -0
  149. package/src/cli.harness.mjs +22 -54
  150. package/src/cli.test.ts +2 -18
  151. package/src/cli.ts +92 -36
  152. package/src/domain/paper-source.test.ts +156 -0
  153. package/src/domain/paper-source.ts +233 -0
  154. package/src/domain/sentences.ts +67 -0
  155. package/src/domain/tex-document.ts +36 -3
  156. package/src/init.test.ts +33 -13
  157. package/src/init.ts +25 -48
  158. package/src/linted-set.test.ts +11 -1
  159. package/src/new-paper.harness.mjs +1 -5
  160. package/src/package-dirs.ts +9 -0
  161. package/src/paper-files.ts +2 -2
  162. package/src/paper-includes.test.ts +161 -0
  163. package/src/paper-includes.ts +77 -0
  164. package/src/ports/latex.ts +13 -0
  165. package/src/register.test.ts +165 -0
  166. package/src/register.ts +177 -0
  167. package/src/rule-cases.test.ts +43 -23
  168. package/src/rule-docs.test.ts +0 -1
  169. package/src/rules-config.ts +1 -0
  170. package/src/structure.harness.ts +1 -3
  171. package/src/structure.ts +1 -1
  172. package/src/tex-paper.test.ts +148 -0
  173. package/src/tex-paper.ts +161 -0
  174. package/src/tex-venue-rules.test.ts +87 -5
  175. package/src/tex-venue-rules.ts +62 -30
  176. package/templates/paper/PIPELINE-STATUS.md +0 -4
  177. package/dist/action-ref.d.ts +0 -12
  178. package/dist/action-ref.d.ts.map +0 -1
  179. package/dist/action-ref.js +0 -16
  180. package/dist/action-ref.js.map +0 -1
  181. package/dist/eslint-rules/paper-research-question.d.ts +0 -31
  182. package/dist/eslint-rules/paper-research-question.d.ts.map +0 -1
  183. package/dist/eslint-rules/paper-research-question.js +0 -188
  184. package/dist/eslint-rules/paper-research-question.js.map +0 -1
  185. package/eslint-rules/paper-research-question.harness.mjs +0 -212
  186. package/eslint-rules/paper-research-question.ts +0 -199
  187. package/fixtures/paper-research-question/comment-only/PIPELINE-STATUS.md +0 -9
  188. package/fixtures/paper-research-question/comment-only/paper.tex +0 -7
  189. package/fixtures/paper-research-question/declared-not-in-paper/PIPELINE-STATUS.md +0 -10
  190. package/fixtures/paper-research-question/declared-not-in-paper/paper.tex +0 -6
  191. package/fixtures/paper-research-question/draft/PIPELINE-STATUS.md +0 -6
  192. package/fixtures/paper-research-question/draft/paper.tex +0 -2
  193. package/fixtures/paper-research-question/markdown-no-rq/PIPELINE-STATUS.md +0 -9
  194. package/fixtures/paper-research-question/markdown-no-rq/paper.md +0 -4
  195. package/fixtures/paper-research-question/shipped-no-rq/PIPELINE-STATUS.md +0 -12
  196. package/fixtures/paper-research-question/shipped-no-rq/paper.tex +0 -3
  197. package/fixtures/paper-research-question/shipped-with-rq/PIPELINE-STATUS.md +0 -10
  198. package/fixtures/paper-research-question/shipped-with-rq/paper.tex +0 -2
  199. package/skills/harden-paper/fixtures/uncited-assertions-sample.md +0 -43
  200. package/skills/harden-paper/fixtures/uncited-assertions-sample.tex +0 -77
  201. package/src/action-ref.test.ts +0 -26
  202. package/src/action-ref.ts +0 -15
@@ -128,6 +128,9 @@ const AIDC = {
128
128
  };
129
129
  const IEEE = "\\documentclass[conference,compsoc]{IEEEtran}";
130
130
  const STATEMENT = "\\section*{LLM Usage Statement}\nNone.";
131
+ /** Two `\\documentclass` lines behind a switch: `article` for one venue, `second` for this one. */
132
+ const switched = (second: string): string =>
133
+ `\\def\\venue{2}\n\\if\\venue1\n\\documentclass{article}\n\\fi\n\\if\\venue2\n${second}\n\\fi`;
131
134
  const aidc = (body: string, cls = IEEE) =>
132
135
  paper(tex(`\\section{Introduction}\n${body}\n${STATEMENT}`, cls), AIDC);
133
136
 
@@ -147,6 +150,13 @@ interface RuleCases {
147
150
  }
148
151
 
149
152
  const TEX_FILE = `${P}/paper.tex`;
153
+
154
+ /** A body of 200 sentences of 14 words: enough words for `tex/register` to judge a rate. */
155
+ const FORMAL = Array.from(
156
+ { length: 200 },
157
+ () =>
158
+ "The rule reads the body of the paper and counts every sentence it holds.",
159
+ ).join(" ");
150
160
  const STATUS_FILE = `${P}/PIPELINE-STATUS.md`;
151
161
  const SHIPPED_STATUS =
152
162
  "---\nstages:\n - stage: submitted\n date: 2026-07-22\n pdf: versions/a.pdf\n bytes: 1\n---\n# PIPELINE-STATUS\n";
@@ -176,25 +186,6 @@ const CASES: Readonly<Record<string, RuleCases>> = {
176
186
  },
177
187
  silent: paper(tex("x")),
178
188
  },
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
189
  "paper/section-word": {
199
190
  reports: {
200
191
  tree: paper(tex("As in §2.")),
@@ -202,7 +193,13 @@ const CASES: Readonly<Record<string, RuleCases>> = {
202
193
  severity: 1,
203
194
  line: 3,
204
195
  },
205
- silent: paper(tex("As in Section~2.")),
196
+ // A cleveref name definition sets a label, it is not prose.
197
+ silent: paper(
198
+ tex(
199
+ "As in Section~2.",
200
+ "\\documentclass{article}\n\\usepackage{cleveref}\n\\crefname{section}{§}{§§}",
201
+ ),
202
+ ),
206
203
  },
207
204
  "paper/leading-zero": {
208
205
  reports: {
@@ -299,14 +296,15 @@ const CASES: Readonly<Record<string, RuleCases>> = {
299
296
  },
300
297
  silent: paper(fixture("tex-build/frontmatter-clean.tex")),
301
298
  },
299
+ // The class picked behind a TeX switch: two candidates, one of them the venue's or none.
302
300
  "tex/template": {
303
301
  reports: {
304
- tree: aidc("Text.", "\\documentclass[conference]{IEEEtran}"),
302
+ tree: aidc("Text.", switched("\\documentclass[conference]{IEEEtran}")),
305
303
  file: TEX_FILE,
306
304
  severity: 2,
307
- line: 1,
305
+ line: 3,
308
306
  },
309
- silent: aidc("Text."),
307
+ silent: aidc("Text.", switched(IEEE)),
310
308
  },
311
309
  "tex/required-section": {
312
310
  reports: {
@@ -326,6 +324,28 @@ const CASES: Readonly<Record<string, RuleCases>> = {
326
324
  },
327
325
  silent: aidc("Text."),
328
326
  },
327
+ "tex/claim-provenance": {
328
+ reports: {
329
+ tree: paper(tex("Text.\nAgents ignore 42\\% of the rules.")),
330
+ file: TEX_FILE,
331
+ severity: 1,
332
+ line: 4,
333
+ },
334
+ silent: paper(
335
+ tex(
336
+ "Text.\nWe measured that agents ignore 42\\% of the rules.\nThey run on Claude 3 and Python 3.12.",
337
+ ),
338
+ ),
339
+ },
340
+ "tex/register": {
341
+ reports: {
342
+ tree: paper(tex(`${FORMAL} But it holds. So it goes. And it ends.`)),
343
+ file: TEX_FILE,
344
+ severity: 1,
345
+ line: 3,
346
+ },
347
+ silent: paper(tex(FORMAL)),
348
+ },
329
349
  "bib/reachable-entry": {
330
350
  reports: {
331
351
  tree: paper(inlineBib("@book{a,\n title = {A},\n year = {2026}\n}")),
@@ -26,7 +26,6 @@ const AWAITING_PAGE: ReadonlySet<string> = new Set([
26
26
  "paper/leading-zero",
27
27
  "paper/refs-checked",
28
28
  "paper/refs-fresh",
29
- "paper/research-question",
30
29
  "paper/section-word",
31
30
  "paper/source",
32
31
  "paper/stages",
@@ -64,6 +64,7 @@ export interface ConfigBlock {
64
64
  readonly languageOptions?: Readonly<Record<string, unknown>>;
65
65
  readonly linterOptions?: Readonly<Record<string, unknown>>;
66
66
  readonly rules?: Readonly<Record<string, unknown>>;
67
+ readonly settings?: Readonly<Record<string, unknown>>;
67
68
  }
68
69
 
69
70
  export type Parsed<T> =
@@ -79,9 +79,7 @@ try {
79
79
  check(
80
80
  "and the message names the CONSEQUENCE, not a restatement of the condition",
81
81
  /are skipped/.test(at("no-scorecard")[0]?.message ?? "") &&
82
- /stage, source and research-question checks/.test(
83
- at("no-scorecard")[0]?.message ?? "",
84
- ),
82
+ /stage and source checks/.test(at("no-scorecard")[0]?.message ?? ""),
85
83
  );
86
84
  check(
87
85
  "and the consequence names THIS directory by name",
package/src/structure.ts CHANGED
@@ -122,7 +122,7 @@ export function checkStructure(
122
122
  */
123
123
  function whyMissingMatters(file: string, dirName: string): string {
124
124
  if (file === "PIPELINE-STATUS.md")
125
- return `the stage, source and research-question checks are skipped for \`${dirName}\``;
125
+ return `the stage and source checks are skipped for \`${dirName}\``;
126
126
  return `declared as required by your \`structure\` configuration`;
127
127
  }
128
128
 
@@ -0,0 +1,148 @@
1
+ /**
2
+ * The reading every parse-tree rule shares (`readPaper`), the files of the paper's body that lint
3
+ * reads on their own (`bodyFiles`), and where a finding of the assembled text is reported
4
+ * (`reportInPaper`).
5
+ */
6
+ import { describe, expect, it } from "vitest";
7
+ import { latexReader } from "./adapters/latex/index.ts";
8
+ import { memoryFiles } from "./adapters/memory/index.ts";
9
+ import { join } from "node:path";
10
+ import { texInputsDir } from "./package-dirs.ts";
11
+ import { bodyFiles, readPaper, reportInPaper } from "./tex-paper.ts";
12
+
13
+ describe("readPaper — the paper as the rules read it", () => {
14
+ it("reads includes from the paper's own directory", () => {
15
+ const p = readPaper("/p/paper.tex", "x\\input{s/a}y", {
16
+ files: memoryFiles({ "/p/s/a.tex": "AAA" }),
17
+ latex: latexReader,
18
+ });
19
+ expect(p.text).toBe("xAAAy");
20
+ expect(p.main).toBe("paper.tex");
21
+ });
22
+
23
+ it("an include the paper's directory lacks is found in paperlint's own inputs, as the build finds it", () => {
24
+ const p = readPaper("/p/paper.tex", "x\\input{guards}y", {
25
+ files: memoryFiles({ [join(texInputsDir(), "guards.tex")]: "G" }),
26
+ latex: latexReader,
27
+ });
28
+ expect({ text: p.text, missing: p.missing }).toEqual({
29
+ text: "xGy",
30
+ missing: [],
31
+ });
32
+ });
33
+
34
+ it("the paper's directory comes first: its file wins over paperlint's of the same name", () => {
35
+ const p = readPaper("/p/paper.tex", "\\input{guards}", {
36
+ files: memoryFiles({
37
+ "/p/guards.tex": "mine",
38
+ [join(texInputsDir(), "guards.tex")]: "package",
39
+ }),
40
+ latex: latexReader,
41
+ });
42
+ expect(p.text).toBe("mine");
43
+ });
44
+ });
45
+
46
+ describe("bodyFiles — the files of the body lint reads on their own", () => {
47
+ const doc = (preamble: string, body: string): string =>
48
+ `\\documentclass{article}\n${preamble}\n\\begin{document}\n${body}\n\\end{document}\n`;
49
+ const files = (extra: Record<string, string> = {}) =>
50
+ memoryFiles({
51
+ "/p/macros.tex": "\\newcommand{\\x}{y}",
52
+ "/p/sections/a.tex": "A\n\\input{sections/b}\n",
53
+ "/p/sections/b.tex": "B",
54
+ [join(texInputsDir(), "guards.tex")]: "G",
55
+ ...extra,
56
+ });
57
+
58
+ it("every file an include in the document body brings in, nested ones too, by absolute path", () => {
59
+ expect(
60
+ bodyFiles("/p/paper.tex", doc("", "\\input{sections/a}"), {
61
+ files: files(),
62
+ latex: latexReader,
63
+ }),
64
+ ).toEqual({
65
+ files: ["/p/sections/a.tex", "/p/sections/b.tex"],
66
+ missing: [],
67
+ });
68
+ });
69
+
70
+ it("not a preamble include (macros), and not a file found only in paperlint's inputs", () => {
71
+ expect(
72
+ bodyFiles(
73
+ "/p/paper.tex",
74
+ doc("\\input{macros}", "\\input{guards}\nText."),
75
+ { files: files(), latex: latexReader },
76
+ ),
77
+ ).toEqual({ files: [], missing: [] });
78
+ });
79
+
80
+ it("an include that resolves nowhere is named, with the file that wrote it", () => {
81
+ expect(
82
+ bodyFiles("/p/paper.tex", doc("", "\\input{gone}"), {
83
+ files: files(),
84
+ latex: latexReader,
85
+ }),
86
+ ).toEqual({ files: [], missing: [{ file: "paper.tex", target: "gone" }] });
87
+ });
88
+
89
+ it("a main file with no document environment: every include is the body", () => {
90
+ expect(
91
+ bodyFiles("/p/paper.tex", "\\input{sections/b}", {
92
+ files: files(),
93
+ latex: latexReader,
94
+ }),
95
+ ).toEqual({ files: ["/p/sections/b.tex"], missing: [] });
96
+ });
97
+ });
98
+
99
+ describe("reportInPaper — where a finding of the assembled text is reported", () => {
100
+ const paper = readPaper("/p/paper.tex", "x\n\\input{a}\ny", {
101
+ files: memoryFiles({ "/p/a.tex": "one\ntwo" }),
102
+ latex: latexReader,
103
+ });
104
+ const report = (
105
+ at: { start: number; end: number } | null,
106
+ messageId = "m",
107
+ ) => {
108
+ const out: unknown[] = [];
109
+ reportInPaper(
110
+ {
111
+ filename: "/p/paper.tex",
112
+ sourceCode: {
113
+ text: "",
114
+ getLocFromIndex: (i) => ({ line: i, column: 0 }),
115
+ },
116
+ report: (d) =>
117
+ out.push(
118
+ "messageId" in d
119
+ ? { at: d.loc.start.line, id: d.messageId }
120
+ : { at: d.loc.start.line, message: d.message },
121
+ ),
122
+ },
123
+ { m: "said {{w}}" },
124
+ paper,
125
+ [{ messageId, data: { w: "so" }, at }],
126
+ );
127
+ return out;
128
+ };
129
+
130
+ it("in the main file: at its own place, by its id", () => {
131
+ expect(report({ start: 0, end: 1 })).toEqual([{ at: 0, id: "m" }]);
132
+ });
133
+
134
+ it("in an included file: at the include, the file, line and column in front of the filled message", () => {
135
+ // "x\n" then "one\ntwo": offset 6 is the `t` of `two`, line 2 column 1 of a.tex.
136
+ expect(report({ start: 6, end: 9 })).toEqual([
137
+ { at: 2, message: "a.tex:2:1: said so" },
138
+ ]);
139
+ });
140
+
141
+ it("no place, or a place outside the text: the top of the file; an unknown id is printed as itself", () => {
142
+ expect(report(null)).toEqual([{ at: 0, id: "m" }]);
143
+ expect(report({ start: 99, end: 99 })).toEqual([{ at: 0, id: "m" }]);
144
+ expect(report({ start: 6, end: 9 }, "unknown")).toEqual([
145
+ { at: 2, message: "a.tex:2:1: unknown" },
146
+ ]);
147
+ });
148
+ });
@@ -0,0 +1,161 @@
1
+ /**
2
+ * THE PAPER, NOT ITS MAIN FILE — what every rule over the LaTeX parse tree reads, and how it reports.
3
+ *
4
+ * `readPaper` gives a rule `paper.tex` with every `\input`, `\include` and `\subfile` spliced in, the
5
+ * way TeX reads it (`assemblePaper`, src/domain/paper-source.ts). `reportInPaper` puts each finding
6
+ * back where its text came from: a finding in `paper.tex` at its own place; a finding in an included
7
+ * file at the include in `paper.tex` that brought it in — the one place ESLint can point to in this
8
+ * file — with the included file, line and column at the front of the message.
9
+ *
10
+ * An include is looked for where the build tells TeX to look (`texSearchPath`): the paper's own
11
+ * directory, then paperlint's inputs. `bodyFiles` names the files of the paper's BODY the author
12
+ * wrote — what the rules over ESLint's own LaTeX text read one by one, each at its own path.
13
+ */
14
+ import { basename, dirname, join } from "node:path";
15
+ import {
16
+ assemblePaper,
17
+ originOf,
18
+ type PaperSource,
19
+ } from "./domain/paper-source.ts";
20
+ import type { Span } from "./domain/tex-document.ts";
21
+ import type { LatexReader } from "./ports/latex.ts";
22
+ import type { Files } from "./ports/files.ts";
23
+ import { callerPath } from "./caller-path.ts";
24
+ import type { AbsolutePath } from "./domain/paths.ts";
25
+ import { texSearchPath } from "./package-dirs.ts";
26
+ import type { Located, TexRuleContext } from "./tex-venue-rules.ts";
27
+
28
+ /** What reading a paper needs: the disk, and the LaTeX reader. */
29
+ export interface PaperDeps {
30
+ readonly files: Files;
31
+ readonly latex: LatexReader;
32
+ }
33
+
34
+ /** The paper whose main file is `filename` with the text `src`, its includes spliced. */
35
+ export function readPaper(
36
+ filename: string,
37
+ src: string,
38
+ deps: PaperDeps,
39
+ ): PaperSource {
40
+ const dir = dirname(filename);
41
+ return assemblePaper(basename(filename), src, {
42
+ includes: deps.latex.includes,
43
+ documentBody: deps.latex.documentBody,
44
+ read: (rel) => {
45
+ const at = located(dir, rel, deps.files);
46
+ const b = at === null ? null : deps.files.readBytes(at);
47
+ return b === null ? null : new TextDecoder().decode(b);
48
+ },
49
+ });
50
+ }
51
+
52
+ /** `rel` in the first directory of the paper's TeX search path that holds it, or null. */
53
+ const located = (dir: string, rel: string, files: Files): AbsolutePath | null =>
54
+ texSearchPath(dir)
55
+ .map((d) => callerPath(join(d, rel)))
56
+ .find((p) => files.isFile(p)) ?? null;
57
+
58
+ /** An include that resolved nowhere: the file that wrote it, and the path as written. */
59
+ export interface Unread {
60
+ readonly file: string;
61
+ readonly target: string;
62
+ }
63
+
64
+ /**
65
+ * The files of the body of the paper whose main file is `filename`: every file an include inside its
66
+ * `document` environment brings in, nested ones too, found in the paper's own directory. Not a
67
+ * preamble include (macros are not the body), and not a file found only in paperlint's inputs (not
68
+ * the author's text). `missing` is every include, anywhere, that resolved nowhere.
69
+ */
70
+ export function bodyFiles(
71
+ filename: string,
72
+ src: string,
73
+ deps: PaperDeps,
74
+ ): {
75
+ readonly files: readonly AbsolutePath[];
76
+ readonly missing: readonly Unread[];
77
+ } {
78
+ const dir = dirname(filename);
79
+ const paper = readPaper(filename, src, deps);
80
+ const body = deps.latex.documentBody(src);
81
+ const inBody = (via: Span | null): boolean =>
82
+ via !== null &&
83
+ (body === null || (via.start >= body.start && via.end <= body.end));
84
+ const own = (rel: string): AbsolutePath | null => {
85
+ const at = located(dir, rel, deps.files);
86
+ return at === callerPath(join(dir, rel)) ? at : null;
87
+ };
88
+ return {
89
+ files: [
90
+ ...new Set(
91
+ paper.segments
92
+ .filter((s) => s.file !== paper.main && inBody(s.via))
93
+ .map((s) => own(s.file))
94
+ .filter((f): f is AbsolutePath => f !== null),
95
+ ),
96
+ ],
97
+ missing: paper.missing.map(({ file, target }) => ({ file, target })),
98
+ };
99
+ }
100
+
101
+ /** Line and column (1-based) of an offset in a text. */
102
+ function lineColumn(text: string, at: number): string {
103
+ const line = text.slice(0, at).split("\n").length;
104
+ const column = at - text.lastIndexOf("\n", at - 1);
105
+ return `${String(line)}:${String(column)}`;
106
+ }
107
+
108
+ /** A message template with its data filled in, the way ESLint fills it. */
109
+ const filled = (
110
+ template: string,
111
+ data: Readonly<Record<string, string | number>> = {},
112
+ ): string =>
113
+ Object.entries(data).reduce(
114
+ (m, [k, v]) => m.replaceAll(`{{${k}}}`, String(v)),
115
+ template,
116
+ );
117
+
118
+ /** Where a finding stands in the main file, and — when its text came from another file — which. */
119
+ interface Placed {
120
+ readonly at: Span | null;
121
+ readonly from: string | null;
122
+ }
123
+
124
+ /** A finding's span of the assembled text, as a place in the main file. */
125
+ function placed(paper: PaperSource, at: Span | null): Placed {
126
+ const origin = at === null ? null : originOf(paper, at);
127
+ if (origin === null) return { at: null, from: null };
128
+ if (origin.file === paper.main) return { at: origin.span, from: null };
129
+ return {
130
+ at: origin.via,
131
+ from: `${origin.file}:${lineColumn(origin.source, origin.span.start)}`,
132
+ };
133
+ }
134
+
135
+ /**
136
+ * Each finding reported in the main file: at its own place, or at the include that brought its text
137
+ * in, named in the message (`sections/results.tex:12:3: …`). Offsets are the assembled text's.
138
+ */
139
+ export function reportInPaper(
140
+ context: TexRuleContext,
141
+ messages: Readonly<Record<string, string>>,
142
+ paper: PaperSource,
143
+ findings: readonly Located[],
144
+ ): void {
145
+ const loc = (i: number) => context.sourceCode.getLocFromIndex(i);
146
+ findings.forEach((f) => {
147
+ const p = placed(paper, f.at);
148
+ const where = {
149
+ start: loc(p.at?.start ?? 0),
150
+ end: loc(p.at?.end ?? 0),
151
+ };
152
+ if (p.from === null)
153
+ context.report({ loc: where, messageId: f.messageId, data: f.data });
154
+ else
155
+ context.report({
156
+ loc: where,
157
+ // A message id the rule does not define is printed as itself rather than dropped.
158
+ message: `${p.from}: ${filled(messages[f.messageId] ?? f.messageId, f.data)}`,
159
+ });
160
+ });
161
+ }
@@ -79,11 +79,16 @@ function lint(
79
79
  report: (d) =>
80
80
  out.push({
81
81
  rule: `tex/${name}`,
82
- messageId: d.messageId,
83
- message: Object.entries(d.data ?? {}).reduce(
84
- (m, [k, v]) => m.replaceAll(`{{${k}}}`, String(v)),
85
- rule.meta.messages[d.messageId] ?? `(no message ${d.messageId})`,
86
- ),
82
+ // A finding from an included file arrives as a whole message, its file at the front.
83
+ messageId: "messageId" in d ? d.messageId : "(message)",
84
+ message:
85
+ "messageId" in d
86
+ ? Object.entries(d.data ?? {}).reduce(
87
+ (m, [k, v]) => m.replaceAll(`{{${k}}}`, String(v)),
88
+ rule.meta.messages[d.messageId] ??
89
+ `(no message ${d.messageId})`,
90
+ )
91
+ : d.message,
87
92
  line: d.loc.start.line,
88
93
  }),
89
94
  });
@@ -183,6 +188,83 @@ describe("tex/template — the class line's states: missing, empty, commented ou
183
188
  });
184
189
  });
185
190
 
191
+ describe("the venue rules read the whole paper: the files it \\inputs, spliced where they stand", () => {
192
+ it("🔴 a required section kept in an included file is found, and its order judged in the paper", () => {
193
+ const tex = paper(
194
+ "\\documentclass[conference,compsoc]{IEEEtran}",
195
+ "Text.\n\\input{sections/closing}",
196
+ );
197
+ const extra = {
198
+ [`${PAPER}/sections/closing.tex`]:
199
+ "\\section*{LLM Usage Statement}\nNone.\n",
200
+ };
201
+ expect(lint(tex, AIDC, { extra })).toEqual([]);
202
+ // Without the file, the section is missing.
203
+ expect(ids(lint(tex, AIDC))).toEqual(["tex/required-section:missing"]);
204
+ });
205
+
206
+ it("a leftover venue name in an included file is reported at the \\input, naming the file and line", () => {
207
+ const tex = paper(
208
+ "\\documentclass[conference,compsoc]{IEEEtran}",
209
+ "Text.\n\\input{intro}\n\\section*{LLM Usage Statement}\nNone.",
210
+ );
211
+ const fs = lint(tex, AIDC, {
212
+ extra: {
213
+ [`${PAPER}/intro.tex`]: "One line.\nFirst written for AgenticDev.\n",
214
+ },
215
+ });
216
+ expect(fs.map((f) => [f.line, f.message.split(": ")[0]])).toEqual([
217
+ [5, "intro.tex:2:19"],
218
+ ]);
219
+ });
220
+ });
221
+
222
+ describe("tex/template — several \\documentclass lines behind a TeX switch", () => {
223
+ /** One source for two venues, the class picked by `\\if` — as an accepted ACSAC paper does it. */
224
+ const switched = (a: string, b: string) =>
225
+ paper(
226
+ `\\def\\venue{2}\n\\if\\venue1\n${a}\n\\fi\n\\if\\venue2\n${b}\n\\fi`,
227
+ );
228
+
229
+ it("passes when one of the candidates is the preset's class with its options", () => {
230
+ expect(
231
+ lint(
232
+ switched(
233
+ "\\documentclass[letterpaper,twocolumn]{article}",
234
+ "\\documentclass[conference,compsoc]{IEEEtran}",
235
+ ),
236
+ AIDC,
237
+ ),
238
+ ).toEqual([]);
239
+ });
240
+
241
+ it("reports once, at the first candidate, naming every candidate, when none is the preset's", () => {
242
+ const fs = lint(
243
+ switched(
244
+ "\\documentclass{article}",
245
+ "\\documentclass[conference]{IEEEtran}",
246
+ ),
247
+ AIDC,
248
+ );
249
+ expect(ids(fs)).toEqual(["tex/template:noCandidate"]);
250
+ // Line 4: the first candidate, after the comment, `\\def` and `\\if` lines.
251
+ expect(fs[0]?.line).toBe(4);
252
+ expect(fs[0]?.message).toBe(
253
+ "none of the 2 \\documentclass lines is `\\documentclass[conference,compsoc]{IEEEtran}`, which aidc requires: `\\documentclass{article}`, `\\documentclass[conference]{IEEEtran}`. The source picks one behind a TeX switch, which is not evaluated; make one of them the venue's",
254
+ );
255
+ });
256
+
257
+ it("an empty candidate is named as such", () => {
258
+ const fs = lint(
259
+ switched("\\documentclass{}", "\\documentclass{article}"),
260
+ AIDC,
261
+ );
262
+ expect(fs[0]?.message).toMatch(
263
+ /`\\documentclass\{\}`, `\\documentclass\{article\}`/,
264
+ );
265
+ });
266
+ });
267
+
186
268
  describe("tex/template — the shipped ACM and ACL templates", () => {
187
269
  it("🔴 acmart without sigconf — the manuscript format — fails every ACM preset", () => {
188
270
  for (const settings of [