paperlint 5.0.0 → 6.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) 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 +1 -0
  6. package/dist/adapters/latex/index.d.ts.map +1 -1
  7. package/dist/adapters/latex/index.js +3 -0
  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/cli.d.ts.map +1 -1
  20. package/dist/cli.js +25 -5
  21. package/dist/cli.js.map +1 -1
  22. package/dist/domain/anonymity.d.ts +68 -0
  23. package/dist/domain/anonymity.d.ts.map +1 -0
  24. package/dist/domain/anonymity.js +94 -0
  25. package/dist/domain/anonymity.js.map +1 -0
  26. package/dist/domain/body-pages.d.ts +69 -0
  27. package/dist/domain/body-pages.d.ts.map +1 -0
  28. package/dist/domain/body-pages.js +141 -0
  29. package/dist/domain/body-pages.js.map +1 -0
  30. package/dist/domain/tex-document.d.ts +9 -0
  31. package/dist/domain/tex-document.d.ts.map +1 -1
  32. package/dist/eslint-rules/pdf-last-page-balance.d.ts +1 -1
  33. package/dist/eslint-rules/pdf-last-page-balance.js +2 -2
  34. package/dist/facts-file.d.ts +28 -2
  35. package/dist/facts-file.d.ts.map +1 -1
  36. package/dist/facts-file.js +54 -9
  37. package/dist/facts-file.js.map +1 -1
  38. package/dist/lib/paper-config.d.ts.map +1 -1
  39. package/dist/lib/paper-config.js +2 -1
  40. package/dist/lib/paper-config.js.map +1 -1
  41. package/dist/paper-settings.d.ts +11 -0
  42. package/dist/paper-settings.d.ts.map +1 -1
  43. package/dist/paper-settings.js +38 -4
  44. package/dist/paper-settings.js.map +1 -1
  45. package/dist/pdf-content.d.ts +40 -0
  46. package/dist/pdf-content.d.ts.map +1 -0
  47. package/dist/pdf-content.js +97 -0
  48. package/dist/pdf-content.js.map +1 -0
  49. package/dist/pdf-facts.d.ts +30 -0
  50. package/dist/pdf-facts.d.ts.map +1 -1
  51. package/dist/pdf-facts.js +37 -1
  52. package/dist/pdf-facts.js.map +1 -1
  53. package/dist/ports/latex.d.ts +3 -1
  54. package/dist/ports/latex.d.ts.map +1 -1
  55. package/dist/presets.d.ts +2 -0
  56. package/dist/presets.d.ts.map +1 -1
  57. package/dist/presets.js +4 -0
  58. package/dist/presets.js.map +1 -1
  59. package/dist/tex-requirements.d.ts +4 -0
  60. package/dist/tex-requirements.d.ts.map +1 -1
  61. package/dist/tex-requirements.js +3 -0
  62. package/dist/tex-requirements.js.map +1 -1
  63. package/dist/tex-venue-rules.d.ts +7 -1
  64. package/dist/tex-venue-rules.d.ts.map +1 -1
  65. package/dist/tex-venue-rules.js +40 -0
  66. package/dist/tex-venue-rules.js.map +1 -1
  67. package/dist/types.d.ts +2 -0
  68. package/dist/types.d.ts.map +1 -1
  69. package/dist/venue-rules.d.ts +38 -8
  70. package/dist/venue-rules.d.ts.map +1 -1
  71. package/dist/venue-rules.js +179 -41
  72. package/dist/venue-rules.js.map +1 -1
  73. package/docs/configuration.md +18 -16
  74. package/docs/e2e.md +32 -11
  75. package/docs/rules/anonymity/identity.md +111 -0
  76. package/docs/rules/format/layout-override.md +61 -0
  77. package/docs/rules/format/page-limit.md +108 -0
  78. package/docs/rules/pdf/body-size.md +87 -0
  79. package/docs/rules.md +44 -39
  80. package/eslint-rules/pdf-last-page-balance.harness.mjs +6 -1
  81. package/eslint-rules/pdf-last-page-balance.ts +2 -2
  82. package/fixtures/build-e2e/aidc/expect.json +1 -1
  83. package/fixtures/build-e2e/aidc/paperlint.json +1 -1
  84. package/fixtures/build-e2e/anon-ack/PIPELINE-STATUS.md +3 -0
  85. package/fixtures/build-e2e/anon-ack/expect.json +19 -0
  86. package/fixtures/build-e2e/anon-ack/paper.tex +31 -0
  87. package/fixtures/build-e2e/anon-ack/paperlint.json +1 -0
  88. package/fixtures/build-e2e/anon-author/PIPELINE-STATUS.md +3 -0
  89. package/fixtures/build-e2e/anon-author/expect.json +19 -0
  90. package/fixtures/build-e2e/anon-author/paper.tex +29 -0
  91. package/fixtures/build-e2e/anon-author/paperlint.json +1 -0
  92. package/fixtures/build-e2e/anon-bib/PIPELINE-STATUS.md +3 -0
  93. package/fixtures/build-e2e/anon-bib/expect.json +20 -0
  94. package/fixtures/build-e2e/anon-bib/paper.tex +35 -0
  95. package/fixtures/build-e2e/anon-bib/paperlint.json +1 -0
  96. package/fixtures/build-e2e/anon-href/PIPELINE-STATUS.md +3 -0
  97. package/fixtures/build-e2e/anon-href/expect.json +19 -0
  98. package/fixtures/build-e2e/anon-href/paper.tex +30 -0
  99. package/fixtures/build-e2e/anon-href/paperlint.json +1 -0
  100. package/fixtures/build-e2e/anon-macro/PIPELINE-STATUS.md +3 -0
  101. package/fixtures/build-e2e/anon-macro/expect.json +19 -0
  102. package/fixtures/build-e2e/anon-macro/paper.tex +30 -0
  103. package/fixtures/build-e2e/anon-macro/paperlint.json +1 -0
  104. package/fixtures/build-e2e/anon-pdfauthor/PIPELINE-STATUS.md +3 -0
  105. package/fixtures/build-e2e/anon-pdfauthor/expect.json +19 -0
  106. package/fixtures/build-e2e/anon-pdfauthor/paper.tex +31 -0
  107. package/fixtures/build-e2e/anon-pdfauthor/paperlint.json +1 -0
  108. package/fixtures/build-e2e/unbalanced/expect.json +2 -2
  109. package/lib/paper-config.ts +3 -1
  110. package/package.json +1 -1
  111. package/presets/acm-sigconf.jsonc +3 -1
  112. package/presets/aidc.jsonc +16 -17
  113. package/presets/ieee-conference.jsonc +8 -0
  114. package/presets/venue-profile.schema.json +9 -0
  115. package/skills/render-paper/extract-pdf-facts.harness.ts +3 -3
  116. package/src/adapters/latex/index.ts +3 -0
  117. package/src/adapters/latex/layout.ts +106 -0
  118. package/src/adapters/latex/nodes.ts +11 -3
  119. package/src/adapters/latex/parse.ts +5 -1
  120. package/src/build.harness.mjs +2 -2
  121. package/src/build.test.ts +5 -0
  122. package/src/cli.test.ts +13 -0
  123. package/src/cli.ts +36 -4
  124. package/src/domain/anonymity.test.ts +163 -0
  125. package/src/domain/anonymity.ts +129 -0
  126. package/src/domain/body-pages.test.ts +300 -0
  127. package/src/domain/body-pages.ts +223 -0
  128. package/src/domain/tex-document.ts +10 -0
  129. package/src/facts-file.test.ts +100 -4
  130. package/src/facts-file.ts +91 -9
  131. package/src/new-paper.test.ts +7 -1
  132. package/src/paper-settings.test.ts +63 -6
  133. package/src/paper-settings.ts +53 -5
  134. package/src/pdf-content.test.ts +71 -0
  135. package/src/pdf-content.ts +125 -0
  136. package/src/pdf-facts.test.ts +110 -2
  137. package/src/pdf-facts.ts +106 -8
  138. package/src/ports/latex.ts +3 -0
  139. package/src/presets.ts +8 -8
  140. package/src/rule-cases.test.ts +29 -3
  141. package/src/rule-docs.test.ts +0 -2
  142. package/src/tex-requirements.ts +9 -0
  143. package/src/tex-venue-rules.test.ts +110 -5
  144. package/src/tex-venue-rules.ts +57 -2
  145. package/src/types.ts +2 -0
  146. package/src/venue-rules.test.ts +351 -39
  147. package/src/venue-rules.ts +247 -47
