paperlint 5.0.0 → 6.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 (177) hide show
  1. package/CLAUDE.md +13 -0
  2. package/CONTRIBUTING.md +3 -0
  3. package/README.md +8 -8
  4. package/bin/paperlint.mjs +5 -1
  5. package/dist/adapters/latex/index.d.ts +2 -1
  6. package/dist/adapters/latex/index.d.ts.map +1 -1
  7. package/dist/adapters/latex/index.js +6 -2
  8. package/dist/adapters/latex/index.js.map +1 -1
  9. package/dist/adapters/latex/layout.d.ts +27 -0
  10. package/dist/adapters/latex/layout.d.ts.map +1 -0
  11. package/dist/adapters/latex/layout.js +62 -0
  12. package/dist/adapters/latex/layout.js.map +1 -0
  13. package/dist/adapters/latex/nodes.d.ts.map +1 -1
  14. package/dist/adapters/latex/nodes.js +11 -1
  15. package/dist/adapters/latex/nodes.js.map +1 -1
  16. package/dist/adapters/latex/parse.d.ts.map +1 -1
  17. package/dist/adapters/latex/parse.js +5 -1
  18. package/dist/adapters/latex/parse.js.map +1 -1
  19. package/dist/adapters/latex/prose.d.ts +6 -1
  20. package/dist/adapters/latex/prose.d.ts.map +1 -1
  21. package/dist/adapters/latex/prose.js +142 -31
  22. package/dist/adapters/latex/prose.js.map +1 -1
  23. package/dist/cli.d.ts.map +1 -1
  24. package/dist/cli.js +35 -18
  25. package/dist/cli.js.map +1 -1
  26. package/dist/domain/anonymity.d.ts +68 -0
  27. package/dist/domain/anonymity.d.ts.map +1 -0
  28. package/dist/domain/anonymity.js +94 -0
  29. package/dist/domain/anonymity.js.map +1 -0
  30. package/dist/domain/body-pages.d.ts +69 -0
  31. package/dist/domain/body-pages.d.ts.map +1 -0
  32. package/dist/domain/body-pages.js +141 -0
  33. package/dist/domain/body-pages.js.map +1 -0
  34. package/dist/domain/register.d.ts +96 -0
  35. package/dist/domain/register.d.ts.map +1 -0
  36. package/dist/domain/register.js +136 -0
  37. package/dist/domain/register.js.map +1 -0
  38. package/dist/domain/tex-document.d.ts +28 -0
  39. package/dist/domain/tex-document.d.ts.map +1 -1
  40. package/dist/domain/tex-document.js +8 -3
  41. package/dist/domain/tex-document.js.map +1 -1
  42. package/dist/eslint-rules/pdf-last-page-balance.d.ts +1 -1
  43. package/dist/eslint-rules/pdf-last-page-balance.js +2 -2
  44. package/dist/facts-file.d.ts +28 -2
  45. package/dist/facts-file.d.ts.map +1 -1
  46. package/dist/facts-file.js +54 -9
  47. package/dist/facts-file.js.map +1 -1
  48. package/dist/lib/paper-config.d.ts.map +1 -1
  49. package/dist/lib/paper-config.js +2 -1
  50. package/dist/lib/paper-config.js.map +1 -1
  51. package/dist/paper-settings.d.ts +11 -0
  52. package/dist/paper-settings.d.ts.map +1 -1
  53. package/dist/paper-settings.js +38 -4
  54. package/dist/paper-settings.js.map +1 -1
  55. package/dist/pdf-content.d.ts +40 -0
  56. package/dist/pdf-content.d.ts.map +1 -0
  57. package/dist/pdf-content.js +97 -0
  58. package/dist/pdf-content.js.map +1 -0
  59. package/dist/pdf-facts.d.ts +30 -0
  60. package/dist/pdf-facts.d.ts.map +1 -1
  61. package/dist/pdf-facts.js +37 -1
  62. package/dist/pdf-facts.js.map +1 -1
  63. package/dist/ports/latex.d.ts +8 -1
  64. package/dist/ports/latex.d.ts.map +1 -1
  65. package/dist/presets.d.ts +5 -0
  66. package/dist/presets.d.ts.map +1 -1
  67. package/dist/presets.js +8 -1
  68. package/dist/presets.js.map +1 -1
  69. package/dist/register-bands.d.ts +37 -0
  70. package/dist/register-bands.d.ts.map +1 -0
  71. package/dist/register-bands.js +196 -0
  72. package/dist/register-bands.js.map +1 -0
  73. package/dist/register.d.ts +4 -2
  74. package/dist/register.d.ts.map +1 -1
  75. package/dist/register.js +6 -45
  76. package/dist/register.js.map +1 -1
  77. package/dist/tex-requirements.d.ts +10 -0
  78. package/dist/tex-requirements.d.ts.map +1 -1
  79. package/dist/tex-requirements.js +29 -6
  80. package/dist/tex-requirements.js.map +1 -1
  81. package/dist/tex-venue-rules.d.ts +8 -2
  82. package/dist/tex-venue-rules.d.ts.map +1 -1
  83. package/dist/tex-venue-rules.js +40 -0
  84. package/dist/tex-venue-rules.js.map +1 -1
  85. package/dist/types.d.ts +2 -0
  86. package/dist/types.d.ts.map +1 -1
  87. package/dist/venue-rules.d.ts +38 -8
  88. package/dist/venue-rules.d.ts.map +1 -1
  89. package/dist/venue-rules.js +179 -41
  90. package/dist/venue-rules.js.map +1 -1
  91. package/docs/configuration.md +18 -16
  92. package/docs/e2e.md +32 -11
  93. package/docs/rules/anonymity/identity.md +111 -0
  94. package/docs/rules/format/layout-override.md +61 -0
  95. package/docs/rules/format/page-limit.md +108 -0
  96. package/docs/rules/pdf/body-size.md +87 -0
  97. package/docs/rules/tex/claim-emphasis.md +71 -0
  98. package/docs/rules/tex/contrast-frames.md +100 -0
  99. package/docs/rules/tex/relation-markers.md +72 -0
  100. package/docs/rules.md +59 -50
  101. package/eslint-rules/pdf-last-page-balance.harness.mjs +6 -1
  102. package/eslint-rules/pdf-last-page-balance.ts +2 -2
  103. package/fixtures/build-e2e/aidc/expect.json +1 -1
  104. package/fixtures/build-e2e/aidc/paperlint.json +1 -1
  105. package/fixtures/build-e2e/anon-ack/PIPELINE-STATUS.md +3 -0
  106. package/fixtures/build-e2e/anon-ack/expect.json +19 -0
  107. package/fixtures/build-e2e/anon-ack/paper.tex +31 -0
  108. package/fixtures/build-e2e/anon-ack/paperlint.json +1 -0
  109. package/fixtures/build-e2e/anon-author/PIPELINE-STATUS.md +3 -0
  110. package/fixtures/build-e2e/anon-author/expect.json +19 -0
  111. package/fixtures/build-e2e/anon-author/paper.tex +29 -0
  112. package/fixtures/build-e2e/anon-author/paperlint.json +1 -0
  113. package/fixtures/build-e2e/anon-bib/PIPELINE-STATUS.md +3 -0
  114. package/fixtures/build-e2e/anon-bib/expect.json +20 -0
  115. package/fixtures/build-e2e/anon-bib/paper.tex +35 -0
  116. package/fixtures/build-e2e/anon-bib/paperlint.json +1 -0
  117. package/fixtures/build-e2e/anon-href/PIPELINE-STATUS.md +3 -0
  118. package/fixtures/build-e2e/anon-href/expect.json +19 -0
  119. package/fixtures/build-e2e/anon-href/paper.tex +30 -0
  120. package/fixtures/build-e2e/anon-href/paperlint.json +1 -0
  121. package/fixtures/build-e2e/anon-macro/PIPELINE-STATUS.md +3 -0
  122. package/fixtures/build-e2e/anon-macro/expect.json +19 -0
  123. package/fixtures/build-e2e/anon-macro/paper.tex +30 -0
  124. package/fixtures/build-e2e/anon-macro/paperlint.json +1 -0
  125. package/fixtures/build-e2e/anon-pdfauthor/PIPELINE-STATUS.md +3 -0
  126. package/fixtures/build-e2e/anon-pdfauthor/expect.json +19 -0
  127. package/fixtures/build-e2e/anon-pdfauthor/paper.tex +31 -0
  128. package/fixtures/build-e2e/anon-pdfauthor/paperlint.json +1 -0
  129. package/fixtures/build-e2e/unbalanced/expect.json +2 -2
  130. package/lib/paper-config.ts +3 -1
  131. package/package.json +1 -1
  132. package/presets/acm-sigconf.jsonc +3 -1
  133. package/presets/aidc.jsonc +66 -17
  134. package/presets/ieee-conference.jsonc +8 -0
  135. package/presets/venue-profile.schema.json +57 -1
  136. package/skills/grade-paper-writing/SKILL.md +59 -27
  137. package/skills/grade-paper-writing/SKILL.md.spec.ts +58 -26
  138. package/skills/render-paper/extract-pdf-facts.harness.ts +3 -3
  139. package/src/adapters/latex/index.test.ts +91 -0
  140. package/src/adapters/latex/index.ts +6 -2
  141. package/src/adapters/latex/layout.ts +106 -0
  142. package/src/adapters/latex/nodes.ts +11 -3
  143. package/src/adapters/latex/parse.ts +5 -1
  144. package/src/adapters/latex/prose.ts +188 -58
  145. package/src/build.harness.mjs +2 -2
  146. package/src/build.test.ts +5 -0
  147. package/src/cli.test.ts +13 -0
  148. package/src/cli.ts +50 -17
  149. package/src/domain/anonymity.test.ts +163 -0
  150. package/src/domain/anonymity.ts +129 -0
  151. package/src/domain/body-pages.test.ts +300 -0
  152. package/src/domain/body-pages.ts +223 -0
  153. package/src/domain/register.test.ts +153 -0
  154. package/src/domain/register.ts +261 -0
  155. package/src/domain/tex-document.ts +47 -3
  156. package/src/facts-file.test.ts +100 -4
  157. package/src/facts-file.ts +91 -9
  158. package/src/new-paper.test.ts +7 -1
  159. package/src/paper-settings.test.ts +63 -6
  160. package/src/paper-settings.ts +53 -5
  161. package/src/pdf-content.test.ts +71 -0
  162. package/src/pdf-content.ts +125 -0
  163. package/src/pdf-facts.test.ts +110 -2
  164. package/src/pdf-facts.ts +106 -8
  165. package/src/ports/latex.ts +9 -0
  166. package/src/presets.ts +15 -9
  167. package/src/register-bands.test.ts +327 -0
  168. package/src/register-bands.ts +291 -0
  169. package/src/register.ts +7 -37
  170. package/src/rule-cases.test.ts +96 -20
  171. package/src/rule-docs.test.ts +0 -2
  172. package/src/tex-requirements.ts +56 -8
  173. package/src/tex-venue-rules.test.ts +110 -5
  174. package/src/tex-venue-rules.ts +64 -3
  175. package/src/types.ts +2 -0
  176. package/src/venue-rules.test.ts +351 -39
  177. package/src/venue-rules.ts +247 -47
