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
package/src/pdf-facts.ts CHANGED
@@ -34,6 +34,15 @@ import {
34
34
  import type { PageLayout, TextBox } from "./domain/page-layout.ts";
35
35
  // eslint-disable-next-line boundaries/dependencies -- legacy layer, moves behind a port in #76
36
36
  import { fillsFor, isUpright } from "./adapters/pdfjs/fill.ts";
37
+ import {
38
+ appendixOutlineDests,
39
+ linkTargetsOf,
40
+ metadataOf,
41
+ namedRefs,
42
+ pageTextOf,
43
+ refOf,
44
+ xmpOf,
45
+ } from "./pdf-content.ts";
37
46
  import { messageOf } from "./domain/text.ts";
38
47
  import { fieldOf, isRecord, numbersOf } from "./domain/record.ts";
39
48
 
@@ -83,12 +92,27 @@ interface Page {
83
92
  [Symbol.iterator](): Iterator<readonly unknown[]>;
84
93
  };
85
94
  getViewport(params: { scale: number }): Viewport;
95
+ /** pdf.js's annotations; the link ones carry `url`. */
96
+ getAnnotations(): Promise<readonly unknown[]>;
86
97
  }
87
98
 
88
99
  /** A document as this module reads it; pdf.js's `PDFDocumentProxy` is one. */
89
100
  interface Doc {
90
101
  readonly numPages: number;
91
102
  getPage(pageNumber: number): Promise<Page>;
103
+ /** The Info dictionary, and the XMP packet when there is one. */
104
+ getMetadata(): Promise<{
105
+ readonly info: unknown;
106
+ readonly metadata: Iterable<unknown> | null;
107
+ }>;
108
+ /** Every named destination: name → explicit destination. */
109
+ getDestinations(): Promise<unknown>;
110
+ /** The 0-based index of the page a destination's reference names. */
111
+ getPageIndex(ref: object): Promise<number>;
112
+ /** One named destination, or null. */
113
+ getDestination(id: string): Promise<unknown>;
114
+ /** The bookmarks: `{ title, dest, items }`, or null. */
115
+ getOutline(): Promise<unknown>;
92
116
  }
93
117
 
94
118
  /** A viewport point, which pdf.js types as `any[]`: the two numbers it holds. */
@@ -106,6 +130,22 @@ export interface PdfFacts {
106
130
  readonly fonts: Fonts;
107
131
  readonly last: PageText;
108
132
  readonly layout: readonly PageLayout[];
133
+ /** Each page's text, in content order, a line break after each line (`pageTextOf`). */
134
+ readonly pageTexts: readonly string[];
135
+ /** The target of every link, with its 1-based page. */
136
+ readonly links: readonly PdfLink[];
137
+ /** The 1-based page of the first bibliography entry hyperref anchored, or null without one. */
138
+ readonly bibAnchorPage: number | null;
139
+ /** The 1-based page the appendix starts on, from hyperref's destinations and bookmarks, or null. */
140
+ readonly appendixAnchorPage: number | null;
141
+ /** The Info dictionary and XMP packet, flat (`metadataOf`). */
142
+ readonly metadata: Readonly<Record<string, string>>;
143
+ }
144
+
145
+ /** A link annotation's target, and the page it is on. */
146
+ export interface PdfLink {
147
+ readonly page: number;
148
+ readonly uri: string;
109
149
  }
110
150
 
111
151
  export type PdfReadFailure =