@@ -136,8 +136,10 @@ export const SETTINGS_KEYS: Readonly<Record<string, string>> = Object.freeze({
136
136
  "rule overrides: { id: severity } for every paper in scope, or ESLint blocks (files, ignores, rules) — paperlint lint",
137
137
  extends:
138
138
  "the venue preset: paperlint:<name> or ./path.jsonc — the pdf/ venue rules, paperlint build",
139
- kind: "the kind of paper, whose page limit applies — pdf/limits",
139
+ kind: "the kind of paper, whose page limit applies — format/page-limit",
140
140
  pdf: "where the built PDF is, relative to the paper, when it is not paper.pdf — the facts",
141
+ identity:
142
+ "what identifies the authors — names, handles, emails, affiliations, own project names; a blind venue's PDF must not say any of them — anonymity/identity. The root's list and a paper's are joined",
141
143
  scripts: "how skill prose names the scripts — consumer.mjs",
142
144
  timezone: "the zone deadline anchors are written in — consumer.mjs",
143
145
  contactEmail: "the polite-pool address for citation lookups — consumer.mjs",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "paperlint",
3
- "version": "5.0.0",
3
+ "version": "6.0.0",
4
4
  "description": "A linter for scientific papers written in LaTeX: catches mistakes before you submit to a conference or journal.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -74,7 +74,9 @@
74
74
  // «font size is 9 pt for all body text»
75
75
  "body_pt": 9,
76
76
  // banal measures the MODE of the rendered text, not the declared size (measured 08-26: 9.30 at
77
- // acmart's declared 9). Hence the tolerance — and hence why body size stays a nudge, not a gate.
77
+ // acmart's declared 9). Hence the tolerance: banal reads 0.3 pt high on every template measured
78
+ // (fixtures/accepted-papers/body-size.json), and 0.5 absorbs that while a paper one size off is
79
+ // still reported.
78
80
  "body_pt_tol": 0.5,
79
81
 
80
82
  // «font size of the references can be from 7 pt to 9 pt»
@@ -22,6 +22,10 @@
22
22
  {
23
23
  "extends": "paperlint:ieee-conference",
24
24
 
25
+ // «Submissions must be properly anonymized for double-blind review.» (the call above), and
26
+ // `"blind":true` in the portal's data. anonymity/identity then requires the paper's `identity`.
27
+ "blind": true,
28
+
25
29
  // What the venue is called in a paper's text (tex/venue-leftover): the workshop, and the
26
30
  // conference it is held at.
27
31
  "aliases": ["AIDC", "ACSAC"],
@@ -44,30 +48,25 @@
44
48
  "textblock_w_in": 7.0,
45
49
  "textblock_h_in": 9.02,
46
50
 
47
- // 🔴 PAGE LIMITS ARE NOT GATED HERE, AND THAT IS A MEASUREMENT. banal splits pages into body
48
- // and references by what is on them, and a page that holds ANY non-bibliography text counts as
49
- // body. Measured 2026-09-27 on a [conference,compsoc] paper whose body ends at the foot of
50
- // page 12:
51
+ // 🔴 THE PAGE LIMIT IS COUNTED BEFORE THE REFERENCES, NOT BY banal. banal splits pages into
52
+ // body and references by what is on them, and a page that holds ANY non-bibliography text
53
+ // counts as body. Measured 2026-09-27 on a [conference,compsoc] paper whose body ends at the
54
+ // foot of page 12:
51
55
  // LLM Usage Statement before the references → body 13, references 0
52
56
  // an appendix before the references → body 13, references 2
53
57
  // both placed after the references → body right, the tail counted as references
54
58
  // AIDC counts neither in the limit («excluding references and appendices»; the ACSAC AI policy:
55
- // the LLM Usage Statement «does not count towards the page limit»), and tex/required-section
56
- // accepts the statement before or after the bibliography. A gate on banal's body count would
57
- // therefore fail a correct 12-page paper — the realm.jsonc case again. The numbers below are
58
- // data for humans, not an input to pdf/limits.
59
- "page_limits_not_gated": {
60
- "_": "Body pages, excluding references, appendices and the LLM Usage Statement. banal counts a page holding an appendix or the statement before the bibliography as body, so its body count is not this number.",
61
- "regular": 12,
62
- "short": 6,
63
- "references_and_appendices": "unlimited",
64
- "llm_usage_statement": "not counted (ACSAC AI policy)",
65
- },
59
+ // the LLM Usage Statement «does not count towards the page limit»). So format/page-limit counts
60
+ // the body up to where the references start (`body_ends_at`): the page they start on counts
61
+ // when body text stands above the heading there, and the limit below is gated. An appendix
62
+ // ends the body too, wherever it sits, when hyperref anchors it (docs/rules/format/page-limit.md).
63
+ "body_ends_at": "references",
66
64
 
67
65
  // «Short position papers or work-in-progress (WIP) papers» share one limit, so one kind.
66
+ // References, appendices and the LLM Usage Statement: unlimited.
68
67
  "kinds": {
69
- "regular": {},
70
- "short": {},
68
+ "regular": { "body_pages_max": 12 },
69
+ "short": { "body_pages_max": 6 },
71
70
  },
72
71
  },
73
72
  }
@@ -59,6 +59,14 @@
59
59
  "body_pt": 10,
60
60
  "body_pt_tol": 0.5,
61
61
 
62
+ // The class sets the bibliography in \footnotesize, 8 pt at 10pt and at 9pt (IEEEtran.cls,
63
+ // \thebibliography). Measured 2026-09-29 on the four built ACSAC papers in
64
+ // fixtures/accepted-papers (body-size.json): pdf.js reads the reference glyphs at 8.00 pt
65
+ // (7.97 without compsoc), banal gives 8.3 on all four. body_pt_tol widens it to 7.5-8.5, so a
66
+ // bibliography shrunk to \scriptsize (7.3) or grown to \small (9.3) is reported.
67
+ "ref_pt_min": 8,
68
+ "ref_pt_max": 8,
69
+
62
70
  // The Times clone. Headings and the title are set in the same family, so a `fonts_title` would
63
71
  // repeat this check and report a Computer Modern build twice.
64
72
  "fonts_text": "NimbusRomNo9L",
@@ -24,6 +24,10 @@
24
24
  "type": "string",
25
25
  "minLength": 1
26
26
  },
