paperlint 7.0.0 → 7.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 (75) hide show
  1. package/README.md +18 -11
  2. package/dist/cli.d.ts.map +1 -1
  3. package/dist/cli.js +2 -2
  4. package/dist/cli.js.map +1 -1
  5. package/dist/domain/body-pages.d.ts +29 -0
  6. package/dist/domain/body-pages.d.ts.map +1 -1
  7. package/dist/domain/body-pages.js +25 -0
  8. package/dist/domain/body-pages.js.map +1 -1
  9. package/dist/paper-includes.d.ts +3 -0
  10. package/dist/paper-includes.d.ts.map +1 -1
  11. package/dist/paper-includes.js +17 -2
  12. package/dist/paper-includes.js.map +1 -1
  13. package/dist/presets.d.ts +10 -3
  14. package/dist/presets.d.ts.map +1 -1
  15. package/dist/presets.js +8 -7
  16. package/dist/presets.js.map +1 -1
  17. package/dist/submission.d.ts +8 -0
  18. package/dist/submission.d.ts.map +1 -1
  19. package/dist/submission.js +19 -7
  20. package/dist/submission.js.map +1 -1
  21. package/dist/tex-requirements.d.ts +9 -0
  22. package/dist/tex-requirements.d.ts.map +1 -1
  23. package/dist/tex-requirements.js +7 -3
  24. package/dist/tex-requirements.js.map +1 -1
  25. package/dist/tex-venue-rules.d.ts +5 -2
  26. package/dist/tex-venue-rules.d.ts.map +1 -1
  27. package/dist/tex-venue-rules.js +29 -10
  28. package/dist/tex-venue-rules.js.map +1 -1
  29. package/dist/venue-rules.d.ts +2 -1
  30. package/dist/venue-rules.d.ts.map +1 -1
  31. package/dist/venue-rules.js +55 -7
  32. package/dist/venue-rules.js.map +1 -1
  33. package/docs/configuration.md +1 -1
  34. package/docs/e2e.md +14 -12
  35. package/docs/rules/format/page-limit.md +18 -1
  36. package/docs/rules/tex/template.md +25 -5
  37. package/docs/rules/tex/venue-leftover.md +13 -0
  38. package/docs/rules.md +63 -55
  39. package/docs/submission.md +5 -1
  40. package/fixtures/build-e2e/msr/PIPELINE-STATUS.md +3 -0
  41. package/fixtures/build-e2e/msr/expect.json +18 -0
  42. package/fixtures/build-e2e/msr/paper.tex +49 -0
  43. package/fixtures/build-e2e/msr/paperlint.json +1 -0
  44. package/fixtures/build-e2e/msr-appendix-after/PIPELINE-STATUS.md +3 -0
  45. package/fixtures/build-e2e/msr-appendix-after/expect.json +20 -0
  46. package/fixtures/build-e2e/msr-appendix-after/paper.tex +48 -0
  47. package/fixtures/build-e2e/msr-appendix-after/paperlint.json +1 -0
  48. package/package.json +1 -1
  49. package/presets/ieee-conference.md +1 -1
  50. package/presets/msr.jsonc +71 -0
  51. package/presets/msr.md +94 -0
  52. package/presets/venue-profile.schema.json +25 -0
  53. package/skills/camera-ready/SKILL.md +8 -1
  54. package/skills/camera-ready/SKILL.md.spec.ts +7 -0
  55. package/skills/submission-portal/SKILL.md +2 -2
  56. package/skills/submission-portal/SKILL.md.spec.ts +1 -1
  57. package/skills/submit-paper/SKILL.md +9 -1
  58. package/skills/submit-paper/SKILL.md.spec.ts +8 -0
  59. package/src/cli.ts +7 -2
  60. package/src/domain/body-pages.test.ts +144 -0
  61. package/src/domain/body-pages.ts +61 -1
  62. package/src/new-paper.test.ts +27 -0
  63. package/src/paper-includes.test.ts +26 -0
  64. package/src/paper-includes.ts +20 -2
  65. package/src/paper-settings-commands.test.ts +1 -1
  66. package/src/preset-card-rule.test.ts +1 -0
  67. package/src/presets.test.ts +66 -1
  68. package/src/presets.ts +23 -6
  69. package/src/submission.test.ts +42 -3
  70. package/src/submission.ts +28 -7
  71. package/src/tex-requirements.ts +21 -4
  72. package/src/tex-venue-rules.test.ts +161 -0
  73. package/src/tex-venue-rules.ts +45 -15
  74. package/src/venue-rules.test.ts +173 -2
  75. package/src/venue-rules.ts +71 -10