@@ -198,10 +238,17 @@ async function readPage(
198
238
  page: Page,
199
239
  seen: ReadonlySet<string>,
200
240
  lib: PdfJs,
201
- ): Promise<{ raw: RawPage; items: TextItem[]; layout: PageLayout }> {
241
+ ): Promise<{
242
+ raw: RawPage;
243
+ items: TextItem[];
244
+ layout: PageLayout;
245
+ text: string;
246
+ links: readonly string[];
247
+ }> {
202
248
  // The operator list is what makes pdf.js load a page's fonts into `commonObjs`.
203
249
  const ops = await page.getOperatorList();
204
- const items = (await page.getTextContent()).items.filter(isText);
250
+ const all = (await page.getTextContent()).items;
251
+ const items = all.filter(isText);
205
252
  const loaded = loadedFonts(page);
206
253
  const named = new Set(items.map((it) => it.fontName));
207
254
  const fonts = [...loaded.values()].filter(
@@ -211,6 +258,8 @@ async function readPage(
211
258
  raw: { hasText: items.length > 0, fonts },
212
259
  items,
213
260
  layout: layoutOf(page, items, ops, lib),
261
+ text: pageTextOf(all),
262
+ links: linkTargetsOf(await page.getAnnotations()),
214
263
  };
215
264
  }
216
265
 
@@ -310,12 +359,7 @@ function layoutOf(
310
359
  * transform, no pages) — `readPdf` below is the one production caller.
311
360
  */
312
361
  export async function factsOf(doc: Doc, lib: PdfJs): Promise<PdfRead> {
313
- const read: {
314
- page: Page;
315
- raw: RawPage;
316
- items: TextItem[];
317
- layout: PageLayout;
318
- }[] = [];
362
+ const read: (Awaited<ReturnType<typeof readPage>> & { page: Page })[] = [];
319
363
  const seen = new Set<string>();
320
364
  for (let i = 1; i <= doc.numPages; i++) {
321
365
  const page = await doc.getPage(i);
@@ -340,10 +384,64 @@ export async function factsOf(doc: Doc, lib: PdfJs): Promise<PdfRead> {
340
384
  fonts,
341
385
  last: pageText(last.page, last.items),
342
386
  layout: read.map((r) => r.layout),
387
+ ...saidOf(read, await doc.getMetadata()),
388
+ ...(await anchorsOf(doc)),
343
389
  },
344
390
  };
345
391
  }
346
392
 
393
+ /**
394
+ * Where hyperref anchored the bibliography's first entry (the lowest page of a `cite.<key>`
395
+ * destination) and the appendix (the lowest page of an `appendix.<letter>` destination or of a
396
+ * bookmark titled «Appendix…»). A reference pdf.js cannot place is skipped.
397
+ */
398
+ async function anchorsOf(
399
+ doc: Doc,
400
+ ): Promise<Pick<PdfFacts, "bibAnchorPage" | "appendixAnchorPage">> {
401
+ const destinations = await doc.getDestinations();
402
+ const bookmarked = await Promise.all(
403
+ appendixOutlineDests(await doc.getOutline()).map(async (d) =>
404
+ refOf(typeof d === "string" ? await doc.getDestination(d) : d),
405
+ ),
406
+ );
407
+ return {
408
+ bibAnchorPage: await lowestPage(doc, namedRefs(destinations, "cite.")),
409
+ appendixAnchorPage: await lowestPage(doc, [
410
+ ...namedRefs(destinations, "appendix."),
411
+ ...bookmarked.filter((r): r is object => r !== null),
412
+ ]),
413
+ };
414
+ }
415
+
416
+ /** The lowest 1-based page any of `refs` names, or null when none can be placed. */
417
+ async function lowestPage(
418
+ doc: Doc,
419
+ refs: readonly object[],
420
+ ): Promise<number | null> {
421
+ const pages = await Promise.all(
422
+ refs.map((r) =>
423
+ doc.getPageIndex(r).then(
424
+ (i) => [i + 1],
425
+ () => [],
426
+ ),
427
+ ),
428
+ );
429
+ const found = pages.flat();
430
+ return found.length === 0 ? null : Math.min(...found);
431
+ }
432
+
433
+ /** What the pages say, from each page's read, and the document's metadata. */
434
+ function saidOf(
435
+ read: readonly Pick<Awaited<ReturnType<typeof readPage>>, "text" | "links">[],
436
+ meta: Awaited<ReturnType<Doc["getMetadata"]>>,
437
+ ): Pick<PdfFacts, "pageTexts" | "links" | "metadata"> {
438
+ return {
439
+ pageTexts: read.map((r) => r.text),
440
+ links: read.flatMap((r, i) => r.links.map((uri) => ({ page: i + 1, uri }))),
441
+ metadata: metadataOf(meta.info, xmpOf(meta.metadata)),
442
+ };
443
+ }
444
+
347
445
  /** pdf.js's own exception names for the two conditions a caller can act on. */
348
446
  export function failureOf(e: unknown): PdfRead {
349
447
  const name = fieldOf(e, "name");
@@ -6,6 +6,8 @@ import type { Include } from "../domain/paper-source.ts";
6
6
  import type {
7
7
  ClassLine,
8
8
  DocumentClass,
9
+ Emphasis,
10
+ LayoutOverride,
9
11
  Outline,
10
12
  Passage,
11
13
  Span,
@@ -25,6 +27,11 @@ export interface LatexReader {
25
27
  * code or the title block. Citations, links and cross-references stay in place as marks.
26
28
  */
27
29
  readonly bodyProse: (src: string) => readonly Passage[];
30
+ /**
31
+ * The phrases set in bold, italics or underline inside that same prose, in document order — never
32
+ * a heading, a run-in heading (`\textbf{Threats.} …`), a caption, a float or a table.
33
+ */
34
+ readonly bodyEmphasis: (src: string) => readonly Emphasis[];
28
35
  /** Every `\input`, `\include` and `\subfile`, in source order: the path as written, where it stands. */
29
36
  readonly includes: (src: string) => readonly Include[];
30
37
  /** The span of the `document` environment's body, or null when the source has none. */
@@ -39,4 +46,6 @@ export interface LatexReader {
39
46
  * option `want` names (the author's own options stay), or it has none.
40
47
  */
41
48
  readonly withDocumentClass: (src: string, want: DocumentClass) => string;
49
+ /** Every command that changes the page layout the class sets: margins, text block, line spacing. */
50
+ readonly layoutOverrides: (src: string) => readonly LayoutOverride[];
42
51
  }
package/src/presets.ts CHANGED
@@ -19,7 +19,7 @@
19
19
  * tex union — a child never removes a package its parent needs
20
20
  * format per key, the child wins; `kinds` by kind name, a child's kind replaces that kind
21
21
  * rules per rule id, the child wins
22
- * template the child wins; so does `name`, and `required_sections` (the whole list)
22
+ * template the child wins; so does `name`, and `required_sections` and `register` (each whole)
23
23
  * aliases union, with every `name` — what the venue is called along the chain
24
24
  *
25
25
  * ── THE LABEL ─────────────────────────────────────────────────────────────────────
@@ -51,6 +51,7 @@ import {
51
51
  } from "./paper-settings.ts";
52
52
  import { CONFIG_FILE } from "#lib/paper-config";
53
53
  import { messageOf } from "./domain/text.ts";
54
+ import type { RegisterAnchor } from "./domain/register.ts";
54
55
 
55
56
  /** The prefix of a shipped preset's spec. */
56
57
  export const SHIPPED_PREFIX = "paperlint:";
@@ -83,10 +84,14 @@ export interface Preset {
83
84
  readonly template: PresetTemplate | null;
84
85
  /** Every `name` and `aliases` along the chain: what this venue is called in a paper's text. */
85
86
  readonly aliases: readonly string[];
87
+ /** Whether the venue reviews double-blind (`anonymity/identity`); false when no file of the chain says. */
88
+ readonly blind: boolean;
86
89
  readonly requiredSections: readonly RequiredSection[];
87
90
  readonly tex: TexRequirements;
88
91
  readonly format: VenueFormat;
89
92
  readonly rules: Readonly<Record<string, unknown>>;
93
+ /** The accepted papers the register rules measure a body against; empty when no file names any. */
94
+ readonly registerAnchors: readonly RegisterAnchor[];
90
95
  }
91
96
 
92
97
  /** Why a spec does not resolve. */
@@ -196,6 +201,7 @@ export function mergeFormat(
196
201
  refPtMax: pick("refPtMax"),
197
202
  fontsText: pick("fontsText"),
198
203
  fontsTitle: pick("fontsTitle"),
204
+ bodyEndsAt: pick("bodyEndsAt"),
199
205
  kinds: new Map([...parent.kinds, ...child.kinds]),
200
206
  };
201
207
  }
@@ -205,22 +211,18 @@ function merged(
205
211
  spec: string,
206
212
  rootFirst: readonly { readonly preset: PresetFile; readonly file: string }[],
207
213
  ): Preset {
208
- const base: {
209
- name: string | null;
210
- template: PresetTemplate | null;
211
- aliases: readonly string[];
212
- requiredSections: readonly RequiredSection[];
213
- tex: TexRequirements;
214
- format: VenueFormat;
215
- rules: Readonly<Record<string, unknown>>;
214
+ const base: Omit<Preset, "label" | "chain"> & {
215
+ readonly name: string | null;
216
216
  } = {
217
217
  name: null,
218
218
  template: null,
219
219
  aliases: [],
220
+ blind: false,
220
221
  requiredSections: [],
221
222
  tex: NO_REQUIREMENTS,
222
223
  format: NO_FORMAT,
223
224
  rules: {},
225
+ registerAnchors: [],
224
226
  };
225
227
  const m = rootFirst.reduce(
226
228
  (acc, { preset: p, file }) => ({
@@ -233,10 +235,12 @@ function merged(
233
235
  ...p.aliases,
234
236
  ]),
235
237
  ],
238
+ blind: p.blind ?? acc.blind,
236
239
  requiredSections: p.requiredSections ?? acc.requiredSections,
237
240
  tex: p.tex ? mergeRequirements(acc.tex, p.tex) : acc.tex,
238
241
  format: mergeFormat(acc.format, p.format),
239
242
  rules: { ...acc.rules, ...p.rules },
243
+ registerAnchors: p.registerAnchors ?? acc.registerAnchors,
240
244
  }),
241
245
  base,
242
246
  );
@@ -245,10 +249,12 @@ function merged(
245
249
  chain: rootFirst.map((x) => x.file),
246
250
  template: m.template,
247
251
  aliases: m.aliases,
252
+ blind: m.blind,
248
253
  requiredSections: m.requiredSections,
249
254
  tex: m.tex,
250
255
  format: m.format,
251
256
  rules: m.rules,
257
+ registerAnchors: m.registerAnchors,
252
258
  };
253
259
  }
254
260
 
@@ -0,0 +1,327 @@
1
+ /**
2
+ * The register band rules on REAL papers — the accepted ones in `fixtures/accepted-papers/`, and
3
+ * variants derived from their sources, one change each:
4
+ *
5
+ * - every anchor the AIDC preset records is re-measured from its fixture and must equal the preset's
6
+ * numbers, so a counter cannot change without the anchors (and so the band) changing with it;
7
+ * - every accepted ACSAC paper stands inside AIDC's band on every measure — a rule that fires on a
8
+ * paper the venue accepted is wrong about the venue;
9
+ * - a variant that adds contrast frames, sets numbers in bold, or strips the relation markers from
10
+ * one of those papers leaves the band on exactly that measure.
11
+ *
12
+ * `judgeBand` itself, on constructed measures: both ends, the floor of words, and what it reports.
13
+ */
14
+ import { readdirSync, readFileSync } from "node:fs";
15
+ import { dirname, join } from "node:path";
16
+ import { fileURLToPath } from "node:url";
17
+ import { describe, expect, it } from "vitest";
18
+ import { latexReader } from "./adapters/latex/index.ts";
19
+ import { nodeFiles } from "./adapters/node/index.ts";
20
+ import {
21
+ bandOf,
22
+ rateOf,
23
+ REGISTER_MEASURES,
24
+ type Band,
25
+ type RegisterAnchor,
26
+ type RegisterMeasureName,
27
+ } from "./domain/register.ts";
28
+ import { presetsDir } from "./package-dirs.ts";
29
+ import { resolvePreset } from "./presets.ts";
30
+ import { MIN_WORDS } from "./register.ts";
31
+ import {
32
+ anchorOf,
33
+ judgeBand,
34
+ measureBody,
35
+ registerBandRules,
36
+ standingOf,
37
+ } from "./register-bands.ts";
38
+ import { readPaper } from "./tex-paper.ts";
39
+ import { useTempDir, writeTree } from "../test/support.ts";
40
+
41
+ const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
42
+ const CORPUS = join(ROOT, "fixtures", "accepted-papers");
43
+
44
+ /** The AIDC preset, resolved as a paper extending it would resolve it. */
45
+ const aidc = (() => {
46
+ const r = resolvePreset(
47
+ "paperlint:aidc",
48
+ join(ROOT, "papers", "p", "paperlint.json"),
49
+ { files: nodeFiles, venuesDir: presetsDir() },
50
+ );
51
+ if (!r.ok) throw new Error(JSON.stringify(r.error));
52
+ return r.value;
53
+ })();
54
+
55
+ /** A fixture paper's text, its includes spliced, as the rules read it. */
56
+ const sourceOf = (paper: string): string => {
57
+ const file = join(CORPUS, paper, "paper.tex");
58
+ return readPaper(file, readFileSync(file, "utf8"), {
59
+ files: nodeFiles,
60
+ latex: latexReader,
61
+ }).text;
62
+ };
63
+
64
+ const measured = new Map<string, RegisterAnchor>();
65
+ /** A fixture paper measured the way an anchor is (cached: each parse costs a second). */
66
+ const anchorFor = (paper: string, src = sourceOf(paper)): RegisterAnchor => {
67
+ const key = `${paper}\n${src}`;
68
+ const hit = measured.get(key);
69
+ if (hit) return hit;
70
+ const a = anchorOf(paper, src, latexReader);
71
+ measured.set(key, a);
72
+ return a;
73
+ };
74
+
75
+ /** Where each measure of a paper stands against AIDC's band. */
76
+ const standings = (
77
+ a: RegisterAnchor,
78
+ ): Readonly<Record<RegisterMeasureName, string>> => {
79
+ const at = (m: RegisterMeasureName): string => {
80
+ const band = bandOf(aidc.registerAnchors, m);
81
+ return band === null
82
+ ? "no band"
83
+ : standingOf(rateOf(a.counts[m], a.words), band);
84
+ };
85
+ return {
86
+ contrast_frames: at("contrast_frames"),
87
+ claim_emphasis: at("claim_emphasis"),
88
+ relation_markers: at("relation_markers"),
89
+ };
90
+ };
91
+
92
+ const WITHIN = {
93
+ contrast_frames: "within",
94
+ claim_emphasis: "within",
95
+ relation_markers: "within",
96
+ };
97
+
98
+ const ACSAC = readdirSync(CORPUS).filter((d) => /acsac\d\d$/u.test(d));
99
+
100
+ /** Parsing a real paper takes a second or more, several under coverage with every suite running. */
101
+ const PAPER_TIMEOUT_MS = 60_000;
102
+
103
+ describe("AIDC's anchors are the accepted ACSAC papers, as the counters measure them today", () => {
104
+ it("🔴 the preset records every ACSAC paper of the corpus, and nothing else", () => {
105
+ expect(aidc.registerAnchors.map((a) => a.paper).sort()).toEqual(
106
+ [...ACSAC].sort(),
107
+ );
108
+ });
109
+
110
+ it.each(aidc.registerAnchors.map((a) => [a.paper, a] as const))(
111
+ "🔴 %s: words and every count equal the fixture's, re-measured",
112
+ { timeout: PAPER_TIMEOUT_MS },
113
+ (paper, recorded) => {
114
+ expect(anchorFor(paper)).toEqual(recorded);
115
+ },
116
+ );
117
+ });
118
+
119
+ describe("every accepted ACSAC paper stands inside AIDC's band on every measure", () => {
120
+ it.each(ACSAC)("%s", { timeout: PAPER_TIMEOUT_MS }, (paper) => {
121
+ expect(standings(anchorFor(paper))).toEqual(WITHIN);
122
+ });
123
+ });
124
+
125
+ /** `src` with the first `n` matches of `re` after `\begin{document}` rewritten by `to`. */
126
+ function inBody(
127
+ src: string,
128
+ re: RegExp,
129
+ to: (match: string) => string,
130
+ n: number,
131
+ ): string {
132
+ const start = src.indexOf("\\begin{document}");
133
+ const matches = new Set(
134
+ [...src.slice(start).matchAll(re)].slice(0, n).map((m) => m.index + start),
135
+ );
136
+ return src.replace(re, (m: string, ...rest: readonly unknown[]) => {
137
+ const at = rest.find((x) => typeof x === "number");
138
+ return typeof at === "number" && matches.has(at) ? to(m) : m;
139
+ });
140
+ }
141
+
142
+ /** The markers `tex/relation-markers` counts, as a writer who leaves the relation to the reader drops them. */
143
+ const ADVERBIAL =
144
+ /\b(?:however|therefore|thus|hence|consequently|as a result|in contrast|specifically|for example|for instance|in particular),\s*/giu;
145
+
146
+ describe("variants of real accepted papers, one change each, leave the band on that measure only", () => {
147
+ it(
148
+ "🔴 secure-acsac24 with a contrast frame after 20 mentions of the model: contrast frames above",
149
+ { timeout: PAPER_TIMEOUT_MS },
150
+ () => {
151
+ const src = inBody(
152
+ sourceOf("secure-acsac24"),
153
+ /\bthe model\b/gu,
154
+ () => "the model, not the prompt",
155
+ 20,
156
+ );
157
+ expect(standings(anchorFor("secure-acsac24", src))).toEqual({
158
+ ...WITHIN,
159
+ contrast_frames: "above",
160
+ });
161
+ },
162
+ );
163
+
164
+ it(
165
+ "🔴 llm-splained-acsac25 with its first 20 percentages set in bold: claims in bold above",
166
+ { timeout: PAPER_TIMEOUT_MS },
167
+ () => {
168
+ const src = inBody(
169
+ sourceOf("llm-splained-acsac25"),
170
+ /\b\d+(?:\.\d+)?\\%/gu,
171
+ (m) => `\\textbf{${m}}`,
172
+ 20,
173
+ );
174
+ expect(standings(anchorFor("llm-splained-acsac25", src))).toEqual({
175
+ ...WITHIN,
176
+ claim_emphasis: "above",
177
+ });
178
+ },
179
+ );
180
+
181
+ it(
182
+ "🔴 rr-dataset-quality-acsac24 with its relations left to the reader: relation markers below",
183
+ { timeout: PAPER_TIMEOUT_MS },
184
+ () => {
185
+ // One change: the sentence adverbials that name a relation are dropped, and «because» becomes
186
+ // «as» — the same claims, the relation between them no longer named.
187
+ const dropped = inBody(
188
+ sourceOf("rr-dataset-quality-acsac24"),
189
+ ADVERBIAL,
190
+ () => "",
191
+ Number.POSITIVE_INFINITY,
192
+ );
193
+ const src = inBody(
194
+ dropped,
195
+ /\bbecause\b/gu,
196
+ () => "as",
197
+ Number.POSITIVE_INFINITY,
198
+ );
199
+ expect(standings(anchorFor("rr-dataset-quality-acsac24", src))).toEqual({
200
+ ...WITHIN,
201
+ relation_markers: "below",
202
+ });
203
+ },
204
+ );
205
+ });
206
+
207
+ describe("judgeBand — a body's rate against a band", () => {
208
+ const band: Band = {
209
+ min: 20,
210
+ max: 10_000 * (5 / MIN_WORDS),
211
+ anchorMin: 30,
212
+ anchorMax: 20,
213
+ anchors: 5,
214
+ };
215
+ const occurrence = (i: number) => ({
216
+ match: `m${String(i)}`,
217
+ sentence: `s${String(i)}`,
218
+ at: { start: i, end: i + 1 },
219
+ });
220
+ const body = (n: number, words = MIN_WORDS) => ({
221
+ words,
222
+ occurrences: Array.from({ length: n }, (_, i) => occurrence(i)),
223
+ at: { start: 0, end: 1 },
224
+ });
225
+ const ids = (n: number, each = true, words = MIN_WORDS) =>
226
+ judgeBand(body(n, words), band, "aidc", each).map((f) => f.messageId);
227
+
228
+ it("inside the band, including its edges, nothing", () => {
229
+ expect(ids(5)).toEqual([]);
230
+ expect(ids(4)).toEqual([]);
231
+ });
232
+
233
+ it("🔴 above it: one finding for the body, then one at every occurrence", () => {
234
+ expect(ids(6)).toEqual([
235
+ "above",
236
+ ...Array.from({ length: 6 }, () => "each"),
237
+ ]);
238
+ expect(ids(6, false)).toEqual(["above"]);
239
+ });
240
+
241
+ it("🔴 below it: one finding for the body, never one per occurrence", () => {
242
+ expect(ids(3)).toEqual(["below"]);
243
+ });
244
+
245
+ it("under the floor of words, or without a band, nothing", () => {
246
+ expect(ids(1, true, MIN_WORDS - 1)).toEqual([]);
247
+ expect(judgeBand(body(99), null, "aidc", true)).toEqual([]);
248
+ });
249
+
250
+ it("the finding names the count, the rate, the band, the anchors' range and the first examples", () => {
251
+ const [f] = judgeBand(body(6), band, "aidc", true);
252
+ expect(f?.data).toMatchObject({
253
+ count: 6,
254
+ rate: "30.0",
255
+ min: "20.0",
256
+ max: "25.0",
257
+ anchors: 5,
258
+ venue: "aidc",
259
+ examples: "«s0», «s1», «s2»",
260
+ });
261
+ });
262
+ });
263
+
264
+ describe("the measures a preset may set a band for", () => {
265
+ it("each of them is recorded for every anchor", () => {
266
+ aidc.registerAnchors.forEach((a) => {
267
+ expect(Object.keys(a.counts).sort()).toEqual(
268
+ [...REGISTER_MEASURES].sort(),
269
+ );
270
+ });
271
+ });
272
+ });
273
+
274
+ describe("the rules, as ESLint calls them", () => {
275
+ /** A paper directory extending `extends`, and what one rule reports on its paper.tex. */
276
+ const reported = (
277
+ rule: "contrast-frames" | "claim-emphasis" | "relation-markers",
278
+ src: string,
279
+ file = "paper.tex",
280
+ ): string[] => {
281
+ const dir = writeTree(useTempDir("paperlint-register-bands-"), {
282
+ "paperlint.json": JSON.stringify({ extends: "paperlint:aidc" }),
283
+ });
284
+ const seen: string[] = [];
285
+ const deps = {
286
+ files: nodeFiles,
287
+ venuesDir: presetsDir(),
288
+ latex: latexReader,
289
+ };
290
+ registerBandRules(deps)
291
+ [rule].create({
292
+ filename: join(dir, file),
293
+ // No `raw`: the rule falls back to the text ESLint gives every language.
294
+ sourceCode: {
295
+ text: src,
296
+ getLocFromIndex: () => ({ line: 1, column: 0 }),
297
+ },
298
+ report: (d) => {
299
+ seen.push("messageId" in d ? d.messageId : d.message);
300
+ },
301
+ })
302
+ .root?.();
303
+ return seen;
304
+ };
305
+ const LONG = `\\documentclass{article}\n\\begin{document}\n${Array.from(
306
+ { length: 200 },
307
+ () =>
308
+ "The rule reads the body of the paper and counts every sentence it holds.",
309
+ ).join(" ")}\n\\end{document}\n`;
310
+
311
+ it("🔴 reads the paper's text when the language gives no raw source", () => {
312
+ expect(reported("relation-markers", LONG)).toEqual(["below"]);
313
+ });
314
+
315
+ it("judges a paper once, on its paper.tex, and nothing else", () => {
316
+ expect(reported("relation-markers", LONG, "intro.tex")).toEqual([]);
317
+ });
318
+
319
+ it("an empty body is measured as no words, reported nowhere", () => {
320
+ const m = measureBody(
321
+ "contrast-frames",
322
+ "\\documentclass{article}\n\\begin{document}\n\\end{document}\n",
323
+ latexReader,
324
+ );
325
+ expect(m).toEqual({ words: 0, occurrences: [], at: null });
326
+ });
327
+ });