27
+ "blind": {
28
+ "description": "The venue reviews double-blind: its call says submissions must be anonymized. Read by `anonymity/identity`, which then requires the paper's `identity` and finds none of it in the PDF. Set only where the venue says so, with the quote. A child's value replaces its parent's.",
29
+ "type": "boolean"
30
+ },
27
31
  "aliases": {
28
32
  "description": "Other names the venue goes by in a paper's text — its acronym, its parent conference (`[\"AISec\", \"ACM CCS\"]`). Read by `tex/venue-leftover`: a paper that extends another venue and says one of these is flagged. Matched as whole words, case-sensitive. Collected along the `extends` chain, with `name`.",
29
33
  "type": "array",
@@ -105,6 +109,11 @@
105
109
  "type": "string",
106
110
  "minLength": 1
107
111
  },
112
+ "body_ends_at": {
113
+ "description": "How `format/page-limit` counts body pages. Absent: banal's body count (every page holding non-bibliography text). `references`: the pages up to where the body ends — at the references or the appendix, whichever comes first, found by hyperref's anchors and then the heading line — with that page counted when body text stands above the heading on it; for a venue whose limit excludes references and appendices (and what follows them, such as statements).",
114
+ "type": "string",
115
+ "enum": ["references"]
116
+ },
108
117
  "page_limits_not_gated": {
109
118
  "type": "object"
110
119
  },