@@ -3,6 +3,7 @@
3
3
  * requires of the LaTeX itself, before any build:
4
4
  *
5
5
  * tex/template error the `\documentclass` is the preset's class, with every option it names
6
+ * and none it forbids (`template_forbids`)
6
7
  * tex/required-section error each section the preset requires is there, titled exactly, and
7
8
  * where the preset says (`last`: after every section of the body)
8
9
  * tex/venue-leftover warn the text a reader sees names ANOTHER shipped venue — its `name`
@@ -107,20 +108,38 @@ type TemplateData = {
107
108
  readonly template: string;
108
109
  };
109
110
 
110
- /** A class line that names a class, against the template's: the same class, with every option. */
111
+ /** What the template requires of a class line: its class and options, and the options it forbids. */
112
+ interface Wanted {
113
+ readonly cls: DocumentClass;
114
+ readonly forbids: readonly string[];
115
+ }
116
+
117
+ /**
118
+ * A class line that names a class, against the template's: the same class, with every option, and
119
+ * none of the options the preset forbids.
120
+ */
111
121
  function judgeClass(
112
122
  line: Extract<ClassLine, { readonly kind: "class" }>,
113
- want: DocumentClass,
123
+ want: Wanted,
114
124
  data: TemplateData,
115
125
  ): readonly Located[] {
116
126
  const at = spanOf(line.place);
117
- if (line.cls !== want.cls)
127
+ if (line.cls !== want.cls.cls)
118
128
  return [{ messageId: "wrongClass", data: { ...data, got: line.cls }, at }];
119
- return missingOptions(line, want).map((option) => ({
120
- messageId: "missingOption",
121
- data: { ...data, option },
122
- at,
123
- }));
129
+ const forbidden = want.forbids.filter((o) => line.options.includes(o));
130
+ const without = want.forbids.map((o) => `\`${o}\``).join(", ");
131
+ return [
132
+ ...forbidden.map((option) => ({
133
+ messageId: "forbiddenOption",
134
+ data: { ...data, option, without },
135
+ at,
136
+ })),
137
+ ...missingOptions(line, want.cls).map((option) => ({
138
+ messageId: "missingOption",
139
+ data: { ...data, option },
140
+ at,
141
+ })),
142
+ ];
124
143
  }
125
144
 
126
145
  /** A candidate as the message names it: its line, or an empty `\documentclass{}`. */
@@ -133,7 +152,7 @@ const candidateLine = (c: ClassCandidate): string =>
133
152
  */
