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
@@ -5,10 +5,14 @@
5
5
  * pdf/profile error the preset the paper extends resolves, and the kind it names exists
6
6
  * pdf/fonts error every font is embedded, none is Type 3, the venue's families are present
7
7
  * pdf/geometry error page size and column count match the preset
8
- * pdf/limits error body and reference pages within the kind's limit; reference font size
9
- * pdf/body-size warn body font size within the preset's tolerance
8
+ * pdf/body-size error body and reference font sizes within the preset's ranges
10
9
  * pdf/measured warn the venue checks ran at all (a preset is named, facts exist, geometry measured)
11
10
  *
11
+ * and, in plugins named for the requirement rather than the input (paperlint#151):
12
+ *
13
+ * format/page-limit error body and reference pages within the limits of the paper's kind
14
+ * anonymity/identity error a blind venue: the paper declares `identity`, and the PDF says none of it
15
+ *
12
16
  * ── WHAT THEY READ ───────────────────────────────────────────────────────────────
13
17
  * Like `pdf/last-page-balance`, they run on a paper's `paper.tex` and judge the files beside it:
14
18
  * `paperlint.json` (which preset, which kind), `_build/paper.facts.json` (what `paperlint build`
@@ -30,26 +34,32 @@
30
34
  * not built / no facts pdf/measured (warn) — lint often runs before or without a build (the
31
35
  * CI action only lints); a warning is printed and does not fail
32
36
  * facts about another PDF pdf/fresh (error) — judging them would judge an earlier build
33
- * no geometry (no banal) pdf/measured (warn); geometry, limits, body-size silent; fonts still judge
34
- * kind does not resolve pdf/profile (error); limits silent; everything else still judges
37
+ * no geometry (no banal) pdf/measured (warn); geometry and body-size silent, page-limit judges
38
+ * only a body counted before the references; fonts still judge
39
+ * kind does not resolve pdf/profile (error); page-limit silent; everything else still judges
35
40
  */
36
41
  import { dirname, isAbsolute, join, basename, relative } from "node:path";
37
42
  import {
38
43
  FACTS_DIR,
39
44
  FACTS_FILE,
45
+ FACTS_SCHEMA,
40
46
  factsPath,
41
47
  parseFactsText,
42
48
  type FontEntry,
43
49
  type ReadFacts,
44
50
  } from "./facts-file.ts";
45
51
  import type { KindLimits, VenueFormat } from "./tex-requirements.ts";
46
- import { paperPreset, presetProblemText } from "./presets.ts";
52
+ import { paperPreset, presetProblemText, type Preset } from "./presets.ts";
53
+ import type { PaperSettings } from "./paper-settings.ts";
47
54
  import type { FlatGeometry } from "./domain/geometry.ts";
48
55
  import { callerPath } from "./caller-path.ts";
49
56
  import { sha256Hex } from "./domain/sha256.ts";
50
57
  import { fieldOf } from "./domain/record.ts";
51
58
  import type { Files } from "./ports/files.ts";
52
59
  import { CONFIG_FILE } from "#lib/paper-config";
60
+ import { findLeaks, type Leak, type Searchable } from "./domain/anonymity.ts";
61
+ import { bodyEnd, type BodyEnd } from "./domain/body-pages.ts";
62
+ import type { TextFacts } from "./facts-file.ts";
53
63
 
54
64
  // ── the verdict's vocabulary ─────────────────────────────────────────────────────────
55
65
 
@@ -71,6 +81,10 @@ export interface Resolved {
71
81
  readonly kind: { readonly name: string; readonly limits: KindLimits } | null;
72
82
  /** Why the kind did not resolve — `pdf/profile` reports it — or null. */
73
83
  readonly kindProblem: Finding | null;
84
+ /** Whether the venue reviews double-blind (the preset's `blind`). */
85
+ readonly blind: boolean;
86
+ /** What identifies the authors (`identity` in paperlint.json), or null when undeclared. */
87
+ readonly identity: readonly string[] | null;
74
88
  }
75
89
 
76
90
  /** Everything the rules need to know about one paper, decided once. */
@@ -125,6 +139,15 @@ function kindOf(
125
139
  };
126
140
  }
127
141
 
142
+ /** How the venue reviews, and who the paper says wrote it: what `anonymity/*` judges by. */
143
+ const reviewOf = (
144
+ preset: Pick<Preset, "blind">,
145
+ settings: Pick<PaperSettings, "identity">,
146
+ ): Pick<Resolved, "blind" | "identity"> => ({
147
+ blind: preset.blind,
148
+ identity: settings.identity,
149
+ });
150
+
128
151
  /** The facts about the PDF on disk, or the `pdf/fresh` finding that says why they are not. */
129
152
  function freshFacts(
130
153
  files: Files,
@@ -134,7 +157,7 @@ function freshFacts(
134
157
  const parsed = parseFactsText(body);
135
158
  if (!parsed.ok)
136
159
  return parsed.error.kind === "schema"
137
- ? finding("schema", { got: parsed.error.got })
160
+ ? finding("schema", { got: parsed.error.got, want: FACTS_SCHEMA })
138
161
  : finding("factsBroken", { why: parsed.error.why });
139
162
  const f = parsed.value;
140
163
  const pdf = files.readBytes(
@@ -168,6 +191,7 @@ export function assessPaper(paperDir: string, deps: VenueRuleDeps): Assessment {
168
191
  venue: label,
169
192
  format: p.preset.format,
170
193
  ...kindOf(label, p.preset.format, p.settings.kind),
194
+ ...reviewOf(p.preset, p.settings),
171
195
  };
172
196
  const factsText = text(deps.files, factsPath(paperDir));
173
197
  if (factsText === null) return { kind: "unbuilt", venue };
@@ -238,22 +262,18 @@ export function judgeGeometry(
238
262
  }
239
263
 
240
264
  /**
241
- * Body and reference pages against the kind's limits, and the reference font size against the
242
- * profile's range. The range is about the DECLARED size and banal measures the rendered mode, so
243
- * it is widened by `body_pt_tol` — the drift the preset itself names — or a correct paper fails.
265
+ * Body and reference pages, as banal classifies them, against the kind's limits. A venue that counts
266
+ * the body before the references is judged on its body by `judgeBodyEnd` instead.
244
267
  */
245
- export function judgeLimits(g: FlatGeometry, resolved: Resolved): Finding[] {
246
- return [...judgePages(g, resolved), ...judgeRefPt(g, resolved)];
247
- }
248
-
249
- function judgePages(g: FlatGeometry, resolved: Resolved): Finding[] {
268
+ export function judgePages(g: FlatGeometry, resolved: Resolved): Finding[] {
250
269
  const kind = resolved.kind;
251
270
  if (kind === null) return [];
252
271
  const pages: [number | null, number, string][] = [
253
272
  [kind.limits.bodyPagesMax, g.body_pages, "body pages"],
254
273
  [kind.limits.refPagesMax, g.ref_pages, "reference pages"],
255
274
  ];
256
- return pages.flatMap(([max, got, what]) =>
275
+ const from = resolved.format.bodyEndsAt === "references" ? 1 : 0;
276
+ return pages.slice(from).flatMap(([max, got, what]) =>
257
277
  max !== null && got > max
258
278
  ? [
259
279
  finding("pages", {
@@ -268,29 +288,122 @@ function judgePages(g: FlatGeometry, resolved: Resolved): Finding[] {
268
288
  );
269
289
  }
270
290
 
271
- function judgeRefPt(g: FlatGeometry, resolved: Resolved): Finding[] {
272
- const f = resolved.format;
273
- const slop = f.bodyPtTol ?? 0;
274
- if (f.refPtMin === null || f.refPtMax === null || g.ref_pt === null)
291
+ /**
292
+ * The body counted up to where it ends (`body_ends_at: references`) — the references or the
293
+ * appendix, whichever comes first — against the kind's limit: the page it ends on counts when body
294
+ * text stands above the heading there (`bodyEnd`). A limit to check and no end found, or a
295
+ * structural signal the text does not bear out, is said, not passed: the count has nothing to
296
+ * stand on.
297
+ */
298
+ export function judgeBodyEnd(
299
+ text: Pick<TextFacts, "pages" | "bibAnchorPage" | "appendixAnchorPage">,
300
+ resolved: Resolved,
301
+ ): readonly Finding[] {
302
+ const kind = resolved.kind;
303
+ const max = kind?.limits.bodyPagesMax ?? null;
304
+ if (
305
+ resolved.format.bodyEndsAt !== "references" ||
306
+ kind === null ||
307
+ max === null
308
+ )
275
309
  return [];
276
- return g.ref_pt < f.refPtMin - slop || g.ref_pt > f.refPtMax + slop
277
- ? [
278
- finding("refPt", {
279
- got: g.ref_pt,
280
- min: f.refPtMin,
281
- max: f.refPtMax,
282
- slop,
310
+ const end = endOfBody(text);
311
+ const venue = resolved.venue;
312
+ switch (end.kind) {
313
+ case "missing":
314
+ return [finding("noReferences", { venue })];
315
+ case "disagree":
316
+ return [finding("unclear", { venue, why: disagreement(end) })];
317
+ case "found": {
318
+ const what = `body pages (up to the ${end.by} on page ${String(end.page)})`;
319
+ const got = end.bodyPages;
320
+ return got > max
321
+ ? [finding("pages", { what, got, max, venue, kind: kind.name })]
322
+ : [];
323
+ }
324
+ }
325
+ }
326
+
327
+ /** Where the body ends in what the PDF says, and where hyperref anchored the two ends. */
328
+ const endOfBody = (
329
+ t: Pick<TextFacts, "pages" | "bibAnchorPage" | "appendixAnchorPage">,
330
+ ): BodyEnd =>
331
+ bodyEnd(t.pages, { bib: t.bibAnchorPage, appendix: t.appendixAnchorPage });
332
+
333
+ /** What the `unclear` message says about a structural signal the text does not bear out. */
334
+ function disagreement(
335
+ d: Extract<BodyEnd, { readonly kind: "disagree" }>,
336
+ ): string {
337
+ const page = String(d.anchor);
338
+ if (d.what === "appendix")
339
+ return `hyperref anchors the appendix on page ${page}, and no line on page ${page} reads «Appendix»`;
340
+ return d.heading === null
341
+ ? `the first bibliography entry is on page ${page} (hyperref's destination), and no line on page ${page} reads «References»`
342
+ : `the first bibliography entry is on page ${page} (hyperref's destination), and the «References» heading followed by [1] is on page ${String(d.heading)}`;
343
+ }
344
+
345
+ /** Everything the PDF says, as places to search: each page, each metadata field, each link. */
346
+ export const searchable = (t: TextFacts): readonly Searchable[] => [
347
+ ...t.pages.map((text, i) => ({
348
+ place: { kind: "page" as const, page: i + 1 },
349
+ text,
350
+ })),
351
+ ...Object.entries(t.metadata).map(([field, text]) => ({
352
+ place: { kind: "metadata" as const, field },
353
+ text,
354
+ })),
355
+ ...t.links.map(({ page, uri }) => ({
356
+ place: { kind: "link" as const, page, uri },
357
+ text: uri,
358
+ })),
359
+ ];
360
+
361
+ /** Where a leak is, in words. */
362
+ function whereOf(l: Leak): string {
363
+ switch (l.place.kind) {
364
+ case "page":
365
+ return `PDF, page ${String(l.place.page)}`;
366
+ case "metadata":
367
+ return `PDF metadata: ${l.place.field}`;
368
+ case "link":
369
+ return `link target on page ${String(l.place.page)} (${l.place.uri})`;
370
+ }
371
+ }
372
+
373
+ /** A blind venue's paper: `identity` declared, and none of it in the PDF. */
374
+ export function judgeAnonymity(
375
+ resolved: Resolved,
376
+ text: TextFacts | null,
377
+ ): readonly Finding[] {
378
+ if (!resolved.blind) return [];
379
+ const identity = resolved.identity ?? [];
380
+ if (identity.length === 0)
381
+ return [finding("noIdentity", { venue: resolved.venue })];
382
+ return text === null
383
+ ? []
384
+ : findLeaks(identity, searchable(text)).map((l) =>
385
+ finding("leak", {
386
+ found: l.found.replace(/\s+/gu, " "),
387
+ token: l.token,
388
+ where: whereOf(l),
283
389
  venue: resolved.venue,
284
390
  }),
285
- ]
286
- : [];
391
+ );
287
392
  }
288
393
 
289
- /** The body font size against `body_pt ± body_pt_tol`. */
394
+ /** The body font size against `body_pt ± body_pt_tol`, and the reference font size against its range. */
290
395
  export function judgeBodySize(
291
396
  g: FlatGeometry,
292
397
  format: VenueFormat,
293
398
  venue: string,
399
+ ): Finding[] {
400
+ return [...judgeBodyPt(g, format, venue), ...judgeRefPt(g, format, venue)];
401
+ }
402
+
403
+ function judgeBodyPt(
404
+ g: FlatGeometry,
405
+ format: VenueFormat,
406
+ venue: string,
294
407
  ): Finding[] {
295
408
  const want = format.bodyPt;
296
409
  const tol = format.bodyPtTol;
@@ -301,6 +414,34 @@ export function judgeBodySize(
301
414
  : [];
302
415
  }
303
416
 
417
+ /**
418
+ * The reference font size against the profile's range. The range is about the DECLARED size and
419
+ * banal measures the rendered mode, so it is widened by `body_pt_tol` — the drift the preset itself
420
+ * names — or a correct paper fails.
421
+ */
422
+ function judgeRefPt(
423
+ g: FlatGeometry,
424
+ format: VenueFormat,
425
+ venue: string,
426
+ ): Finding[] {
427
+ const slop = format.bodyPtTol ?? 0;
428
+ if (format.refPtMin === null || format.refPtMax === null || g.ref_pt === null)
429
+ return [];
430
+ return g.ref_pt < format.refPtMin - slop || g.ref_pt > format.refPtMax + slop
431
+ ? [
432
+ finding("refPt", {
433
+ got: g.ref_pt,
434
+ range:
435
+ format.refPtMin === format.refPtMax
436
+ ? String(format.refPtMin)
437
+ : `${String(format.refPtMin)}–${String(format.refPtMax)}`,
438
+ slop,
439
+ venue,
440
+ }),
441
+ ]
442
+ : [];
443
+ }
444
+
304
445
  // ── the ESLint rules ────────────────────────────────────────────────────────────────
305
446
 
306
447
  /** The slice of ESLint's rule context these rules use. */
@@ -323,7 +464,7 @@ export interface VenueRuleContext {
323
464
  export interface VenueRuleModule {
324
465
  readonly meta: {
325
466
  readonly type: "problem" | "suggestion";
326
- readonly docs: { readonly description: string };
467
+ readonly docs: { readonly description: string; readonly url?: string };
327
468
  readonly schema: readonly object[];
328
469
  readonly messages: Readonly<Record<string, string>>;
329
470
  };
@@ -339,6 +480,10 @@ type Judge = (a: Assessment, ctx: JudgeContext) => Finding[];
339
480
 
340
481
  const REBUILD = "rebuild the paper (`paperlint build`) to rewrite it";
341
482
 
483
+ /** Where a rule's page lives: `docs/rules/<plugin>/<name>.md` on the default branch. */
484
+ export const rulePageUrl = (id: string): string =>
485
+ `https://github.com/zernie/paperlint/blob/main/docs/rules/${id}.md`;
486
+
342
487
  /** Which rule reports what: each takes the assessment and returns its own findings. */
343
488
  const JUDGES: Readonly<Record<VenueRuleName, Judge>> = {
344
489
  profile: (a) =>
@@ -376,10 +521,19 @@ const JUDGES: Readonly<Record<VenueRuleName, Judge>> = {
376
521
  dimTolOf(options[0]),
377
522
  )
378
523
  : [],
379
- limits: (a) =>
380
- a.kind === "ready" && a.facts.geometry
381
- ? judgeLimits(a.facts.geometry, a.venue)
524
+ "page-limit": (a) =>
525
+ a.kind === "ready"
526
+ ? [
527
+ ...judgeBodyEnd(a.facts.text, a.venue),
528
+ ...(a.facts.geometry ? judgePages(a.facts.geometry, a.venue) : []),
529
+ ]
382
530
  : [],
531
+ anonymity: (a) =>
532
+ a.kind === "ready"
533
+ ? [...judgeAnonymity(a.venue, a.facts.text)]
534
+ : a.kind === "unbuilt"
535
+ ? [...judgeAnonymity(a.venue, null)]
536
+ : [],
383
537
  "body-size": (a) =>
384
538
  a.kind === "ready" && a.facts.geometry
385
539
  ? judgeBodySize(a.facts.geometry, a.venue.format, a.venue.venue)
@@ -408,7 +562,7 @@ const META: Readonly<Record<VenueRuleName, Meta>> = {
408
562
  },
409
563
  messages: {
410
564
  factsBroken: `_build/paper.facts.json cannot be read: {{why}} — ${REBUILD}`,
411
- schema: `_build/paper.facts.json has schema {{got}}; the venue rules read schema 2 — ${REBUILD}`,
565
+ schema: `_build/paper.facts.json has schema {{got}}; the venue rules read schema {{want}} — ${REBUILD}`,
412
566
  pdfMissing:
413
567
  "_build/paper.facts.json describes {{pdf}}, which is not on disk (a failed build removes it) — rebuild the paper",
414
568
  stale:
@@ -477,25 +631,41 @@ const META: Readonly<Record<VenueRuleName, Meta>> = {
477
631
  "{{got}} column(s), {{venue}} requires {{want}} — the wrong document class or class option",
478
632
  },
479
633
  },
480
- limits: {
634
+ "page-limit": {
481
635
  docs: {
482
636
  description:
483
- "body and reference pages within the limit of the paper's kind; reference font size in range",
637
+ "body and reference pages within the limits of the paper's kind at its venue",
638
+ url: rulePageUrl("format/page-limit"),
484
639
  },
485
640
  messages: {
486
641
  pages:
487
642
  "{{what}}: {{got}}, over the limit {{max}} for {{venue}}/{{kind}} — a desk reject; cut the text",
488
- refPt:
489
- "the reference font size is {{got}} pt, outside {{min}}–{{max}} pt for {{venue}} (allowing ±{{slop}} pt for the measuring drift) — fix the bibliography's font size",
643
+ noReferences:
644
+ "{{venue}} limits the body as the pages up to the references, and no page of the PDF has a line reading «References» or «Bibliography» — so the body was NOT counted. Give the bibliography its heading",
645
+ unclear:
646
+ "could not tell where the body ends, so it was NOT counted against {{venue}}'s limit: {{why}}. Check the headings of the bibliography and the appendix, and that nothing before them reads «References» above a [1]",
647
+ },
648
+ },
649
+ anonymity: {
650
+ docs: {
651
+ description:
652
+ "a double-blind venue's PDF names none of the authors' declared identity — in its text, its metadata or its links",
653
+ url: rulePageUrl("anonymity/identity"),
654
+ },
655
+ messages: {
656
+ noIdentity: `{{venue}} reviews double-blind, and this paper declares no \`identity\`, so nothing in the PDF can be checked against the authors. Declare it in ${CONFIG_FILE} (the root's for every paper, or this paper's): "identity": ["Your Name", "your-handle", "you@example.org", "Your University", "your-project"]`,
657
+ leak: "{{where}}: «{{found}}» matches «{{token}}» in `identity`, and {{venue}} reviews double-blind — remove it, or refer to your own work in the third person",
490
658
  },
491
659
  },
492
660
  "body-size": {
493
- type: "suggestion",
494
661
  docs: {
495
662
  description:
496
- "body font size within the venue preset's tolerance (a measured mode, so a warning)",
663
+ "the body and reference font sizes are the venue template's, within the preset's tolerance",
664
+ url: rulePageUrl("pdf/body-size"),
497
665
  },
498
666
  messages: {
667
+ refPt:
668
+ "the reference font size is {{got}} pt, outside {{range}} pt for {{venue}} (allowing ±{{slop}} pt for the measuring drift) — fix the bibliography's font size",
499
669
  body: "the body font size measures {{got}} pt against {{want}} ± {{tol}} pt for {{venue}}. The measurement is the mode of the rendered text, not the declared size — check \\documentclass and its options",
500
670
  bodyMissing:
501
671
  "the build did not measure a body font size — rebuild the paper",
@@ -509,25 +679,42 @@ export type VenueRuleName =
509
679
  | "measured"
510
680
  | "fonts"
511
681
  | "geometry"
512
- | "limits"
513
- | "body-size";
682
+ | "page-limit"
683
+ | "body-size"
684
+ | "anonymity";
685
+
686
+ /** The venue rules of the `pdf` plugin; `page-limit` and `anonymity` live in their own. */
687
+ export type PdfRuleName = Exclude<VenueRuleName, "page-limit" | "anonymity">;
514
688
 
515
689
  /**
516
690
  * The level each venue rule is on at in paperlint's own config, for every `paper.tex`. One owner:
517
691
  * the config reads it, the docs describe it, a consumer overrides it in `rules`.
518
692
  */
519
693
  export const VENUE_RULE_LEVELS: Readonly<
520
- Record<`pdf/${VenueRuleName}`, "error" | "warn">
694
+ Record<`pdf/${PdfRuleName}`, "error" | "warn">
521
695
  > = {
522
696
  "pdf/fresh": "error",
523
697
  "pdf/profile": "error",
524
698
  "pdf/fonts": "error",
525
699
  "pdf/geometry": "error",
526
- "pdf/limits": "error",
527
- "pdf/body-size": "warn",
700
+ "pdf/body-size": "error",
528
701
  "pdf/measured": "warn",
529
702
  };
530
703
 
704
+ /** The `anonymity` plugin's rule and its level, on for every `paper.tex` like the venue rules. */
705
+ export const ANONYMITY_RULE_LEVELS: Readonly<
706
+ Record<"anonymity/identity", "error">
707
+ > = {
708
+ "anonymity/identity": "error",
709
+ };
710
+
711
+ /** The `format` plugin's rule over the built PDF and its level, on for every `paper.tex`. */
712
+ export const PAGE_LIMIT_RULE_LEVELS: Readonly<
713
+ Record<"format/page-limit", "error">
714
+ > = {
715
+ "format/page-limit": "error",
716
+ };
717
+
531
718
  /** The line of `\documentclass`, where the class and its options — most of the fixes — live. */
532
719
  function reportLine(source: string): number {
533
720
  const i = source
@@ -569,7 +756,7 @@ function rule(name: VenueRuleName, deps: VenueRuleDeps): VenueRuleModule {
569
756
  /** The venue rules, as the rules of the `pdf` plugin (beside `last-page-balance`). */
570
757
  export function venueRules(
571
758
  deps: VenueRuleDeps,
572
- ): Record<VenueRuleName, VenueRuleModule> {
759
+ ): Record<PdfRuleName, VenueRuleModule> {
573
760
  // Every name listed: a record missing one is a compile error.
574
761
  return {
575
762
  fresh: rule("fresh", deps),
@@ -577,7 +764,20 @@ export function venueRules(
577
764
  measured: rule("measured", deps),
578
765
  fonts: rule("fonts", deps),
579
766
  geometry: rule("geometry", deps),
580
- limits: rule("limits", deps),
581
767
  "body-size": rule("body-size", deps),
582
768
  };
583
769
  }
770
+
771
+ /** The rule of the `anonymity` plugin: `identity`. */
772
+ export function anonymityRules(
773
+ deps: VenueRuleDeps,
774
+ ): Readonly<Record<"identity", VenueRuleModule>> {
775
+ return { identity: rule("anonymity", deps) };
776
+ }
777
+
778
+ /** The `format` plugin's rule over the built PDF: `page-limit` (beside the source's in `tex-venue-rules.ts`). */
779
+ export function pageLimitRules(
780
+ deps: VenueRuleDeps,
781
+ ): Readonly<Record<"page-limit", VenueRuleModule>> {
782
+ return { "page-limit": rule("page-limit", deps) };
783
+ }