@@ -119,8 +119,8 @@ try {
119
119
  const fonts = Fonts.safeParse(facts.fonts);
120
120
  const fontList = fonts.success ? fonts.data : [];
121
121
  check(
122
- "the facts are schema 2, about paper.pdf, for the venue paperlint.json declares",
123
- facts.schema === 2 &&
122
+ "the facts are schema 3, about paper.pdf, for the venue paperlint.json declares",
123
+ facts.schema === 3 &&
124
124
  facts.pdf === "paper.pdf" &&
125
125
  facts.venue === "agenticdev" &&
126
126
  facts.kind === "short",
@@ -236,5 +236,5 @@ try {
236
236
  }
237
237
 
238
238
  console.log(
239
- `✓ ${String(check.count)} assertions passed — extract-pdf-facts: the exit-code contract, schema 2, and the no-no-yes font`,
239
+ `✓ ${String(check.count)} assertions passed — extract-pdf-facts: the exit-code contract, schema 3, and the no-no-yes font`,
240
240
  );
@@ -31,6 +31,7 @@ import { outlineOf } from "./outline.ts";
31
31
  import { parseLatex } from "./parse.ts";
32
32
  import { bodyProse } from "./prose.ts";
33
33
  import { renderedRuns } from "./rendered.ts";
34
+ import { layoutOverridesOf } from "./layout.ts";
34
35
 
35
36
  export {
36
37
  documentClassOf,
@@ -51,6 +52,7 @@ export { outlineOf } from "./outline.ts";
51
52
  export { parseLatex, type ParsedTex } from "./parse.ts";
52
53
  export { bodyProse } from "./prose.ts";
53
54
  export { renderedRuns } from "./rendered.ts";
55
+ export { layoutOverridesOf } from "./layout.ts";
54
56
 
55
57
  /** The `LatexReader` port over unified-latex: each call parses its source once. */
56
58
  export const latexReader: LatexReader = {
@@ -62,4 +64,5 @@ export const latexReader: LatexReader = {
62
64
  documentBody: (src) => documentBodyOf(parseLatex(src)),
63
65
  template: parseTemplate,
64
66
  withDocumentClass: (src, want) => replaceDocumentClass(parseLatex(src), want),
67
+ layoutOverrides: (src) => layoutOverridesOf(parseLatex(src)),
65
68
  };
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Where a source changes the page layout its class sets for the whole document — the input of
3
+ * `format/layout-override`. Read from the parse tree, never the text: a command in a comment is not
4
+ * a command.
5
+ *
6
+ * What counts, each by its node:
7
+ *
8
+ * \usepackage{geometry} · \RequirePackage{…,geometry} the package that re-sets the margins
9
+ * \geometry{…} · \newgeometry{…} its commands
10
+ * \setlength / \addtolength on a layout length \textheight \textwidth \topmargin …
11
+ * \linespread{…} · \renewcommand{\baselinestretch} the line spacing
12
+ *
13
+ * Local spacing — a negative `\vspace` or `\vskip` around one float — is not a layout the template
14
+ * sets, and no venue's call names it; it is not read.
15
+ *
16
+ * A comment and a `comment` environment are not read — the parser hands both back as text, not
17
+ * macros. Everything else is — the bibliography, math, floats — and a macro definition too: a
18
+ * `\newcommand` whose body sets a layout length does so wherever it is used. Conditionals are not
19
+ * evaluated.
20
+ */
21
+ import type { LayoutOverride } from "../../domain/tex-document.ts";
22
+ import {
23
+ collapse,
24
+ isList,
25
+ macroPlace,
26
+ mandatory,
27
+ textOf,
28
+ visited,
29
+ type Macro,
30
+ } from "./nodes.ts";
31
+ import type { ParsedTex } from "./parse.ts";
32
+
33
+ /** The lengths the class sets for the page and the line; changing one changes the venue's layout. */
34
+ export const LAYOUT_LENGTHS: ReadonlySet<string> = new Set([
35
+ "textheight",
36
+ "textwidth",
37
+ "topmargin",
38
+ "oddsidemargin",
39
+ "evensidemargin",
40
+ "columnsep",
41
+ "headsep",
42
+ "headheight",
43
+ "footskip",
44
+ "baselineskip",
45
+ ]);
46
+
47
+ /** The macro a mandatory argument holds first, or undefined: `{\textheight}` → `textheight`. */
48
+ function firstMacro(m: Macro, i: number): string | undefined {
49
+ const first = mandatory(m)[i]?.content.find((n) => n.type !== "whitespace");
50
+ return first?.type === "macro" ? first.content : undefined;
51
+ }
52
+
53
+ const argText = (m: Macro, i: number): string =>
54
+ collapse(textOf(mandatory(m)[i]?.content));
55
+
56
+ /** What names a macro that overrides the layout; null: this use of it does not. */
57
+ type Namer = (m: Macro) => string | null;
58
+
59
+ const usesGeometry: Namer = (m) =>
60
+ argText(m, 0)
61
+ .split(",")
62
+ .map((p) => p.trim())
63
+ .includes("geometry")
64
+ ? `\\${m.content}{geometry}`
65
+ : null;
66
+
67
+ const setsLayoutLength: Namer = (m) => {
68
+ const length = firstMacro(m, 0);
69
+ return length !== undefined && LAYOUT_LENGTHS.has(length)
70
+ ? `\\${m.content}{\\${length}}`
71
+ : null;
72
+ };
73
+
74
+ /** Each macro that may override the layout, and how to tell whether this use of it does. */
75
+ const OVERRIDES: ReadonlyMap<string, Namer> = new Map<string, Namer>([
76
+ ["usepackage", usesGeometry],
77
+ ["RequirePackage", usesGeometry],
78
+ ["geometry", (m) => `\\${m.content}`],
79
+ ["newgeometry", (m) => `\\${m.content}`],
80
+ ["linespread", (m) => `\\linespread{${argText(m, 0)}}`],
81
+ ["setlength", setsLayoutLength],
82
+ ["addtolength", setsLayoutLength],
83
+ [
84
+ "renewcommand",
85
+ (m) =>
86
+ firstMacro(m, 0) === "baselinestretch"
87
+ ? "\\renewcommand{\\baselinestretch}"
88
+ : null,
89
+ ],
90
+ ]);
91
+
92
+ /** How a message names a macro that overrides the layout, or null when it does not. */
93
+ const overrideOf: Namer = (m) => OVERRIDES.get(m.content)?.(m) ?? null;
94
+
95
+ /** Every layout override in the source, in document order. */
96
+ export function layoutOverridesOf(t: ParsedTex): readonly LayoutOverride[] {
97
+ // Nothing is pruned: a comment, and a `comment` environment, reach the tree as leaves of text.
98
+ const lists = visited(t.root, () => false).filter(isList);
99
+ return lists.flatMap((list) =>
100
+ list.flatMap((n) => {
101
+ if (n.type !== "macro") return [];
102
+ const command = overrideOf(n);
103
+ return command === null ? [] : [{ command, place: macroPlace(n) }];
104
+ }),
105
+ );
106
+ }
@@ -63,9 +63,17 @@ const NONE: Shown = { kind: "none" };
63
63
  */
64
64
  const SHOWN_ARGUMENT: ReadonlyMap<string, Shown> = new Map<string, Shown>([
65
65
  ["texorpdfstring", { kind: "argument", at: 0 }],
66
- ...["color", "fontsize", "vspace", "hspace", "setlength", "addtolength"].map(
67
- (m) => [m, NONE] as const,
68
- ),
66
+ ...[
67
+ "color",
68
+ "fontsize",
69
+ "vspace",
70
+ "hspace",
71
+ "setlength",
72
+ "addtolength",
73
+ "linespread",
74
+ "geometry",
75
+ "newgeometry",
76
+ ].map((m) => [m, NONE] as const),
69
77
  ]);
70
78
 
71
79
  /** The text a (non-key) macro typesets: see `SHOWN_ARGUMENT`. */
@@ -82,7 +82,11 @@ const LINK_SIGNATURES = Object.fromEntries(signed(["href"], "m m"));
82
82
  * Declarations unified-latex has no signature for: signed so their arguments (sizes, not text)
83
83
  * attach to them instead of standing beside them as groups a reader would seem to see.
84
84
  */
85
- const DECLARATION_SIGNATURES = Object.fromEntries(signed(["fontsize"], "m m"));
85
+ const DECLARATION_SIGNATURES = Object.fromEntries([
86
+ ...signed(["fontsize"], "m m"),
87
+ // The layout commands `format/layout-override` reads: a spacing factor, geometry's key list.
88
+ ...signed(["linespread", "geometry", "newgeometry"], "m"),
89
+ ]);
86
90
 
87
91
  /**
88
92
  * Macros that define other macros or environments. A heading in a body is no section where it is
@@ -289,8 +289,8 @@ try {
289
289
  if (existsSync(factsFile)) {
290
290
  const f = JSON.parse(readFileSync(factsFile, "utf8"));
291
291
  check(
292
- "measure: the facts are schema 2, about paper.pdf, with the last page's heights",
293
- f.schema === 2 &&
292
+ "measure: the facts are schema 3, about paper.pdf, with the last page's heights",
293
+ f.schema === 3 &&
294
294
  f.pdf === "paper.pdf" &&
295
295
  JSON.stringify(f.last_page_cols_pt) === "[400,400]",
296
296
  );
package/src/build.test.ts CHANGED
@@ -115,6 +115,11 @@ const reader =
115
115
  },
116
116
  last,
117
117
  layout: [],
118
+ pageTexts: [],
119
+ links: [],
120
+ bibAnchorPage: null,
121
+ appendixAnchorPage: null,
122
+ metadata: {},
118
123
  },
119
124
  });
120
125
  const quiet = () => {};
package/src/cli.test.ts CHANGED
@@ -578,3 +578,16 @@ test("`paperlint hook` with no name: exit 2, and the usage names an example", as
578
578
  err: "`hook` needs a name, e.g. `paperlint hook paper-edit-guard`",
579
579
  });
580
580
  });
581
+
582
+ test("parseSettings refuses a root `identity` that is not a list of words, and takes one that is", () => {
583
+ const bad = parseSettings({ identity: "Ada" }, "paperlint.json", "/r");
584
+ assert.deepEqual(bad, {
585
+ ok: false,
586
+ error:
587
+ 'paperlint.json: "identity" must be a list of strings, each with a letter or digit, got "Ada"',
588
+ });
589
+ assert.equal(
590
+ parseSettings({ identity: ["Ada Example"] }, "paperlint.json", "/r").ok,
591
+ true,
592
+ );
593
+ });
package/src/cli.ts CHANGED
@@ -49,12 +49,25 @@ import { banalInstaller, parseBanalSettings } from "./adapters/banal/index.ts";
49
49
  import { curlDownload } from "./adapters/curl/index.ts";
50
50
  import { latexReader } from "./adapters/latex/index.ts";
51
51
  import { hostDirs, nodeAdapters, nodeFiles } from "./adapters/node/index.ts";
52
- import { VENUE_RULE_LEVELS, venueRules } from "./venue-rules.ts";
53
- import { TEX_VENUE_RULE_LEVELS, texVenueRules } from "./tex-venue-rules.ts";
52
+ import {
53
+ ANONYMITY_RULE_LEVELS,
54
+ anonymityRules,
55
+ PAGE_LIMIT_RULE_LEVELS,
56
+ pageLimitRules,
57
+ VENUE_RULE_LEVELS,
58
+ venueRules,
59
+ } from "./venue-rules.ts";
60
+ import {
61
+ FORMAT_RULE_LEVELS,
62
+ formatRules,
63
+ TEX_VENUE_RULE_LEVELS,
64
+ texVenueRules,
65
+ } from "./tex-venue-rules.ts";
54
66
  import { claimProvenanceRule } from "./claim-provenance.ts";
55
67
  import { registerRule } from "./register.ts";
56
68
  import {
57
69
  paperRules,
70
+ identityField,
58
71
  stringFields,
59
72
  type PaperSettings,
60
73
  } from "./paper-settings.ts";
@@ -218,8 +231,9 @@ settings — paperlint.json, at two levels, one schema. Both are optional.
218
231
  blocks (files, ignores, rules) with globs relative to that file. Order, later wins: paperlint's
219
232
  own, the venue preset's, the root file's, the paper's. Optional rules (off unless turned on):
220
233
  pdf/last-page-balance. The venue rules (pdf/fresh, pdf/profile, pdf/fonts, pdf/geometry,
221
- pdf/limits, pdf/body-size, pdf/measured) are on for every paper with a venue preset; set one to
222
- "off" to skip it. An unknown key, in either file, is an error.
234
+ pdf/body-size, pdf/measured, format/page-limit, format/layout-override, anonymity/identity) are
235
+ on for every paper with a venue preset; set one to "off" to skip it. An unknown key, in either
236
+ file, is an error.
223
237
  `;
224
238
 
225
239
  /** The config the user would otherwise write by hand. The data comes from `opts`, the mechanism is here. */
@@ -262,6 +276,9 @@ export function buildConfig(
262
276
  ...venueRules({ files: nodeFiles, venuesDir: presetsDir() }),
263
277
  },
264
278
  },
279
+ anonymity: {
280
+ rules: anonymityRules({ files: nodeFiles, venuesDir: presetsDir() }),
281
+ },
265
282
  },
266
283
  },
267
284
  {
@@ -325,6 +342,16 @@ export function buildConfig(
325
342
  },
326
343
  paper: { rules: texPaperRules },
327
344
  bib: bibReachable,
345
+ format: {
346
+ rules: {
347
+ ...formatRules({
348
+ files: nodeFiles,
349
+ venuesDir: presetsDir(),
350
+ latex: latexReader,
351
+ }),
352
+ ...pageLimitRules({ files: nodeFiles, venuesDir: presetsDir() }),
353
+ },
354
+ },
328
355
  },
329
356
  language: "tex/latex",
330
357
  rules: {
@@ -340,7 +367,10 @@ export function buildConfig(
340
367
  "tex/acm-frontmatter-override": "error",
341
368
  // Silent for a paper whose paperlint.json names no venue (src/venue-rules.ts).
342
369
  ...VENUE_RULE_LEVELS,
370
+ ...ANONYMITY_RULE_LEVELS,
343
371
  ...TEX_VENUE_RULE_LEVELS,
372
+ ...FORMAT_RULE_LEVELS,
373
+ ...PAGE_LIMIT_RULE_LEVELS,
344
374
  },
345
375
  });