134
153
  function judgeCandidates(
135
154
  candidates: readonly [ClassCandidate, ...ClassCandidate[]],
136
- want: DocumentClass,
155
+ want: Wanted,
137
156
  data: TemplateData,
138
157
  ): readonly Located[] {
139
158
  const matches = candidates.some(
@@ -150,7 +169,7 @@ function judgeCandidates(
150
169
  /** The paper's class line against a template that was read: present, naming a class, that class. */
151
170
  function judgeClassLine(
152
171
  line: ClassLine,
153
- want: DocumentClass,
172
+ want: Wanted,
154
173
  data: TemplateData,
155
174
  ): readonly Located[] {
156
175
  switch (line.kind) {
@@ -183,7 +202,7 @@ export function judgeTemplate(
183
202
  return [{ messageId: "badTemplate", data, at: null }];
184
203
  }
185
204
  const data = { venue: preset.label, template: documentClassLine(want) };
186
- return judgeClassLine(line, want, data);
205
+ return judgeClassLine(line, { cls: want, forbids: t.forbids }, data);
187
206
  }
188
207
 
189
208
  /** Where a heading starts, or null when the parser gave it no place. */
@@ -248,8 +267,17 @@ export function judgeRequiredSections(
248
267
  );
249
268
  }
250
269
 
251
- /** Another shipped venue: the word for it, and what it is called in a paper's text. */
252
- export type OtherVenue = NamedVenue;
270
+ /**
271
+ * Another shipped venue: the word for it, what it is called in a paper's text, and when a name
272
+ * counts as naming it (`mentions`).
273
+ */
274
+ export type OtherVenue = NamedVenue & Pick<Preset, "mentions">;
275
+
276
+ /**
277
+ * What must follow a name for it to name a `with-year` venue: a year beside it — `2027`, `'27` or
278
+ * `’27`, after spaces or none (`~` renders as a space). A lookahead, so the finding is the name.
279
+ */
280
+ const YEAR_BESIDE = "(?=[\\s\\u00a0]*(?:\\d{4}|['’]\\d{2})(?![\\p{L}\\p{N}]))";
253
281
 
254
282
  /** `s` as a pattern that matches it literally. */
255
283
  const literal = (s: string): string =>
@@ -271,7 +299,7 @@ export function judgeLeftover(
271
299
  .map((alias) => ({
272
300
  other: o.label,
273
301
  re: new RegExp(
274
- `(?<![\\p{L}\\p{N}])${literal(alias)}(?![\\p{L}\\p{N}])`,
302
+ `(?<![\\p{L}\\p{N}])${literal(alias)}(?![\\p{L}\\p{N}])${o.mentions === "with-year" ? YEAR_BESIDE : ""}`,
275
303
  "gu",
276
304
  ),
277
305
  })),
@@ -357,6 +385,8 @@ const RULES: Readonly<
357
385
  "the class is `{{got}}`, and {{venue}} requires `{{template}}` — the page size, fonts and layout the venue checks come from the class",
358
386
  missingOption:
359
387
  "the class option `{{option}}` is missing: {{venue}} requires `{{template}}`",
388
+ forbiddenOption:
389
+ "the class option `{{option}}` is forbidden: {{venue}} requires `{{template}}` without {{without}}",
360
390
  noCandidate:
361
391
  "none of the {{count}} \\documentclass lines is `{{template}}`, which {{venue}} requires: {{candidates}}. The source picks one behind a TeX switch, which is not evaluated; make one of them the venue's",
362
392
  },
@@ -442,7 +472,7 @@ export function otherVenues(
442
472
  ): readonly OtherVenue[] {
443
473
  return shippedVenueNames(deps)
444
474
  .filter((v) => !v.chain.every((f) => preset.chain.includes(f)))
445
- .map(({ label, aliases }) => ({ label, aliases }));
475
+ .map(({ label, aliases, mentions }) => ({ label, aliases, mentions }));
446
476
  }
447
477
 
448
478
  /** What the venue-conformance rules are built with: the preset store, and the LaTeX reader. */
@@ -239,13 +239,13 @@ describe("pdf/profile — the declaration must resolve, or nothing is judged", (
239
239
  "an unknown venue (a typo would silently disable every check)",
240
240
  { extends: "paperlint:agentic-dev", kind: "short" },
241
241
  "preset",
242
- /agenticdev, aidc, aisec, ieee-conference, realm/,
242
+ /agenticdev, aidc, aisec, ieee-conference, msr, realm/,
243
243
  ],
244
244
  [
245
245
  "the base TeX set is not a venue",
246
246
  { extends: "paperlint:tex-base" },
247
247
  "preset",
248
- /agenticdev, aidc, aisec, ieee-conference, realm/,
248
+ /agenticdev, aidc, aisec, ieee-conference, msr, realm/,
249
249
  ],
250
250
  [
251
251
  "paperlint.json that is not JSON",
@@ -863,6 +863,177 @@ describe("format/page-limit on AIDC: the two signals of where the references sta
863
863
  });
864
864
  });
865
865
 
866
+ /**
867
+ * A project's own venue that counts its appendices in the body (`appendix_in_body`): a body of ten
868
+ * pages, appendices included, and two pages holding only references — MSR's rule.
869
+ */
870
+ const OWN_APPENDIX_IN_BODY = {
871
+ [`${PAPER}/own.jsonc`]: JSON.stringify({
872
+ type: "venue",
873
+ name: "Own",
874
+ url: "https://example.org/cfp",
875
+ extends: "paperlint:ieee-conference",
876
+ format: {
877
+ body_ends_at: "references",
878
+ appendix_in_body: true,
879
+ kinds: {
880
+ technical: { body_pages_max: 10, ref_pages_max: 2 },
881
+ open: {},
882
+ refs: { ref_pages_max: 2 },
883
+ },
884
+ },
885
+ }),
886
+ };
887
+
888
+ /** `own.jsonc` papers of the kind named, over IEEE facts with `pages` and the two anchors. */
889
+ const ownSplit = (
890
+ pages: readonly string[],
891
+ bib: number | null,
892
+ appendix: number | null,
893
+ kind = "technical",
894
+ ) =>
895
+ lint({
896
+ venue: { extends: "./own.jsonc", kind },
897
+ extra: OWN_APPENDIX_IN_BODY,
898
+ facts: ieeeFacts((f) => {
899
+ f.pages_text = [...pages];
900
+ f.bib_anchor_page = bib;
901
+ f.appendix_anchor_page = appendix;
902
+ // banal's counts, which this counting replaces: neither is judged.
903
+ f.body_pages = 99;
904
+ f.ref_pages = 99;
905
+ }),
906
+ });
907
+
908
+ describe("format/page-limit with `appendix_in_body`: the appendix is body, the reference pages hold only references", () => {
909
+ const REFS = ["the end\nReferences\n[1] A.\n", "[2] B.\n", "[3] C.\n"];
910
+
911
+ it("ten pages of body and two of references pass; banal's counts are not judged", () => {
912
+ expect(ownSplit([...bodyPages(9), ...REFS], 10, null)).toEqual([]);
913
+ });
914
+
915
+ it("🔴 an appendix after the references that brings the body to eleven fails, naming where it starts", () => {
916
+ const fs = ownSplit(
917
+ [...bodyPages(9), ...REFS.slice(0, 2), "Appendix A.\nDetails\n"],
918
+ 10,
919
+ 12,
920
+ );
921
+ expect(fs.map((f) => f.message)).toEqual([
922
+ "body pages (up to the references on page 10, and the appendix from page 12): 11, over the limit 10 for Own/technical — a desk reject; cut the text",
923
+ ]);
924
+ });
925
+
926
+ it("🔴 three pages holding only references fail", () => {
927
+ const fs = ownSplit([...bodyPages(9), ...REFS, "[4] D.\n"], 10, null);
928
+ expect(fs.map((f) => f.message)).toEqual([
929
+ "pages holding only references: 3, over the limit 2 for Own/technical — a desk reject; cut the text",
930
+ ]);
931
+ });
932
+
933
+ it("an eleventh page of body before the references fails, appendices included", () => {
934
+ expect(ids(ownSplit([...bodyPages(10), ...REFS], 11, null))).toEqual([
935
+ "format/page-limit:pages",
936
+ ]);
937
+ });
938
+
939
+ it.each<
940
+ [string, readonly string[], readonly [number | null, number | null], string]
941
+ >([
942
+ [
943
+ "no references heading",
944
+ bodyPages(3),
945
+ [null, null],
946
+ "format/page-limit:noReferences",
947
+ ],
948
+ [
949
+ "an appendix anchor on a page with no «Appendix» line",
950
+ [...bodyPages(3), ...REFS],
951
+ [4, 6],
952
+ "format/page-limit:unclear",
953
+ ],
954
+ ])("%s: said, not counted", (_, pages, [bib, appendix], want) => {
955
+ expect(ids(ownSplit(pages, bib, appendix))).toEqual([want]);
956
+ });
957
+
958
+ it("a kind with no limits judges nothing; one with a reference limit only judges that", () => {
959
+ expect(ownSplit(bodyPages(3), null, null, "open")).toEqual([]);
960
+ expect(
961
+ ids(ownSplit([...bodyPages(12), ...REFS, "[4] D.\n"], 13, null, "refs")),
962
+ ).toEqual(["format/page-limit:pages"]);
963
+ });
964
+ });
965
+
966
+ /** An MSR technical paper that declares who wrote it. */
967
+ const MSR = {
968
+ extends: "paperlint:msr",
969
+ kind: "technical",
970
+ identity: ["Ada Example"],
971
+ };
972
+
973
+ /** MSR facts: `pages` as the PDF's text, the two anchors where hyperref put them. */
974
+ const msrFacts = (
975
+ pages: readonly string[],
976
+ bib: number,
977
+ appendix: number | null = null,
978
+ ) =>
979
+ ieeeFacts((f) => {
980
+ f.pages_text = [...pages];
981
+ f.bib_anchor_page = bib;
982
+ f.appendix_anchor_page = appendix;
983
+ });
984
+
985
+ /** References that start below the last body text on their page, then `more` pages of them. */
986
+ const refsFrom = (more: number): string[] => [
987
+ "the last lines of the conclusion\nR EFERENCES\n[1] D. E. Knuth.\n",
988
+ ...Array.from({ length: more }, (_, i) => `[${String(i + 2)}] More.\n`),
989
+ ];
990
+
991
+ describe("format/page-limit on MSR: ten pages of main text, appendices included, and two of only references", () => {
992
+ it("🔴 ten pages of body, the references starting on the tenth, and two pages of only references pass", () => {
993
+ expect(
994
+ lint({
995
+ venue: MSR,
996
+ facts: msrFacts([...bodyPages(9), ...refsFrom(2)], 10),
997
+ }),
998
+ ).toEqual([]);
999
+ });
1000
+
1001
+ it("🔴 eleven pages of body fail", () => {
1002
+ const fs = lint({
1003
+ venue: MSR,
1004
+ facts: msrFacts([...bodyPages(10), ...refsFrom(2)], 11),
1005
+ });
1006
+ expect(fs.map((f) => f.message)).toEqual([
1007
+ "body pages (up to the references on page 11, appendices included): 11, over the limit 10 for MSR/technical — a desk reject; cut the text",
1008
+ ]);
1009
+ });
1010
+
1011
+ it("🔴 an appendix before the references that brings the body to eleven fails", () => {
1012
+ const pages = [...bodyPages(9), "Appendix A.\nDetails\n", ...refsFrom(2)];
1013
+ expect(ids(lint({ venue: MSR, facts: msrFacts(pages, 11, 10) }))).toEqual([
1014
+ "format/page-limit:pages",
1015
+ ]);
1016
+ });
1017
+
1018
+ it("🔴 an appendix after the references that brings the body to eleven fails", () => {
1019
+ const pages = [...bodyPages(9), ...refsFrom(1), "Appendix A.\nDetails\n"];
1020
+ const fs = lint({ venue: MSR, facts: msrFacts(pages, 10, 12) });
1021
+ expect(fs[0]?.message).toMatch(
1022
+ /^body pages \(up to the references on page 10, and the appendix from page 12\): 11, over the limit 10/,
1023
+ );
1024
+ });
1025
+
1026
+ it("🔴 three pages of only references fail", () => {
1027
+ const fs = lint({
1028
+ venue: MSR,
1029
+ facts: msrFacts([...bodyPages(9), ...refsFrom(3)], 10),
1030
+ });
1031
+ expect(fs.map((f) => f.message)).toEqual([
1032
+ "pages holding only references: 3, over the limit 2 for MSR/technical — a desk reject; cut the text",
1033
+ ]);
1034
+ });
1035
+ });
1036
+
866
1037
  /** An AIDC paper that declares who wrote it. */
867
1038
  const BLIND = {
868
1039
  extends: "paperlint:aidc",
@@ -58,7 +58,12 @@ import { fieldOf } from "./domain/record.ts";
58
58
  import type { Files } from "./ports/files.ts";
59
59
  import { CONFIG_FILE } from "#lib/paper-config";
60
60
  import { findLeaks, type Leak, type Searchable } from "./domain/anonymity.ts";
61
- import { bodyEnd, type BodyEnd } from "./domain/body-pages.ts";
61
+ import {
62
+ bodyEnd,
63
+ pageSplit,
64
+ type BodyEnd,
65
+ type PageSplit,
66
+ } from "./domain/body-pages.ts";
62
67
  import type { TextFacts } from "./facts-file.ts";
63
68
 
64
69
  // ── the verdict's vocabulary ─────────────────────────────────────────────────────────
@@ -261,9 +266,19 @@ export function judgeGeometry(
261
266
  return out;
262
267
  }
263
268
 
269
+ /**
270
+ * How many of banal's two counts — body, then references — a venue's counting replaces with its own
271
+ * from the text: none for banal's count, the body for `body_ends_at`, both with `appendix_in_body`.
272
+ */
273
+ function countedFromText(format: VenueFormat): number {
274
+ if (format.bodyEndsAt !== "references") return 0;
275
+ return format.appendixInBody === true ? 2 : 1;
276
+ }
277
+
264
278
  /**
265
279
  * 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.
280
+ * the body before the references is judged on its body by `judgeBodyEnd` instead, and one that
281
+ * counts the appendix as body on both.
267
282
  */
268
283
  export function judgePages(g: FlatGeometry, resolved: Resolved): Finding[] {
269
284
  const kind = resolved.kind;
@@ -272,7 +287,7 @@ export function judgePages(g: FlatGeometry, resolved: Resolved): Finding[] {
272
287
  [kind.limits.bodyPagesMax, g.body_pages, "body pages"],
273
288
  [kind.limits.refPagesMax, g.ref_pages, "reference pages"],
274
289
  ];
275
- const from = resolved.format.bodyEndsAt === "references" ? 1 : 0;
290
+ const from = countedFromText(resolved.format);
276
291
  return pages.slice(from).flatMap(([max, got, what]) =>
277
292
  max !== null && got > max
278
293
  ? [
@@ -300,13 +315,11 @@ export function judgeBodyEnd(
300
315
  resolved: Resolved,
301
316
  ): readonly Finding[] {
302
317
  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
- )
309
- return [];
318
+ if (resolved.format.bodyEndsAt !== "references" || kind === null) return [];
319
+ if (resolved.format.appendixInBody === true)
320
+ return judgeSplit(text, resolved, kind);
321
+ const max = kind.limits.bodyPagesMax;
322
+ if (max === null) return [];
310
323
  const end = endOfBody(text);
311
324
  const venue = resolved.venue;
312
325
  switch (end.kind) {
@@ -324,6 +337,54 @@ export function judgeBodyEnd(
324
337
  }
325
338
  }
326
339
 
340
+ /** The findings of a split that was found: the body, and the pages holding only references. */
341
+ function splitFindings(
342
+ split: Extract<PageSplit, { readonly kind: "found" }>,
343
+ limits: KindLimits,
344
+ at: { readonly venue: string; readonly kind: string },
345
+ ): readonly Finding[] {
346
+ const refs = `the references on page ${String(split.page)}`;
347
+ const body =
348
+ split.appendixFrom === null
349
+ ? `body pages (up to ${refs}, appendices included)`
350
+ : `body pages (up to ${refs}, and the appendix from page ${String(split.appendixFrom)})`;
351
+ const counts: readonly [number | null, number, string][] = [
352
+ [limits.bodyPagesMax, split.bodyPages, body],
353
+ [limits.refPagesMax, split.refPages, "pages holding only references"],
354
+ ];
355
+ return counts.flatMap(([max, got, what]) =>
356
+ max !== null && got > max
357
+ ? [finding("pages", { what, got, max, ...at })]
358
+ : [],
359
+ );
360
+ }
361
+
362
+ /**
363
+ * The body, appendices included, and the pages holding only references (`appendix_in_body`),
364
+ * against the kind's two limits. Like `judgeBodyEnd`, an end it cannot find is said, not passed.
365
+ */
366
+ function judgeSplit(
367
+ text: Pick<TextFacts, "pages" | "bibAnchorPage" | "appendixAnchorPage">,
368
+ resolved: Resolved,
369
+ kind: NonNullable<Resolved["kind"]>,
370
+ ): readonly Finding[] {
371
+ const limits = kind.limits;
372
+ if (limits.bodyPagesMax === null && limits.refPagesMax === null) return [];
373
+ const split = pageSplit(text.pages, {
374
+ bib: text.bibAnchorPage,
375
+ appendix: text.appendixAnchorPage,
376
+ });
377
+ const venue = resolved.venue;
378
+ switch (split.kind) {
379
+ case "missing":
380
+ return [finding("noReferences", { venue })];
381
+ case "disagree":
382
+ return [finding("unclear", { venue, why: disagreement(split) })];
383
+ case "found":
384
+ return splitFindings(split, limits, { venue, kind: kind.name });
385
+ }
386
+ }
387
+
327
388
  /** Where the body ends in what the PDF says, and where hyperref anchored the two ends. */
328
389
  const endOfBody = (
329
390
  t: Pick<TextFacts, "pages" | "bibAnchorPage" | "appendixAnchorPage">,