paperlint 4.0.0 → 4.2.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 (236) hide show
  1. package/.github/workflows/ci.yml +16 -23
  2. package/CLAUDE.md +55 -11
  3. package/CONTRIBUTING.md +30 -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 +23 -0
  7. package/dist/adapters/latex/document-class.d.ts.map +1 -0
  8. package/dist/adapters/latex/document-class.js +110 -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/includes.d.ts +12 -0
  15. package/dist/adapters/latex/includes.d.ts.map +1 -0
  16. package/dist/adapters/latex/includes.js +42 -0
  17. package/dist/adapters/latex/includes.js.map +1 -0
  18. package/dist/adapters/latex/index.d.ts +33 -0
  19. package/dist/adapters/latex/index.d.ts.map +1 -0
  20. package/dist/adapters/latex/index.js +25 -0
  21. package/dist/adapters/latex/index.js.map +1 -0
  22. package/dist/adapters/latex/nodes.d.ts +58 -0
  23. package/dist/adapters/latex/nodes.d.ts.map +1 -0
  24. package/dist/adapters/latex/nodes.js +97 -0
  25. package/dist/adapters/latex/nodes.js.map +1 -0
  26. package/dist/adapters/latex/outline.d.ts +10 -0
  27. package/dist/adapters/latex/outline.d.ts.map +1 -0
  28. package/dist/adapters/latex/outline.js +39 -0
  29. package/dist/adapters/latex/outline.js.map +1 -0
  30. package/dist/adapters/latex/parse.d.ts +42 -0
  31. package/dist/adapters/latex/parse.d.ts.map +1 -0
  32. package/dist/adapters/latex/parse.js +95 -0
  33. package/dist/adapters/latex/parse.js.map +1 -0
  34. package/dist/adapters/latex/prose.d.ts +8 -0
  35. package/dist/adapters/latex/prose.d.ts.map +1 -0
  36. package/dist/adapters/latex/prose.js +353 -0
  37. package/dist/adapters/latex/prose.js.map +1 -0
  38. package/dist/adapters/latex/rendered.d.ts +10 -0
  39. package/dist/adapters/latex/rendered.d.ts.map +1 -0
  40. package/dist/adapters/latex/rendered.js +39 -0
  41. package/dist/adapters/latex/rendered.js.map +1 -0
  42. package/dist/adapters/pdfjs/fill.d.ts.map +1 -1
  43. package/dist/adapters/pdfjs/fill.js +12 -12
  44. package/dist/adapters/pdfjs/fill.js.map +1 -1
  45. package/dist/build.js +4 -4
  46. package/dist/build.js.map +1 -1
  47. package/dist/claim-provenance.d.ts +34 -0
  48. package/dist/claim-provenance.d.ts.map +1 -0
  49. package/dist/claim-provenance.js +146 -0
  50. package/dist/claim-provenance.js.map +1 -0
  51. package/dist/cli.d.ts.map +1 -1
  52. package/dist/cli.js +49 -5
  53. package/dist/cli.js.map +1 -1
  54. package/dist/domain/paper-source.d.ts +58 -0
  55. package/dist/domain/paper-source.d.ts.map +1 -0
  56. package/dist/domain/paper-source.js +109 -0
  57. package/dist/domain/paper-source.js.map +1 -0
  58. package/dist/domain/sentences.d.ts +26 -0
  59. package/dist/domain/sentences.d.ts.map +1 -0
  60. package/dist/domain/sentences.js +48 -0
  61. package/dist/domain/sentences.js.map +1 -0
  62. package/dist/domain/tex-document.d.ts +119 -0
  63. package/dist/domain/tex-document.d.ts.map +1 -0
  64. package/dist/domain/tex-document.js +43 -0
  65. package/dist/domain/tex-document.js.map +1 -0
  66. package/dist/eslint-rules/paper-typography.d.ts +7 -0
  67. package/dist/eslint-rules/paper-typography.d.ts.map +1 -1
  68. package/dist/eslint-rules/paper-typography.js +53 -1
  69. package/dist/eslint-rules/paper-typography.js.map +1 -1
  70. package/dist/facts-file.d.ts.map +1 -1
  71. package/dist/facts-file.js +2 -2
  72. package/dist/facts-file.js.map +1 -1
  73. package/dist/new-paper.d.ts +5 -0
  74. package/dist/new-paper.d.ts.map +1 -1
  75. package/dist/new-paper.js +3 -0
  76. package/dist/new-paper.js.map +1 -1
  77. package/dist/package-dirs.d.ts +5 -0
  78. package/dist/package-dirs.d.ts.map +1 -0
  79. package/dist/package-dirs.js +14 -0
  80. package/dist/package-dirs.js.map +1 -0
  81. package/dist/ports/latex.d.ts +35 -0
  82. package/dist/ports/latex.d.ts.map +1 -0
  83. package/dist/ports/latex.js +2 -0
  84. package/dist/ports/latex.js.map +1 -0
  85. package/dist/presets.d.ts +15 -2
  86. package/dist/presets.d.ts.map +1 -1
  87. package/dist/presets.js +19 -6
  88. package/dist/presets.js.map +1 -1
  89. package/dist/register.d.ts +40 -0
  90. package/dist/register.d.ts.map +1 -0
  91. package/dist/register.js +134 -0
  92. package/dist/register.js.map +1 -0
  93. package/dist/tex-paper.d.ts +19 -0
  94. package/dist/tex-paper.d.ts.map +1 -0
  95. package/dist/tex-paper.js +105 -0
  96. package/dist/tex-paper.js.map +1 -0
  97. package/dist/tex-requirements.d.ts +11 -0
  98. package/dist/tex-requirements.d.ts.map +1 -1
  99. package/dist/tex-requirements.js +14 -8
  100. package/dist/tex-requirements.js.map +1 -1
  101. package/dist/tex-venue-rules.d.ts +88 -0
  102. package/dist/tex-venue-rules.d.ts.map +1 -0
  103. package/dist/tex-venue-rules.js +268 -0
  104. package/dist/tex-venue-rules.js.map +1 -0
  105. package/dist/venue-rules.d.ts +1 -1
  106. package/dist/venue-rules.d.ts.map +1 -1
  107. package/docs/configuration.md +3 -2
  108. package/docs/e2e.md +69 -40
  109. package/docs/install.md +1 -1
  110. package/docs/prior-art/README.md +1 -1
  111. package/docs/prior-art/package-location.md +5 -5
  112. package/docs/prior-art/repro/README.md +1 -1
  113. package/docs/prior-art/test-tooling.md +21 -10
  114. package/docs/rules/tex/claim-provenance.md +83 -0
  115. package/docs/rules/tex/missing-input.md +58 -0
  116. package/docs/rules/tex/register.md +77 -0
  117. package/docs/rules/tex/required-section.md +75 -0
  118. package/docs/rules/tex/template.md +74 -0
  119. package/docs/rules/tex/venue-leftover.md +73 -0
  120. package/docs/rules.md +73 -48
  121. package/docs/testing.md +17 -4
  122. package/docs/texlive-install-decision.md +1 -1
  123. package/docs/toolchain.md +2 -2
  124. package/eslint-rules/paper-typography.test.mjs +48 -1
  125. package/eslint-rules/paper-typography.ts +72 -1
  126. package/eslint.config.mjs +156 -15
  127. package/fixtures/build-e2e/acmart/expect.json +26 -0
  128. package/fixtures/build-e2e/aidc/PIPELINE-STATUS.md +3 -0
  129. package/fixtures/build-e2e/aidc/expect.json +14 -0
  130. package/fixtures/build-e2e/aidc/paper.tex +46 -0
  131. package/fixtures/build-e2e/aidc/paperlint.json +1 -0
  132. package/fixtures/build-e2e/broken/expect.json +11 -0
  133. package/fixtures/build-e2e/cite/expect.json +27 -0
  134. package/fixtures/build-e2e/empty/expect.json +12 -0
  135. package/fixtures/build-e2e/fallback/expect.json +53 -0
  136. package/fixtures/build-e2e/guards/expect.json +6 -0
  137. package/fixtures/build-e2e/no-source/expect.json +10 -0
  138. package/fixtures/build-e2e/unbalanced/expect.json +60 -0
  139. package/fixtures/pdf-facts/README.md +1 -1
  140. package/fixtures/real-markdown-paper/baseline.ts +8 -7
  141. package/fixtures/render-paper/build-clean.sh +3 -5
  142. package/package.json +14 -2
  143. package/{skills/submit-paper/references/venues → presets}/acm-sigconf.jsonc +4 -1
  144. package/{skills/submit-paper/references/venues → presets}/agenticdev.jsonc +4 -0
  145. package/presets/aidc.jsonc +73 -0
  146. package/{skills/submit-paper/references/venues → presets}/aisec.jsonc +4 -0
  147. package/presets/ieee-conference.jsonc +66 -0
  148. package/{skills/submit-paper/references/venues → presets}/realm.jsonc +8 -1
  149. package/{skills/submit-paper/references/venues → presets/tex}/paper-guards.tex +1 -1
  150. package/{skills/submit-paper/references/venues → presets}/venue-profile.schema.json +30 -2
  151. package/scripts/check.harness.ts +64 -10
  152. package/scripts/check.test.ts +60 -13
  153. package/scripts/check.ts +83 -30
  154. package/scripts/test.test.ts +1 -1
  155. package/scripts/test.ts +1 -1
  156. package/skills/cold-read-diff/SKILL.md +31 -10
  157. package/skills/cold-read-diff/SKILL.md.spec.ts +30 -9
  158. package/skills/draft-paper/SKILL.md +2 -2
  159. package/skills/draft-paper/SKILL.md.spec.ts +1 -1
  160. package/skills/grade-paper-writing/SKILL.md +109 -44
  161. package/skills/grade-paper-writing/SKILL.md.spec.ts +108 -43
  162. package/skills/harden-paper/SKILL.md +35 -36
  163. package/skills/harden-paper/SKILL.md.spec.ts +34 -35
  164. package/skills/paper-adversarial-review/SKILL.md +2 -2
  165. package/skills/paper-adversarial-review/SKILL.md.spec.ts +1 -1
  166. package/skills/paper-pipeline/PIPELINE-MAP.md +2 -2
  167. package/skills/paper-pipeline/SKILL.md +4 -4
  168. package/skills/paper-pipeline/SKILL.md.spec.ts +3 -3
  169. package/skills/paper-pipeline/grade-paper-writing-ablation.eval.mjs +6 -6
  170. package/skills/paper-pipeline/references/writing-craft.md +53 -18
  171. package/skills/paper-pipeline/scripts/consumer.d.mts +0 -2
  172. package/skills/paper-pipeline/scripts/consumer.mjs +1 -15
  173. package/skills/pc-panel-review/SKILL.md +36 -11
  174. package/skills/pc-panel-review/SKILL.md.spec.ts +35 -10
  175. package/skills/render-paper/SKILL.md +2 -2
  176. package/skills/render-paper/SKILL.md.spec.ts +1 -1
  177. package/skills/render-paper/check-render.sh +16 -63
  178. package/skills/render-paper/render-paper.harness.mjs +17 -59
  179. package/src/adapters/banal/index.test.ts +1 -1
  180. package/src/adapters/banal/invocation.ts +1 -1
  181. package/src/adapters/banal/output.test.ts +1 -1
  182. package/src/adapters/banal/run.test.ts +1 -1
  183. package/src/adapters/banal/xml.test.ts +1 -1
  184. package/src/adapters/latex/document-class.ts +155 -0
  185. package/src/adapters/latex/hidden.ts +70 -0
  186. package/src/adapters/latex/includes.ts +66 -0
  187. package/src/adapters/latex/index.test.ts +535 -0
  188. package/src/adapters/latex/index.ts +65 -0
  189. package/src/adapters/latex/nodes.ts +150 -0
  190. package/src/adapters/latex/outline.ts +64 -0
  191. package/src/adapters/latex/parse.ts +137 -0
  192. package/src/adapters/latex/prose.ts +484 -0
  193. package/src/adapters/latex/rendered.ts +59 -0
  194. package/src/adapters/pdfjs/fill.test.ts +1 -1
  195. package/src/adapters/pdfjs/fill.ts +18 -10
  196. package/src/build.harness.mjs +5 -7
  197. package/src/build.ts +4 -4
  198. package/src/claim-provenance.test.ts +352 -0
  199. package/src/claim-provenance.ts +217 -0
  200. package/src/cli.harness.mjs +1 -1
  201. package/src/cli.test.ts +2 -8
  202. package/src/cli.ts +53 -8
  203. package/src/domain/paper-source.test.ts +156 -0
  204. package/src/domain/paper-source.ts +233 -0
  205. package/src/domain/sentences.ts +67 -0
  206. package/src/domain/tex-document.test.ts +80 -0
  207. package/src/domain/tex-document.ts +143 -0
  208. package/src/engine.harness.mjs +1 -1
  209. package/src/facts-file.test.ts +4 -7
  210. package/src/facts-file.ts +2 -2
  211. package/src/latex-loop.harness.ts +1 -1
  212. package/src/new-paper.test.ts +28 -3
  213. package/src/new-paper.ts +8 -0
  214. package/src/package-dirs.ts +17 -0
  215. package/src/paper-settings-commands.test.ts +6 -1
  216. package/src/ports/latex.ts +42 -0
  217. package/src/presets.test.ts +65 -4
  218. package/src/presets.ts +38 -15
  219. package/src/register.test.ts +165 -0
  220. package/src/register.ts +177 -0
  221. package/src/rule-cases.test.ts +524 -0
  222. package/src/rule-docs.test.ts +159 -0
  223. package/src/tex-paper.test.ts +131 -0
  224. package/src/tex-paper.ts +152 -0
  225. package/src/tex-requirements.harness.ts +13 -23
  226. package/src/tex-requirements.ts +35 -8
  227. package/src/tex-venue-rules.test.ts +741 -0
  228. package/src/tex-venue-rules.ts +461 -0
  229. package/src/toolchain.harness.mjs +1 -1
  230. package/src/venue-rules.test.ts +96 -4
  231. package/src/venue-rules.ts +1 -1
  232. package/templates/paper/paper.tex +2 -1
  233. package/skills/harden-paper/fixtures/uncited-assertions-sample.md +0 -43
  234. package/skills/harden-paper/fixtures/uncited-assertions-sample.tex +0 -77
  235. /package/{skills/submit-paper/references/venues → presets/tex}/agenticdev.tex +0 -0
  236. /package/{skills/submit-paper/references/venues → presets}/tex-base.jsonc +0 -0