346
376
  // THE FILES A paper.tex INCLUDES FROM ITS BODY, each read on its own and reported at its own path
@@ -667,6 +697,8 @@ export function parseSettings(
667
697
  };
668
698
  const defaults = stringFields(raw);
669
699
  if (!defaults.ok) return { ok: false, error: `${where}: ${defaults.error}` };
700
+ const identity = identityField(raw);
701
+ if (!identity.ok) return { ok: false, error: `${where}: ${identity.error}` };
670
702
  const structure = Structure.safeParse(raw["structure"]);
671
703
  if (!structure.success)
672
704
  return {
@@ -0,0 +1,163 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ findLeaks,
4
+ spellingsOf,
5
+ tokenPattern,
6
+ type Searchable,
7
+ } from "./anonymity.ts";
8
+
9
+ const page = (text: string, n = 1): Searchable => ({
10
+ place: { kind: "page", page: n },
11
+ text,
12
+ });
13
+ const found = (token: string, text: string): string[] =>
14
+ findLeaks([token], [page(text)]).map((l) => l.found);
15
+
16
+ describe("findLeaks — the spellings of one name a PDF produces", () => {
17
+ it("plain, and in any case (the flags fold Unicode, no locale)", () => {
18
+ expect(found("Ada Example", "by Ada Example, 2026")).toEqual([
19
+ "Ada Example",
20
+ ]);
21
+ expect(found("Ada Example", "ADA EXAMPLE")).toEqual(["ADA EXAMPLE"]);
22
+ });
23
+
24
+ it("Cyrillic folds too", () => {
25
+ expect(found("Ада Пример", "АДА ПРИМЕР пишет")).toEqual(["АДА ПРИМЕР"]);
26
+ expect(found("АДА", "ада")).toEqual(["ада"]);
27
+ });
28
+
29
+ it("items joined without a space where kerning split a word", () => {
30
+ expect(found("Ada Example", "AdaExample")).toEqual(["AdaExample"]);
31
+ });
32
+
33
+ it("letter-spaced text", () => {
34
+ expect(found("Ada Example", "A d a E x a m p l e")).toEqual([
35
+ "A d a E x a m p l e",
36
+ ]);
37
+ });
38
+
39
+ it("a word hyphenated across a line break, and a name broken across lines", () => {
40
+ expect(found("Ada Example", "Ada Exam-\nple wrote")).toEqual([
41
+ "Ada Exam-\nple",
42
+ ]);
43
+ expect(found("Ada Example", "by Ada\nExample")).toEqual(["Ada\nExample"]);
44
+ expect(found("adaexample", "ada­example")).toEqual(["ada­example"]);
45
+ });
46
+
47
+ it("ligatures (NFKC on both sides)", () => {
48
+ expect(found("Fiona", "fiona")).toEqual(["fiona"]);
49
+ expect(found("fiona", "Fiona")).toEqual(["Fiona"]);
50
+ });
51
+
52
+ it("handles and addresses, as whole tokens", () => {
53
+ expect(found("adaexample", "https://github.com/adaexample/tool")).toEqual([
54
+ "adaexample",
55
+ ]);
56
+ expect(found("ada@example.org", "mail ada@example.org.")).toEqual([
57
+ "ada@example.org",
58
+ ]);
59
+ });
60
+
61
+ it("does not match inside another word", () => {
62
+ expect(found("Ada", "Canada and adamant")).toEqual([]);
63
+ expect(found("MIT", "submitted")).toEqual([]);
64
+ });
65
+
66
+ it("says nothing about a clean text", () => {
67
+ expect(found("Ada Example", "Anonymous Authors")).toEqual([]);
68
+ });
69
+ });
70
+
71
+ describe("findLeaks — places and tokens", () => {
72
+ it("reports each token once per place, with the place", () => {
73
+ const leaks = findLeaks(
74
+ ["Ada Example", "ada@example.org"],
75
+ [
76
+ page("Ada Example and Ada Example", 2),
77
+ {
78
+ place: { kind: "metadata", field: "Author" },
79
+ text: "ada@example.org",
80
+ },
81
+ {
82
+ place: {
83
+ kind: "link",
84
+ page: 3,
85
+ uri: "https://github.com/adaexample",
86
+ },
87
+ text: "https://github.com/adaexample",
88
+ },
89
+ ],
90
+ );
91
+ expect(leaks).toEqual([
92
+ {
93
+ token: "Ada Example",
94
+ place: { kind: "page", page: 2 },
95
+ found: "Ada Example",
96
+ },
97
+ {
98
+ token: "ada@example.org",
99
+ place: { kind: "metadata", field: "Author" },
100
+ found: "ada@example.org",
101
+ },
102
+ {
103
+ token: "Ada Example",
104
+ place: { kind: "link", page: 3, uri: "https://github.com/adaexample" },
105
+ found: "adaexample",
106
+ },
107
+ ]);
108
+ });
109
+
110
+ it("a token of separators only has no pattern and finds nothing", () => {
111
+ expect(tokenPattern(" - ")).toBeNull();
112
+ expect(findLeaks([" "], [page("anything at all")])).toEqual([]);
113
+ });
114
+ });
115
+
116
+ describe("findLeaks — one text, two tokens", () => {
117
+ it("is one leak, under the first token declared", () => {
118
+ expect(
119
+ findLeaks(["Ada Example", "adaexample"], [page("by AdaExample")]),
120
+ ).toEqual([
121
+ {
122
+ token: "Ada Example",
123
+ place: { kind: "page", page: 1 },
124
+ found: "AdaExample",
125
+ },
126
+ ]);
127
+ });
128
+ });
129
+
130
+ describe("spellingsOf — a personal name as a reference list prints it", () => {
131
+ it("adds initials and surname-first forms to a name of two to four words", () => {
132
+ expect(spellingsOf("Ada Example")).toEqual([
133
+ "Ada Example",
134
+ "A. Example",
135
+ "Example, Ada",
136
+ "Example, A.",
137
+ ]);
138
+ expect(spellingsOf("Ada Byron Example")).toEqual([
139
+ "Ada Byron Example",
140
+ "A. B. Example",
141
+ "Example, Ada Byron",
142
+ "Example, A. B.",
143
+ ]);
144
+ });
145
+
146
+ it("leaves a handle, an address, one word, or lowercase words alone", () => {
147
+ expect(spellingsOf("adaexample")).toEqual(["adaexample"]);
148
+ expect(spellingsOf("ada@example.org")).toEqual(["ada@example.org"]);
149
+ expect(spellingsOf("Example")).toEqual(["Example"]);
150
+ expect(spellingsOf("example tool")).toEqual(["example tool"]);
151
+ expect(spellingsOf("A B C D E")).toEqual(["A B C D E"]);
152
+ });
153
+
154
+ it("a self-citation's author list finds the declared name", () => {
155
+ expect(
156
+ found("Ada Example", "[3] A. Example and B. Other, “A tool,” 2025."),
157
+ ).toEqual(["A. Example"]);
158
+ expect(found("Ada Example", "Example, A., and Other, B. (2025)")).toEqual([
159
+ "Example, A.",
160
+ ]);
161
+ expect(found("Ada Example", "B. Example wrote")).toEqual([]);
162
+ });
163
+ });