@@ -0,0 +1,300 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ bodyEnd,
4
+ isAppendixHeading,
5
+ isReferencesHeading,
6
+ } from "./body-pages.ts";
7
+
8
+ describe("isReferencesHeading", () => {
9
+ it.each([
10
+ "References",
11
+ "REFERENCES",
12
+ "R EFERENCES",
13
+ "Bibliography",
14
+ "7 References",
15
+ "VII. REFERENCES",
16
+ " References ",
17
+ ])("%j is a heading", (line) => {
18
+ expect(isReferencesHeading(line)).toBe(true);
19
+ });
20
+
21
+ it.each(["See the references below", "References to prior work", "Refs", ""])(
22
+ "%j is not",
23
+ (line) => {
24
+ expect(isReferencesHeading(line)).toBe(false);
25
+ },
26
+ );
27
+ });
28
+
29
+ describe("bodyEnd", () => {
30
+ it("a heading below body text on its page: that page is a body page", () => {
31
+ expect(
32
+ bodyEnd([
33
+ "Intro\nbody",
34
+ "more body",
35
+ "end of the body\nReferences\n[1] A.",
36
+ "Appendix",
37
+ ]),
38
+ ).toEqual({ kind: "found", by: "references", page: 3, bodyPages: 3 });
39
+ });
40
+
41
+ it("a heading that is the first text of its page: the body ends on the page before", () => {
42
+ expect(bodyEnd(["Intro\nbody", "more body", "References\n[1] A."])).toEqual(
43
+ { kind: "found", by: "references", page: 3, bodyPages: 2 },
44
+ );
45
+ });
46
+
47
+ it("empty lines and a bare page number above the heading are not body", () => {
48
+ expect(bodyEnd(["body", "\n \n7\nR EFERENCES\n[1] x"])).toEqual({
49
+ kind: "found",
50
+ by: "references",
51
+ page: 2,
52
+ bodyPages: 1,
53
+ });
54
+ });
55
+
56
+ it("a statement or appendix after the references is not counted", () => {
57
+ expect(
58
+ bodyEnd([
59
+ "body",
60
+ "R EFERENCES\n[1] x\nLLM Usage Statement",
61
+ "Appendix A",
62
+ ]),
63
+ ).toEqual({ kind: "found", by: "references", page: 2, bodyPages: 1 });
64
+ });
65
+
66
+ it("no heading on any page is said, not guessed", () => {
67
+ expect(bodyEnd(["body", "more"])).toEqual({ kind: "missing" });
68
+ expect(bodyEnd([])).toEqual({ kind: "missing" });
69
+ });
70
+ });
71
+
72
+ describe("bodyEnd: a «References» line that is not the heading", () => {
73
+ const BODY = [
74
+ "Intro\nbody",
75
+ "Related work\nReferences\nto prior work are many.",
76
+ ];
77
+
78
+ it("🔴 a bare «References» line in the body loses to a heading followed by [1]", () => {
79
+ expect(
80
+ bodyEnd([...BODY, "more body", "last body line\nReferences\n[1] A."]),
81
+ ).toEqual({ kind: "found", by: "references", page: 4, bodyPages: 4 });
82
+ });
83
+
84
+ it("🔴 with hyperref's anchor, a bare «References» on an earlier page is ignored", () => {
85
+ expect(
86
+ bodyEnd([...BODY, "References\n[1] A.", "Appendix"], {
87
+ bib: 3,
88
+ appendix: null,
89
+ }),
90
+ ).toEqual({ kind: "found", by: "references", page: 3, bodyPages: 2 });
91
+ });
92
+
93
+ it("🔴 a table header «References» above the heading on the anchor's page: the heading is the confirmed one", () => {
94
+ expect(
95
+ bodyEnd(["body", "TABLE 2\nTool\nReferences\nx\nReferences\n[1] A."], {
96
+ bib: 2,
97
+ appendix: null,
98
+ }),
99
+ ).toEqual({ kind: "found", by: "references", page: 2, bodyPages: 2 });
100
+ });
101
+
102
+ it("with an anchor and no [1] after the heading, the last heading line on the anchor's page is taken", () => {
103
+ expect(
104
+ bodyEnd(["body", "Tool\nReferences\nx\nReferences\nAbel, A."], {
105
+ bib: 2,
106
+ appendix: null,
107
+ }),
108
+ ).toEqual({ kind: "found", by: "references", page: 2, bodyPages: 2 });
109
+ expect(
110
+ bodyEnd(["body", "References\nAbel, A. (2020)."], {
111
+ bib: 2,
112
+ appendix: null,
113
+ }),
114
+ ).toEqual({ kind: "found", by: "references", page: 2, bodyPages: 1 });
115
+ });
116
+
117
+ it("without an anchor or a confirmed heading, the first bare heading is the fallback", () => {
118
+ expect(bodyEnd(["body", "References\nAbel, A. (2020)."])).toEqual({
119
+ kind: "found",
120
+ by: "references",
121
+ page: 2,
122
+ bodyPages: 1,
123
+ });
124
+ });
125
+ });
126
+
127
+ describe("bodyEnd: the two signals disagree", () => {
128
+ it("the anchor's page has no heading line: said, with the anchor", () => {
129
+ expect(
130
+ bodyEnd(["body", "References\n[1] A.", "[2] B."], {
131
+ bib: 3,
132
+ appendix: null,
133
+ }),
134
+ ).toEqual({
135
+ kind: "disagree",
136
+ what: "references",
137
+ heading: null,
138
+ anchor: 3,
139
+ });
140
+ });
141
+
142
+ it("a confirmed heading on another page than the anchor's: said, with both", () => {
143
+ expect(
144
+ bodyEnd(["body\nReferences\n[1] quoted", "References\n[1] A."], {
145
+ bib: 2,
146
+ appendix: null,
147
+ }),
148
+ ).toEqual({ kind: "disagree", what: "references", heading: 1, anchor: 2 });
149
+ });
150
+
151
+ it("an anchor past the last page is a disagreement, not a crash", () => {
152
+ expect(bodyEnd(["References\n[1] A."], { bib: 5, appendix: null })).toEqual(
153
+ {
154
+ kind: "disagree",
155
+ what: "references",
156
+ heading: null,
157
+ anchor: 5,
158
+ },
159
+ );
160
+ });
161
+ });
162
+
163
+ describe("bodyEnd: running headers and footers are not body", () => {
164
+ /** A page of an ACM-style paper: a running header, then `text`. */
165
+ const page = (n: number, text: string) =>
166
+ `Conf '26, June ${String(n)}, Place\nAuthors\n${text}`;
167
+
168
+ it("🔴 a heading at the top of its page, under a running header: the page is not body", () => {
169
+ const pages = [1, 2, 3].map((n) => page(n, `body ${String(n)}`));
170
+ expect(bodyEnd([...pages, page(4, "References\n[1] A.")])).toEqual({
171
+ kind: "found",
172
+ by: "references",
173
+ page: 4,
174
+ bodyPages: 3,
175
+ });
176
+ });
177
+
178
+ it("body text under the header and above the heading still makes the page body", () => {
179
+ const pages = [1, 2, 3].map((n) => page(n, `body ${String(n)}`));
180
+ expect(
181
+ bodyEnd([...pages, page(4, "the end of the body\nReferences\n[1] A.")]),
182
+ ).toEqual({ kind: "found", by: "references", page: 4, bodyPages: 4 });
183
+ });
184
+
185
+ it("a line on only two pages is not a running header: it counts as body", () => {
186
+ expect(bodyEnd(["Setup\nbody", "x", "Setup\nReferences\n[1] A."])).toEqual({
187
+ kind: "found",
188
+ by: "references",
189
+ page: 3,
190
+ bodyPages: 3,
191
+ });
192
+ });
193
+ });
194
+
195
+ describe("isAppendixHeading", () => {
196
+ it.each([
197
+ "Appendix A.",
198
+ "APPENDIX",
199
+ "A PPENDIX B",
200
+ "Appendices",
201
+ "Appendix 1:",
202
+ ])("%j is a heading", (line) => {
203
+ expect(isAppendixHeading(line)).toBe(true);
204
+ });
205
+ it.each([
206
+ "Appendix A shows the rest.",
207
+ "See the appendix",
208
+ "Appendix A Proofs",
209
+ "",
210
+ ])("%j is not", (line) => {
211
+ expect(isAppendixHeading(line)).toBe(false);
212
+ });
213
+ });
214
+
215
+ describe("bodyEnd: an appendix before the references ends the body", () => {
216
+ const PAGES = [
217
+ "body",
218
+ "more body",
219
+ "the conclusion ends here",
220
+ "Appendix A.\nDetailed results\nTABLE 3",
221
+ "References\n[1] A.",
222
+ ];
223
+
224
+ it("🔴 the appendix at the top of its page, the references after it: the body ends on the page before", () => {
225
+ expect(bodyEnd(PAGES, { bib: 5, appendix: 4 })).toEqual({
226
+ kind: "found",
227
+ by: "appendix",
228
+ page: 4,
229
+ bodyPages: 3,
230
+ });
231
+ });
232
+
233
+ it("an appendix that starts below the conclusion on its page: that page is body", () => {
234
+ const pages = [...PAGES];
235
+ pages[3] = "the conclusion ends here\nAppendix A.\nDetails";
236
+ expect(bodyEnd(pages, { bib: 5, appendix: 4 })).toEqual({
237
+ kind: "found",
238
+ by: "appendix",
239
+ page: 4,
240
+ bodyPages: 4,
241
+ });
242
+ });
243
+
244
+ it("an appendix after the references changes nothing", () => {
245
+ expect(
246
+ bodyEnd(["body", "end\nReferences\n[1] A.", "Appendix A.\nx"], {
247
+ bib: 2,
248
+ appendix: 3,
249
+ }),
250
+ ).toEqual({ kind: "found", by: "references", page: 2, bodyPages: 2 });
251
+ });
252
+
253
+ it("the appendix and the references on one page: the heading that comes first ends the body", () => {
254
+ expect(
255
+ bodyEnd(["body", "end\nAppendix A.\nx\nReferences\n[1] A."], {
256
+ bib: 2,
257
+ appendix: 2,
258
+ }),
259
+ ).toEqual({ kind: "found", by: "appendix", page: 2, bodyPages: 2 });
260
+ });
261
+
262
+ it("an appendix and no references heading at all: the appendix ends the body", () => {
263
+ expect(
264
+ bodyEnd(["body", "Appendix\nx"], { bib: null, appendix: 2 }),
265
+ ).toEqual({
266
+ kind: "found",
267
+ by: "appendix",
268
+ page: 2,
269
+ bodyPages: 1,
270
+ });
271
+ });
272
+ });
273
+
274
+ describe("bodyEnd: where the appendix is not taken, or not found", () => {
275
+ it("without hyperref's anchor the appendix is not looked for: a sentence can end on «Appendix A.»", () => {
276
+ expect(
277
+ bodyEnd(["body, detailed in\nAppendix A.", "end\nReferences\n[1] A."]),
278
+ ).toEqual({ kind: "found", by: "references", page: 2, bodyPages: 2 });
279
+ });
280
+
281
+ it("hyperref anchors the appendix on a page with no line reading «Appendix»: said", () => {
282
+ expect(
283
+ bodyEnd(["body", "A Proofs\nx"], { bib: null, appendix: 2 }),
284
+ ).toEqual({
285
+ kind: "disagree",
286
+ what: "appendix",
287
+ heading: null,
288
+ anchor: 2,
289
+ });
290
+ });
291
+
292
+ it("an appendix anchor past the last page is a disagreement, not a crash", () => {
293
+ expect(bodyEnd(["body"], { bib: null, appendix: 4 })).toEqual({
294
+ kind: "disagree",
295
+ what: "appendix",
296
+ heading: null,
297
+ anchor: 4,
298
+ });
299
+ });
300
+ });
@@ -0,0 +1,223 @@
1
+ /**
2
+ * HOW MANY PAGES THE BODY TAKES, counted from what the PDF says rather than from how banal
3
+ * classifies its pages. A venue that limits the body «excluding references and appendices» (AIDC)
4
+ * cannot be judged on banal's body count: banal calls any page holding non-bibliography text body,
5
+ * so an LLM Usage Statement or an appendix placed after the bibliography is counted as body, and a
6
+ * correct paper fails (measured, `presets/aidc.jsonc`).
7
+ *
8
+ * ── WHERE THE BODY ENDS: AT THE REFERENCES, OR AT THE APPENDIX, WHICHEVER COMES FIRST ──────
9
+ * «Excluding references and appendices» excludes an appendix wherever it sits, so the body ends at
10
+ * the first of the two. Each is found by a structural signal first:
11
+ *
12
+ * references the page of the first bibliography entry, from the destination hyperref writes for
13
+ * every `\bibitem` (`cite.<key>`); then, on that page, the heading line: a line that
14
+ * reads «References» or «Bibliography» (any case, optionally numbered), the one
15
+ * CONFIRMED by a next line opening the first entry, `[1]`, else the last one there.
16
+ * Without hyperref: the first confirmed heading, else the first bare one — a table
17
+ * header or a subsection titled «References» reads the same, so it is a fallback.
18
+ * appendix the page hyperref anchored the appendix on (an `appendix.<letter>` destination, or a
19
+ * bookmark titled «Appendix…»); then, on that page, the line reading «Appendix»,
20
+ * «Appendix A.», «APPENDIX» or «Appendices». Without hyperref the appendix is not
21
+ * looked for: a sentence can end on a line reading «Appendix A.», and nothing tells
22
+ * it from the heading.
23
+ *
24
+ * A structural signal whose page has no heading line, or a confirmed references heading on another
25
+ * page than hyperref's first entry, is a disagreement, and it is said rather than resolved by
26
+ * picking one.
27
+ *
28
+ * ── THE PAGE THE BODY ENDS ON ──────────────────────────────────────────────────────
29
+ * Body text above the heading on that page makes it a body page: a body that runs half a page past
30
+ * the limit is over the limit, and the count must flag it rather than let a desk reject through.
31
+ * Only when the heading is the first text of its page does the body end on the page before. A line
32
+ * with no letter (a page number) is not body text, and neither is a running header or footer — a
33
+ * line that repeats, digits aside, on at least three pages and a third of them.
34
+ */
35
+
36
+ /** What ends the body. */
37
+ export type BodyEndsAt = "references" | "appendix";
38
+
39
+ /** Where hyperref anchored the bibliography's first entry and the appendix: 1-based pages, or null. */
40
+ export interface Anchors {
41
+ readonly bib: number | null;
42
+ readonly appendix: number | null;
43
+ }
44
+
45
+ /** Where the body ends, that no page says, or that a structural signal and the text disagree. */
46
+ export type BodyEnd =
47
+ | {
48
+ readonly kind: "found";
49
+ readonly by: BodyEndsAt;
50
+ /** 1-based page holding the heading that ends the body. */
51
+ readonly page: number;
52
+ /** The body, as the venue counts it: the heading's page too, when body text stands above it. */
53
+ readonly bodyPages: number;
54
+ }
55
+ | { readonly kind: "missing" }
56
+ | {
57
+ readonly kind: "disagree";
58
+ readonly what: BodyEndsAt;
59
+ /** The page of the first confirmed references heading, or null when the anchor's page has no heading. */
60
+ readonly heading: number | null;
61
+ /** The page hyperref anchored it on. */
62
+ readonly anchor: number;
63
+ };
64
+
65
+ /**
66
+ * A line that is a references heading: `References` or `Bibliography`, in any case (IEEEtran sets
67
+ * `REFERENCES` in small caps, which pdf.js may split as `R EFERENCES`), optionally numbered
68
+ * (`7 References`, `VII. REFERENCES`). The line is compared with every space removed.
69
+ */
70
+ const HEADING = /^(?:(?:\d+|[ivxlcdm]+)\.?)?(?:references|bibliography)$/iu;
71
+
72
+ /** Whether one line of a page's text is the references heading. */
73
+ export const isReferencesHeading = (line: string): boolean =>
74
+ HEADING.test(line.normalize("NFKC").replace(/\s+/gu, ""));
75
+
76
+ /** A line that is an appendix heading: `Appendix A.`, `APPENDIX`, `Appendices`, spaces removed. */
77
+ const APPENDIX_HEADING = /^appendi(?:x|ces)(?:[a-z]|\d+)?[.:]?$/iu;
78
+
79
+ /** Whether one line of a page's text is an appendix heading. */
80
+ export const isAppendixHeading = (line: string): boolean =>
81
+ APPENDIX_HEADING.test(line.normalize("NFKC").replace(/\s+/gu, ""));
82
+
83
+ /** Whether a line opens the first entry of a numbered bibliography: `[1]`. */
84
+ const isFirstEntry = (line: string): boolean => /^\s*\[\s*1\s*\]/u.test(line);
85
+
86
+ /** A page's lines, and where its headings are: the confirmed one, and every one. */
87
+ interface PageReading {
88
+ readonly lines: readonly string[];
89
+ /** Line index of a heading whose next non-empty line is `[1]`, or -1. */
90
+ readonly confirmed: number;
91
+ /** Line indices of every heading line. */
92
+ readonly headings: readonly number[];
93
+ }
94
+
95
+ function readPage(text: string): PageReading {
96
+ const lines = text.split("\n");
97
+ const headings = lines.flatMap((l, i) => (isReferencesHeading(l) ? [i] : []));
98
+ const confirmed =
99
+ headings.find((i) =>
100
+ isFirstEntry(lines.slice(i + 1).find((l) => l.trim() !== "") ?? ""),
101
+ ) ?? -1;
102
+ return { lines, confirmed, headings };
103
+ }
104
+
105
+ /** A line with digits blanked and spaces collapsed: what a running header keeps from page to page. */
106
+ const shape = (line: string): string =>
107
+ line.replace(/\d+/gu, "#").replace(/\s+/gu, " ").trim();
108
+
109
+ /** The shapes of the lines that repeat on at least three pages and a third of them. */
110
+ function runningLines(pages: readonly PageReading[]): ReadonlySet<string> {
111
+ const onPages = pages.flatMap((p) => [...new Set(p.lines.map(shape))]);
112
+ const sorted = onPages.toSorted();
113
+ const floor = Math.max(3, pages.length / 3);
114
+ // In sorted order, a shape on `floor` pages is the same string `floor - 1` places further on.
115
+ return new Set(
116
+ sorted.filter((s, i) => sorted[i + Math.ceil(floor) - 1] === s),
117
+ );
118
+ }
119
+
120
+ /** The heading line the references start at on a page: the confirmed one, else the last one. */
121
+ const startLine = (p: PageReading): number =>
122
+ p.confirmed >= 0 ? p.confirmed : (p.headings.at(-1) ?? -1);
123
+
124
+ /** A heading that ends the body: its 0-based page and its line there. */
125
+ interface At {
126
+ readonly kind: "at";
127
+ readonly by: BodyEndsAt;
128
+ readonly page: number;
129
+ readonly line: number;
130
+ /** The lines of that page above the heading. */
131
+ readonly above: readonly string[];
132
+ }
133
+ type Start = At | Extract<BodyEnd, { readonly kind: "disagree" | "missing" }>;
134
+
135
+ /** The references heading at line `line` of 0-based page `page`. */
136
+ const at = (page: number, p: PageReading, line: number): At => ({
137
+ kind: "at",
138
+ by: "references",
139
+ page,
140
+ line,
141
+ above: p.lines.slice(0, line),
142
+ });
143
+
144
+ /** Where the references start: at hyperref's first entry's page, else at the heading in the text. */
145
+ function referencesAt(
146
+ pages: readonly PageReading[],
147
+ anchor: number | null,
148
+ ): Start {
149
+ const confirmed = pages.findIndex((p) => p.confirmed >= 0);
150
+ if (anchor !== null) {
151
+ const p = pages[anchor - 1];
152
+ const line = p === undefined ? -1 : startLine(p);
153
+ if (p === undefined || line < 0)
154
+ return { kind: "disagree", what: "references", heading: null, anchor };
155
+ if (confirmed >= 0 && confirmed !== anchor - 1)
156
+ return {
157
+ kind: "disagree",
158
+ what: "references",
159
+ heading: confirmed + 1,
160
+ anchor,
161
+ };
162
+ return at(anchor - 1, p, line);
163
+ }
164
+ const i =
165
+ confirmed >= 0 ? confirmed : pages.findIndex((p) => p.headings.length > 0);
166
+ const p = pages[i];
167
+ return p === undefined ? { kind: "missing" } : at(i, p, startLine(p));
168
+ }
169
+
170
+ /** Where the appendix starts: on hyperref's page for it, at the line reading «Appendix». */
171
+ function appendixAt(
172
+ pages: readonly PageReading[],
173
+ anchor: number | null,
174
+ ): Start {
175
+ if (anchor === null) return { kind: "missing" };
176
+ const lines = pages[anchor - 1]?.lines ?? [];
177
+ const line = lines.findIndex(isAppendixHeading);
178
+ return line < 0
179
+ ? { kind: "disagree", what: "appendix", heading: null, anchor }
180
+ : {
181
+ kind: "at",
182
+ by: "appendix",
183
+ page: anchor - 1,
184
+ line,
185
+ above: lines.slice(0, line),
186
+ };
187
+ }
188
+
189
+ /** The body's end at a heading: the heading's page counts when body text stands above it. */
190
+ function endAt(pages: readonly PageReading[], at: At): BodyEnd {
191
+ const running = runningLines(pages);
192
+ const body = at.above.some((l) => /\p{L}/u.test(l) && !running.has(shape(l)));
193
+ return {
194
+ kind: "found",
195
+ by: at.by,
196
+ page: at.page + 1,
197
+ bodyPages: body ? at.page + 1 : at.page,
198
+ };
199
+ }
200
+
201
+ /**
202
+ * Where the body ends — at the references or the appendix, whichever comes first — from each
203
+ * page's text and where hyperref anchored the two. Pure.
204
+ */
205
+ export function bodyEnd(
206
+ texts: readonly string[],
207
+ anchors: Anchors = { bib: null, appendix: null },
208
+ ): BodyEnd {
209
+ const pages = texts.map(readPage);
210
+ const starts = [
211
+ referencesAt(pages, anchors.bib),
212
+ appendixAt(pages, anchors.appendix),
213
+ ];
214
+ const disagree = starts.find(
215
+ (s): s is Extract<BodyEnd, { readonly kind: "disagree" }> =>
216
+ s.kind === "disagree",
217
+ );
218
+ if (disagree !== undefined) return disagree;
219
+ const first = starts
220
+ .filter((s): s is At => s.kind === "at")
221
+ .toSorted((a, b) => a.page - b.page || a.line - b.line)[0];
222
+ return first === undefined ? { kind: "missing" } : endAt(pages, first);
223
+ }
@@ -0,0 +1,153 @@
1
+ /**
2
+ * The register measures on real parses: what counts as a contrast frame, a relation marker and a
3
+ * claim in bold, and what does not; the band the anchors set; the excerpt a finding quotes.
4
+ * Every "does not count" case below is a sentence shape found in an accepted ACSAC paper.
5
+ */
6
+ import { describe, expect, it } from "vitest";
7
+ import { latexReader } from "../adapters/latex/index.ts";
8
+ import {
9
+ bandOf,
10
+ bodySentences,
11
+ claimEmphasis,
12
+ contrastFrames,
13
+ excerpt,
14
+ rateOf,
15
+ relationMarkers,
16
+ type RegisterAnchor,
17
+ } from "./register.ts";
18
+
19
+ const doc = (body: string): string =>
20
+ `\\documentclass{article}\n\\begin{document}\n${body}\n\\end{document}\n`;
21
+ const sentencesOf = (body: string) =>
22
+ bodySentences(latexReader.bodyProse(doc(body)));
23
+ const frames = (body: string): number =>
24
+ contrastFrames(sentencesOf(body)).length;
25
+ const markers = (body: string): number =>
26
+ relationMarkers(sentencesOf(body)).length;
27
+ const claims = (body: string): readonly string[] =>
28
+ claimEmphasis(latexReader.bodyEmphasis(doc(body))).map((o) => o.match);
29
+
30
+ describe("contrastFrames — a claim set against a rejected alternative", () => {
31
+ it.each([
32
+ [
33
+ "X, not Y",
34
+ "They react to the dangerous word, not the dangerous operation.",
35
+ ],
36
+ ["X --- not Y", "It guarantees encryption --- not authenticity."],
37
+ ["X; not Y", "We score the operation; not the attacker."],
38
+ ["X, and not Y", "It reads the field, and not the raw input."],
39
+ ["not X but Y", "Over-blocking is not a side metric but a precondition."],
40
+ ["rather than", "It fires on the mention rather than the act."],
41
+ ["instead of", "It reads the whole event instead of the command."],
42
+ ["as opposed to", "It matches words as opposed to operations."],
43
+ ])("🔴 %s is one frame", (_form, sentence) => {
44
+ expect(frames(sentence)).toBe(1);
45
+ });
46
+
47
+ it.each([
48
+ ["not only … but also", "It is not only fast but also exact."],
49
+ [
50
+ "a negated clause",
51
+ "The nature of the audit is not reported, but it did not assess quality.",
52
+ ],
53
+ ["«, not yet»", "The fix is merged, not yet released."],
54
+ [
55
+ "«, not already in the corpus»",
56
+ "We kept every guard published since, not already in the corpus.",
57
+ ],
58
+ ["«without»", "The attack works without any access to the device."],
59
+ ["«unlike»", "Unlike prior work, we measure the installed guards."],
60
+ ["a bare negation", "The guard does not block it."],
61
+ ])("%s is no frame", (_form, sentence) => {
62
+ expect(frames(sentence)).toBe(0);
63
+ });
64
+
65
+ it("two frames in one sentence are two; one phrase matching two forms is one", () => {
66
+ expect(
67
+ frames("It reads the word, not the act, rather than the operation."),
68
+ ).toBe(2);
69
+ expect(frames("It is not a word but rather than a verb.")).toBe(2);
70
+ });
71
+
72
+ it("a frame in a table, a caption or a heading is not prose, so not counted", () => {
73
+ expect(
74
+ frames(
75
+ "\\section{Words, not operations}\n\\begin{table}\\caption{Words, not operations.}\\begin{tabular}{l}a, not b\\end{tabular}\\end{table}\nPlain text here.",
76
+ ),
77
+ ).toBe(0);
78
+ });
79
+ });
80
+
81
+ describe("relationMarkers — words that name how a sentence relates to the one before", () => {
82
+ it("🔴 each marker counts where it stands, sentence-initial or not", () => {
83
+ expect(
84
+ markers(
85
+ "However, the rate is low. It fails because the rule matches words. For example, echo is blocked. It is therefore unsafe.",
86
+ ),
87
+ ).toBe(4);
88
+ });
89
+
90
+ it("a marker inside a longer word is not one: «thusly» is no «thus», «sincere» no «since»", () => {
91
+ expect(
92
+ markers("The sincere author wrote thusly. Eventually it held."),
93
+ ).toBe(0);
94
+ });
95
+ });
96
+
97
+ describe("claimEmphasis — a claim set in bold inside a sentence", () => {
98
+ it("🔴 a bold phrase of six words or more, or holding a number, inside a sentence", () => {
99
+ expect(
100
+ claims(
101
+ "We find that \\textbf{they react to the dangerous word}. The \\textbf{median coverage is 2.5/10} here.",
102
+ ),
103
+ ).toEqual([
104
+ "they react to the dangerous word",
105
+ "median coverage is 2.5/10",
106
+ ]);
107
+ });
108
+
109
+ it("a term in bold, a label opening a paragraph or item, italics, and a run-in heading are not claims", () => {
110
+ expect(
111
+ claims(
112
+ "BaroVox offers a \\textbf{Pressure-Acoustic Transformation (PAT)} here.\n\n\\textbf{Varying the orientation of the sound source from the device}. We vary it.\n\\begin{itemize}\n\\item \\textbf{A corpus of user security questions with sources}: we release it.\n\\end{itemize}\nWe \\emph{find that the guards react to the word}.\n\n\\textbf{Threats to validity of the 46 guards.} None.",
113
+ ),
114
+ ).toEqual([]);
115
+ });
116
+ });
117
+
118
+ describe("bandOf — the anchors' range, widened by two Poisson standard deviations", () => {
119
+ const anchor = (words: number, contrast_frames: number): RegisterAnchor => ({
120
+ paper: "p",
121
+ words,
122
+ counts: { contrast_frames, claim_emphasis: 0, relation_markers: 0 },
123
+ });
124
+
125
+ it("🔴 the edges stand two standard deviations of the edge anchor's count beyond it", () => {
126
+ // 4 in 10,000 words: rate 4, sd 2 → max 8. 1 in 10,000: rate 1, sd 1 → min 1 - 2 < 0 → 0.
127
+ expect(
128
+ bandOf([anchor(10_000, 4), anchor(10_000, 1)], "contrast_frames"),
129
+ ).toEqual({ min: 0, max: 8, anchorMin: 1, anchorMax: 4, anchors: 2 });
130
+ });
131
+
132
+ it("an anchor with no occurrence is widened as if it had one; a floor above zero stays", () => {
133
+ expect(bandOf([anchor(10_000, 0)], "contrast_frames")?.max).toBe(2);
134
+ expect(bandOf([anchor(10_000, 100)], "contrast_frames")?.min).toBe(80);
135
+ });
136
+
137
+ it("no anchors, no band; a body of no words has a rate of zero", () => {
138
+ expect(bandOf([], "contrast_frames")).toBeNull();
139
+ expect(rateOf(3, 0)).toBe(0);
140
+ });
141
+ });
142
+
143
+ describe("excerpt — the words around an occurrence, as a finding quotes them", () => {
144
+ it("the match with its neighbours, cut at a word, an ellipsis where the sentence goes on", () => {
145
+ const s =
146
+ "We score 46 gates and find that they react to the dangerous word, not the dangerous operation, in every one of the ten cases we built.";
147
+ const at = s.indexOf(", not");
148
+ expect(excerpt(s, at, at + 6)).toBe(
149
+ "…that they react to the dangerous word, not the dangerous operation, in every one…",
150
+ );
151
+ expect(excerpt("a, not b", 1, 7)).toBe("a, not b");
152
+ });
153
+ });