@@ -0,0 +1,535 @@
1
+ /**
2
+ * The LaTeX adapter on real parses: the class line in each of its states, a preset's template, the
3
+ * class `paperlint new` writes, the outline, and the rendered text. Trees come only from
4
+ * `parseLatex`; the one helper that reads a node's position is tested on the shapes a position can
5
+ * take.
6
+ */
7
+ import type * as Ast from "@unified-latex/unified-latex-types";
8
+ import { describe, expect, it } from "vitest";
9
+ import { runText, spanIn, type ProsePiece } from "../../domain/tex-document.ts";
10
+ import {
11
+ bodyProse,
12
+ documentBodyOf,
13
+ documentClassOf,
14
+ includesOf,
15
+ inPlace,
16
+ latexReader,
17
+ macroPlace,
18
+ mandatory,
19
+ optional,
20
+ outlineOf,
21
+ parseLatex,
22
+ parseTemplate,
23
+ placeOf,
24
+ renderedRuns,
25
+ replaceDocumentClass,
26
+ textOf,
27
+ } from "./index.ts";
28
+
29
+ const IEEE = { cls: "IEEEtran", options: ["conference", "compsoc"] };
30
+
31
+ describe("documentClassOf — the class line's three states", () => {
32
+ it("a class: each option whitespace-collapsed, and the span of the whole macro, arguments included", () => {
33
+ const src = "% x\n\\documentclass[ conference ,\n compsoc]{IEEEtran}\n";
34
+ expect(documentClassOf(parseLatex(src))).toEqual({
35
+ kind: "class",
36
+ cls: "IEEEtran",
37
+ options: ["conference", "compsoc"],
38
+ place: {
39
+ kind: "at",
40
+ span: { start: 4, end: src.indexOf("}") + 1 },
41
+ },
42
+ });
43
+ });
44
+
45
+ it("🔴 empty: \\documentclass{} names no class, and stands where it stands — not missing", () => {
46
+ expect(documentClassOf(parseLatex("%\n\\documentclass{}\n"))).toEqual({
47
+ kind: "empty",
48
+ place: { kind: "at", span: { start: 2, end: 16 } },
49
+ });
50
+ });
51
+
52
+ it("ambiguous: two \\documentclass lines behind a switch, each read as a candidate in source order", () => {
53
+ const src =
54
+ "\\if\\x1\\documentclass{article}\\fi\\if\\x2\\documentclass[a]{b}\\fi";
55
+ const line = documentClassOf(parseLatex(src));
56
+ expect(
57
+ line.kind === "ambiguous" &&
58
+ line.candidates.map((c) => c.kind === "class" && c.cls),
59
+ ).toEqual(["article", "b"]);
60
+ });
61
+
62
+ it("missing: no \\documentclass at the top of the tree", () => {
63
+ expect(
64
+ documentClassOf(parseLatex("\\begin{document}\\end{document}")),
65
+ ).toEqual({ kind: "missing" });
66
+ });
67
+
68
+ it("a group in the class name and a paragraph break are read as text", () => {
69
+ const line = documentClassOf(parseLatex("\\documentclass{{IEEE}\n\ntran}"));
70
+ expect(line.kind === "class" && line.cls).toBe("IEEE tran");
71
+ });
72
+ });
73
+
74
+ describe("placeOf / macroPlace — a node the parser gave no position is unplaced, never offset 0", () => {
75
+ const at = (start: number, end: number) => ({
76
+ start: { offset: start, line: 1, column: start + 1 },
77
+ end: { offset: end, line: 1, column: end + 1 },
78
+ });
79
+ it("placeOf: a position is a span; none is unplaced", () => {
80
+ expect(placeOf({ position: at(3, 7) })).toEqual({
81
+ kind: "at",
82
+ span: { start: 3, end: 7 },
83
+ });
84
+ expect(placeOf({})).toEqual({ kind: "unplaced" });
85
+ });
86
+
87
+ it("macroPlace: unplaced without a position; with one, to past the last argument that holds text", () => {
88
+ const bare: Ast.Macro = { type: "macro", content: "documentclass" };
89
+ expect(macroPlace(bare)).toEqual({ kind: "unplaced" });
90
+ const withArgs: Ast.Macro = {
91
+ type: "macro",
92
+ content: "section",
93
+ position: at(0, 8),
94
+ args: [
95
+ { type: "argument", content: [], openMark: "", closeMark: "" },
96
+ {
97
+ type: "argument",
98
+ content: [{ type: "string", content: "A", position: at(9, 10) }],
99
+ openMark: "{",
100
+ closeMark: "}",
101
+ },
102
+ { type: "argument", content: [], openMark: "[", closeMark: "]" },
103
+ ],
104
+ };
105
+ expect(macroPlace(withArgs)).toEqual({
106
+ kind: "at",
107
+ span: { start: 0, end: 11 },
108
+ });
109
+ });
110
+ });
111
+
112
+ describe("a macro with no arguments, and markup in a name", () => {
113
+ const bare: Ast.Macro = { type: "macro", content: "foo" };
114
+ it("has no mandatory or optional arguments, and its place is its name", () => {
115
+ expect(mandatory(bare)).toEqual([]);
116
+ expect(optional(bare)).toBe(undefined);
117
+ expect(
118
+ macroPlace({
119
+ ...bare,
120
+ position: {
121
+ start: { offset: 2, line: 1, column: 3 },
122
+ end: { offset: 6, line: 1, column: 7 },
123
+ },
124
+ }),
125
+ ).toEqual({ kind: "at", span: { start: 2, end: 6 } });
126
+ });
127
+
128
+ it("textOf drops markup it cannot read as text, and reads nothing as empty", () => {
129
+ expect(textOf([bare, { type: "string", content: "x" }])).toBe("x");
130
+ expect(textOf(undefined)).toBe("");
131
+ });
132
+ });
133
+
134
+ describe("parseTemplate — a preset's template", () => {
135
+ it.each<[string, { cls: string; options: string[] } | null]>([
136
+ ["\\documentclass[conference,compsoc]{IEEEtran}", IEEE],
137
+ ["article", { cls: "article", options: [] }],
138
+ [" acmart\n", { cls: "acmart", options: [] }],
139
+ ["two words", null],
140
+ ["% c\n\\documentclass{article}\n", { cls: "article", options: [] }],
141
+ ["\\documentclass{article} trailing", null],
142
+ ["\\documentclass[a]{}", null],
143
+ ["", null],
144
+ ])("%j → %j", (text, want) => {
145
+ expect(parseTemplate(text)).toEqual(want);
146
+ });
147
+ });
148
+
149
+ const replaced = (src: string) => replaceDocumentClass(parseLatex(src), IEEE);
150
+
151
+ describe("replaceDocumentClass — the class `paperlint new` writes", () => {
152
+ it("replaces the whole line, arguments included, and keeps everything around it", () => {
153
+ expect(replaced("% c\n\\documentclass[11pt]{article}\n\\title{x}\n")).toBe(
154
+ "% c\n\\documentclass[conference,compsoc]{IEEEtran}\n\\title{x}\n",
155
+ );
156
+ });
157
+
158
+ it("a class that already satisfies the template keeps the author's own options", () => {
159
+ const src = "\\documentclass[compsoc,conference,a4paper]{IEEEtran}\nx";
160
+ expect(replaced(src)).toBe(src);
161
+ });
162
+
163
+ it("the right class missing a required option keeps the author's options and adds the missing ones", () => {
164
+ expect(
165
+ replaced("\\documentclass[review,anonymous,conference]{IEEEtran}\nx"),
166
+ ).toBe("\\documentclass[review,anonymous,conference,compsoc]{IEEEtran}\nx");
167
+ });
168
+
169
+ it("an option with a braced value keeps its braces, its commas and its spacing", () => {
170
+ expect(replaced("\\documentclass[review, foo={a,b}]{IEEEtran}\nx")).toBe(
171
+ "\\documentclass[review, foo={a,b},conference,compsoc]{IEEEtran}\nx",
172
+ );
173
+ });
174
+
175
+ it("🔴 a source with several \\documentclass lines is returned as it is: which branch builds is not known", () => {
176
+ const src =
177
+ "\\if\\x1\\documentclass{article}\\fi\\if\\x2\\documentclass{IEEEtran}\\fi";
178
+ expect(replaced(src)).toBe(src);
179
+ });
180
+
181
+ it("no class, or an empty one, is returned as it is", () => {
182
+ expect(replaced("just text")).toBe("just text");
183
+ expect(replaced("\\documentclass{}\nx")).toBe("\\documentclass{}\nx");
184
+ });
185
+
186
+ it("the port does the same over a source", () => {
187
+ expect(
188
+ latexReader.withDocumentClass("\\documentclass{article}", IEEE),
189
+ ).toBe("\\documentclass[conference,compsoc]{IEEEtran}");
190
+ });
191
+ });
192
+
193
+ describe("replaceDocumentClass — missing options are added in place, the author's bytes kept", () => {
194
+ it("🔴 the missing options go before the `]`: the author's bytes, a comment included, stay as written", () => {
195
+ expect(replaced("\\documentclass[foo=bar% note\nbaz]{IEEEtran}\nx")).toBe(
196
+ "\\documentclass[foo=bar% note\nbaz,conference,compsoc]{IEEEtran}\nx",
197
+ );
198
+ const acm = "\\documentclass[review,% reason\nanonymous]{acmart}\nx";
199
+ expect(
200
+ replaceDocumentClass(parseLatex(acm), {
201
+ cls: "acmart",
202
+ options: ["sigconf"],
203
+ }),
204
+ ).toBe("\\documentclass[review,% reason\nanonymous,sigconf]{acmart}\nx");
205
+ });
206
+
207
+ it("a trailing comment in the options: the missing ones go on the next line, before the `]`", () => {
208
+ expect(replaced("\\documentclass[review% c\n]{IEEEtran}")).toBe(
209
+ "\\documentclass[review% c\n,conference,compsoc]{IEEEtran}",
210
+ );
211
+ });
212
+
213
+ it("no options, an empty `[]`, a blank `[ ]`: the options are written without a stray comma", () => {
214
+ expect(replaced("\\documentclass{IEEEtran}\nx")).toBe(
215
+ "\\documentclass[conference,compsoc]{IEEEtran}\nx",
216
+ );
217
+ expect(replaced("\\documentclass[]{IEEEtran}")).toBe(
218
+ "\\documentclass[conference,compsoc]{IEEEtran}",
219
+ );
220
+ expect(replaced("\\documentclass[ ]{IEEEtran}")).toBe(
221
+ "\\documentclass[ conference,compsoc]{IEEEtran}",
222
+ );
223
+ });
224
+
225
+ it("reading the options: a line comment joins its two sides with no space, as TeX does", () => {
226
+ const read = (src: string) => {
227
+ const line = documentClassOf(parseLatex(src));
228
+ return line.kind === "class" && line.options;
229
+ };
230
+ expect(read("\\documentclass[review,% reason\nanonymous]{acmart}")).toEqual(
231
+ ["review", "anonymous"],
232
+ );
233
+ expect(read("\\documentclass[foo=bar% note\nbaz]{IEEEtran}")).toEqual([
234
+ "foo=barbaz",
235
+ ]);
236
+ });
237
+ });
238
+
239
+ describe("outlineOf — a heading's title", () => {
240
+ it("keeps the text of formatting macros and drops keys such as a label", () => {
241
+ const o = outlineOf(
242
+ parseLatex("\\section*{LLM \\textbf{Usage} Statement\\label{s:llm}}"),
243
+ );
244
+ expect(o.sections.map((h) => h.title)).toEqual(["LLM Usage Statement"]);
245
+ });
246
+ });
247
+
248
+ describe("outlineOf — a formatting macro's text is its last mandatory argument", () => {
249
+ it.each<[string, string]>([
250
+ [
251
+ "\\section*{\\textcolor{red}{LLM Usage Statement}}",
252
+ "LLM Usage Statement",
253
+ ],
254
+ [
255
+ "\\section*{LLM \\emph{Usage} \\textbf{Statement}}",
256
+ "LLM Usage Statement",
257
+ ],
258
+ ["\\section*{See \\href{https://x.org}{the site}}", "See the site"],
259
+ [
260
+ "\\section*{\\texorpdfstring{LLM Usage Statement}{Short}}",
261
+ "LLM Usage Statement",
262
+ ],
263
+ ["\\section*{\\color{red}LLM Usage Statement}", "LLM Usage Statement"],
264
+ ["\\section*{LLM~Usage~Statement}", "LLM Usage Statement"],
265
+ [
266
+ "\\section*{\\colorbox{yellow}{LLM Usage} Statement}",
267
+ "LLM Usage Statement",
268
+ ],
269
+ [
270
+ "\\section*{\\fontsize{10}{12}\\selectfont LLM \\vspace{1em}Usage\\hspace{2pt} Statement}",
271
+ "LLM Usage Statement",
272
+ ],
273
+ [
274
+ "\\section*{\\setlength{\\parskip}{0pt}\\addtolength{\\parskip}{1pt}LLM Usage Statement}",
275
+ "LLM Usage Statement",
276
+ ],
277
+ ])("%s → %s", (src, title) => {
278
+ expect(outlineOf(parseLatex(src)).sections.map((h) => h.title)).toEqual([
279
+ title,
280
+ ]);
281
+ });
282
+ });
283
+
284
+ describe("outlineOf", () => {
285
+ it("sections in order with titles collapsed, the first back matter, and the document's end", () => {
286
+ const src =
287
+ "\\documentclass{article}\\section{Pre}\n\\begin{document}\n\\section{A B}\n" +
288
+ "\\section*{C}\n\\appendix\n\\section{D}\n\\bibliography{r}\n\\end{document}";
289
+ const o = outlineOf(parseLatex(src));
290
+ expect(o.sections.map((h) => h.title)).toEqual(["A B", "C", "D"]);
291
+ expect(o.backMatter).toBe(src.indexOf("\\appendix"));
292
+ expect(o.end).toBe(src.length - 1);
293
+ const [a] = o.sections;
294
+ expect(
295
+ a?.place.kind === "at" && src.slice(a.place.span.start, a.place.span.end),
296
+ ).toBe("\\section{A B}");
297
+ });
298
+
299
+ it("a bibliography environment is back matter too; a fragment has no end; no back matter is null", () => {
300
+ const src = "\\section{A}\\begin{thebibliography}{1}\\end{thebibliography}";
301
+ expect(latexReader.outline(src)).toMatchObject({
302
+ backMatter: src.indexOf("\\begin"),
303
+ end: null,
304
+ });
305
+ expect(latexReader.outline("\\section{A}").backMatter).toBe(null);
306
+ });
307
+ });
308
+
309
+ describe("outlineOf — a heading that is never rendered is not in the outline", () => {
310
+ it.each<[string, string]>([
311
+ [
312
+ "a comment environment",
313
+ "\\begin{comment}\\section{Hidden}\\end{comment}",
314
+ ],
315
+ ["a \\newcommand body", "\\newcommand{\\x}{\\section{Hidden}}"],
316
+ ["a \\def body", "\\def\\y{\\section{Hidden}}"],
317
+ ["a \\newenvironment body", "\\newenvironment{e}{\\section{Hidden}}{}"],
318
+ ])("%s", (_, hidden) => {
319
+ const src = `\\begin{document}${hidden}\\section{Real}\\end{document}`;
320
+ expect(outlineOf(parseLatex(src)).sections.map((h) => h.title)).toEqual([
321
+ "Real",
322
+ ]);
323
+ });
324
+ });
325
+
326
+ describe("renderedRuns", () => {
327
+ it("runs of strings and spaces, each character mapped to its source offset", () => {
328
+ const src = "A~B \\emph{C D}\n\n E";
329
+ const runs = renderedRuns(parseLatex(src));
330
+ expect(runs.map(runText)).toEqual(["A B ", "C D", " E"]);
331
+ const [first] = runs;
332
+ expect(first && spanIn(first, 2, 3)).toEqual({ start: 2, end: 3 });
333
+ });
334
+
335
+ it("comments, math, code, keys and the bibliography are not text a reader sees", () => {
336
+ const src =
337
+ "x % AISec\n$AISec$ \\cite{AISec} \\label{AISec} \\url{https://AISec.cc}\n" +
338
+ "\\begin{verbatim}AISec\\end{verbatim}\\begin{thebibliography}{1}\\bibitem{a} AISec\\end{thebibliography}";
339
+ expect(
340
+ latexReader
341
+ .renderedRuns(src)
342
+ .map(runText)
343
+ .some((t) => t.includes("AISec")),
344
+ ).toBe(false);
345
+ });
346
+
347
+ it("a citation's key is hidden, its notes in square brackets are text", () => {
348
+ const runs = latexReader
349
+ .renderedRuns("x \\citep[Presented at AISec][p.~3]{zzkey} y")
350
+ .map(runText);
351
+ expect(runs.some((t) => t.includes("Presented at AISec"))).toBe(true);
352
+ expect(runs.some((t) => t.includes("zzkey"))).toBe(false);
353
+ });
354
+
355
+ it("\\href: its URL is hidden, its link text is text", () => {
356
+ const runs = latexReader
357
+ .renderedRuns("x \\href{https://example.org/AISec}{the zzlink text} y")
358
+ .map(runText);
359
+ expect(runs.some((t) => t.includes("AISec"))).toBe(false);
360
+ expect(runs.some((t) => t.includes("the zzlink text"))).toBe(true);
361
+ });
362
+
363
+ it("a macro definition's body is read like prose: the rule does not expand macros", () => {
364
+ const src =
365
+ "\\newcommand{\\oldvenue}{AISec} \\renewcommand\\b{BISec} \\def\\c{CISec} x";
366
+ const text = latexReader.renderedRuns(src).map(runText).join("|");
367
+ expect(
368
+ ["AISec", "BISec", "CISec", "x"].filter((w) => text.includes(w)),
369
+ ).toEqual(["AISec", "BISec", "CISec", "x"]);
370
+ });
371
+
372
+ it("the options of other key macros stay hidden: they are settings, not prose", () => {
373
+ const src = "\\includegraphics[AISec]{f} \\usepackage[AISec]{p}";
374
+ expect(
375
+ latexReader
376
+ .renderedRuns(src)
377
+ .map(runText)
378
+ .some((t) => t.includes("AISec")),
379
+ ).toBe(false);
380
+ });
381
+ });
382
+
383
+ describe("inPlace — a node the parser gave no position yields nothing, never offset 0", () => {
384
+ it("a span is handed to f; an unplaced node gives the empty list", () => {
385
+ const f = (s: { start: number; end: number }) => [s.start];
386
+ expect(inPlace({ kind: "at", span: { start: 3, end: 5 } }, f)).toEqual([3]);
387
+ expect(inPlace({ kind: "unplaced" }, f)).toEqual([]);
388
+ });
389
+ });
390
+
391
+ /** A prose piece, readable in an expectation: text as itself, a mark or math in angle brackets. */
392
+ const pieceShape = (x: ProsePiece): string =>
393
+ x.kind === "text"
394
+ ? x.segment.text
395
+ : x.kind === "owner"
396
+ ? `<${x.owner}@${String(x.span.start)}>`
397
+ : `<math ${x.tex}>`;
398
+
399
+ describe("bodyProse — passages a sentence cannot cross, with the marks that say where a claim comes from", () => {
400
+ it("text, a citation mark, a reference mark and math, in source order; a footnote is its own passage", () => {
401
+ const src =
402
+ "\\begin{document}\nA~\\cite{k} B \\ref{s} $x$.\\footnote{F.}\n\n\\section{H}C\\%\n\\end{document}";
403
+ const at = (s: string) => src.indexOf(s);
404
+ const shape = bodyProse(parseLatex(src)).map((p) =>
405
+ p.pieces.map(pieceShape),
406
+ );
407
+ expect(shape).toEqual([
408
+ [
409
+ "A",
410
+ " ",
411
+ `<citation@${String(at("\\cite"))}>`,
412
+ " ",
413
+ "B",
414
+ " ",
415
+ `<reference@${String(at("\\ref"))}>`,
416
+ " ",
417
+ "<math $x$>",
418
+ ".",
419
+ ],
420
+ ["F", "."],
421
+ ["C", "%"],
422
+ ]);
423
+ });
424
+
425
+ it("a character macro's character stands at the macro's last source character", () => {
426
+ const src = "C\\%";
427
+ const [p] = bodyProse(parseLatex(src));
428
+ expect(p?.pieces.at(-1)).toEqual({
429
+ kind: "text",
430
+ segment: { text: "%", at: 2 },
431
+ });
432
+ });
433
+ });
434
+
435
+ describe("bodyProse — run-in headings and list items", () => {
436
+ /** The text of each passage of `src`'s body, marks and math left out. */
437
+ const texts = (src: string): readonly string[] =>
438
+ bodyProse(parseLatex(src)).map((p) =>
439
+ p.pieces
440
+ .map((x) => (x.kind === "text" ? x.segment.text : ""))
441
+ .join("")
442
+ .trim(),
443
+ );
444
+
445
+ it("🔴 a run-in heading — a bold, italic or emphasised phrase ending in `.` or `:` that opens a paragraph or an item — is a heading, left out", () => {
446
+ expect(
447
+ texts(
448
+ "\\begin{document}\n\\section{Method}\\label{sec:m}\n\\vspace{2pt}\\textbf{Correctness gate.} Each task carries a check.\n\n\\noindent \\textit{\\textbf{VPNs:}} GPT recommended them.\n\\begin{itemize}\n\\item \\emph{Scale.} It grows.\n\\end{itemize}\n\\end{document}",
449
+ ),
450
+ ).toEqual([
451
+ "Each task carries a check.",
452
+ "GPT recommended them.",
453
+ "It grows.",
454
+ ]);
455
+ });
456
+
457
+ it("🔴 a list item's text is prose, each item its own passage; its label is not — and an \\item outside a list ends a passage", () => {
458
+ expect(
459
+ texts(
460
+ "\\begin{document}\n\\begin{itemize}\n\\item[A:] It grows.\n\\item It holds.\n\\end{itemize}\nAfter \\item the list.\n\\end{document}",
461
+ ),
462
+ ).toEqual(["It grows.", "It holds.", "After", "the list."]);
463
+ });
464
+
465
+ it("a run-in heading holding a footnote or a citation is still a heading, left out whole", () => {
466
+ expect(
467
+ texts(
468
+ "\\begin{document}\n\\textbf{Scale\\footnote{F.}.} It grows.\n\n\\textbf{See~\\cite{k}:} It holds.\n\\end{document}",
469
+ ),
470
+ ).toEqual(["It grows.", "It holds."]);
471
+ });
472
+
473
+ it("the same markup inside a sentence, or without the closing mark, is prose", () => {
474
+ expect(
475
+ texts(
476
+ "\\begin{document}\nWe \\textbf{do not.} stop.\n\n\\textbf{Bold} opens this one.\n\\end{document}",
477
+ ),
478
+ ).toEqual(["We do not. stop.", "Bold opens this one."]);
479
+ });
480
+ });
481
+
482
+ describe("includesOf — the files a source pulls in", () => {
483
+ it("each of \\input, \\include and \\subfile, with the path as written and the macro's span", () => {
484
+ const src = "a\\input{sections/1-intro}\n\\include{b.tex}\\subfile{c_d}";
485
+ expect(includesOf(parseLatex(src))).toEqual([
486
+ {
487
+ macro: "input",
488
+ target: "sections/1-intro",
489
+ span: { start: 1, end: 25 },
490
+ },
491
+ { macro: "include", target: "b.tex", span: { start: 26, end: 41 } },
492
+ { macro: "subfile", target: "c_d", span: { start: 41, end: 54 } },
493
+ ]);
494
+ });
495
+
496
+ it("\\input without braces (TeX's own form) names the file up to the next space", () => {
497
+ const targets = (src: string) =>
498
+ includesOf(parseLatex(src)).map((i) => i.target);
499
+ expect(targets("\\input sections/a \n")).toEqual(["sections/a"]);
500
+ // At the end of the source, the name runs to it.
501
+ expect(targets("\\input sections/a")).toEqual(["sections/a"]);
502
+ });
503
+
504
+ it("an \\input with nothing after it names no file", () => {
505
+ expect(includesOf(parseLatex("\\input"))).toEqual([]);
506
+ });
507
+
508
+ it("not in a comment, not in a macro definition's body, not an empty argument", () => {
509
+ const src = "% \\input{old}\n\\newcommand{\\x}{\\input{y}}\\input{}";
510
+ expect(includesOf(parseLatex(src))).toEqual([]);
511
+ });
512
+
513
+ it("the port answers the same over a source", () => {
514
+ expect(latexReader.includes("\\input{a}").map((i) => i.target)).toEqual([
515
+ "a",
516
+ ]);
517
+ });
518
+ });
519
+
520
+ describe("documentBodyOf — the body of a source that is a document of its own", () => {
521
+ it("from the first to the last node inside the document environment", () => {
522
+ const src =
523
+ "\\documentclass{x}\\begin{document}BODY and more\\end{document}";
524
+ const body = documentBodyOf(parseLatex(src));
525
+ expect(body && src.slice(body.start, body.end)).toBe("BODY and more");
526
+ expect(latexReader.documentBody(src)).toEqual(body);
527
+ });
528
+
529
+ it("none without a document environment, or with an empty one", () => {
530
+ expect(documentBodyOf(parseLatex("just text"))).toBe(null);
531
+ expect(documentBodyOf(parseLatex("\\begin{document}\\end{document}"))).toBe(
532
+ null,
533
+ );
534
+ });
535
+ });
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Reads a paper.tex and answers four questions — checks nothing itself: `documentClassOf` (which
3
+ * class, with which options, and where), `outlineOf` (the sections in order, where the back matter
4
+ * starts, where the document ends), `renderedRuns` (the text a reader sees, each character with
5
+ * its source offset) and `bodyProse` (the body's prose as passages, with its citation and
6
+ * cross-reference marks). Used by `tex/template`, `tex/required-section`, `tex/venue-leftover`,
7
+ * `tex/claim-provenance` and `paperlint new`, through the `LatexReader` port (`src/ports/latex.ts`);
8
+ * `src/cli.ts` wires it.
9
+ *
10
+ * ── WHY THE PARSE TREE AND NOT THE `tex/latex` PROJECTION ────────────────────────
11
+ * The ESLint language's projection (`eslint-rules/latex-language.ts`) blanks the preamble,
12
+ * `\documentclass`, `\author` and every other macro in its OPAQUE list, because prose rules must
13
+ * not see markup. These questions are about exactly that markup: the class line, a `\section*`
14
+ * title, an `\institution{…}` in the author block. So the source is parsed here with unified-latex,
15
+ * the parser the language already uses.
16
+ *
17
+ * ── STATES, NOT NULL ─────────────────────────────────────────────────────────────
18
+ * A class line is missing, empty or a class; a node the parser gave no position is `unplaced`, not
19
+ * offset 0; a run of text is a non-empty list of contiguous segments, and a range outside it has
20
+ * no span (`spanIn` in `src/domain/tex-document.ts`). A tree travels only with its own source
21
+ * (`ParsedTex`, minted by `parseLatex` alone).
22
+ */
23
+ import type { LatexReader } from "../../ports/latex.ts";
24
+ import {
25
+ documentClassOf,
26
+ parseTemplate,
27
+ replaceDocumentClass,
28
+ } from "./document-class.ts";
29
+ import { documentBodyOf, includesOf } from "./includes.ts";
30
+ import { outlineOf } from "./outline.ts";
31
+ import { parseLatex } from "./parse.ts";
32
+ import { bodyProse } from "./prose.ts";
33
+ import { renderedRuns } from "./rendered.ts";
34
+
35
+ export {
36
+ documentClassOf,
37
+ parseTemplate,
38
+ replaceDocumentClass,
39
+ } from "./document-class.ts";
40
+ export {
41
+ collapse,
42
+ inPlace,
43
+ macroPlace,
44
+ mandatory,
45
+ optional,
46
+ placeOf,
47
+ textOf,
48
+ } from "./nodes.ts";
49
+ export { documentBodyOf, includesOf } from "./includes.ts";
50
+ export { outlineOf } from "./outline.ts";
51
+ export { parseLatex, type ParsedTex } from "./parse.ts";
52
+ export { bodyProse } from "./prose.ts";
53
+ export { renderedRuns } from "./rendered.ts";
54
+
55
+ /** The `LatexReader` port over unified-latex: each call parses its source once. */
56
+ export const latexReader: LatexReader = {
57
+ documentClass: (src) => documentClassOf(parseLatex(src)),
58
+ outline: (src) => outlineOf(parseLatex(src)),
59
+ renderedRuns: (src) => renderedRuns(parseLatex(src)),
60
+ bodyProse: (src) => bodyProse(parseLatex(src)),
61
+ includes: (src) => includesOf(parseLatex(src)),
62
+ documentBody: (src) => documentBodyOf(parseLatex(src)),
63
+ template: parseTemplate,
64
+ withDocumentClass: (src, want) => replaceDocumentClass(parseLatex(src), want),
65
+ };