paperlint 7.0.0 → 7.2.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 (152) hide show
  1. package/.github/workflows/ci.yml +3 -0
  2. package/.github/workflows/deadlines.yml +100 -0
  3. package/README.md +24 -11
  4. package/dist/adapters/hotcrp/deadlines.d.ts +8 -0
  5. package/dist/adapters/hotcrp/deadlines.d.ts.map +1 -0
  6. package/dist/adapters/hotcrp/deadlines.io.d.ts +6 -0
  7. package/dist/adapters/hotcrp/deadlines.io.d.ts.map +1 -0
  8. package/dist/adapters/hotcrp/deadlines.io.js +26 -0
  9. package/dist/adapters/hotcrp/deadlines.io.js.map +1 -0
  10. package/dist/adapters/hotcrp/deadlines.js +134 -0
  11. package/dist/adapters/hotcrp/deadlines.js.map +1 -0
  12. package/dist/adapters/hotcrp/index.d.ts +2 -0
  13. package/dist/adapters/hotcrp/index.d.ts.map +1 -1
  14. package/dist/adapters/hotcrp/index.js +2 -0
  15. package/dist/adapters/hotcrp/index.js.map +1 -1
  16. package/dist/cli.d.ts.map +1 -1
  17. package/dist/cli.js +42 -7
  18. package/dist/cli.js.map +1 -1
  19. package/dist/cycle-rules.d.ts +24 -0
  20. package/dist/cycle-rules.d.ts.map +1 -0
  21. package/dist/cycle-rules.js +212 -0
  22. package/dist/cycle-rules.js.map +1 -0
  23. package/dist/domain/body-pages.d.ts +29 -0
  24. package/dist/domain/body-pages.d.ts.map +1 -1
  25. package/dist/domain/body-pages.js +25 -0
  26. package/dist/domain/body-pages.js.map +1 -1
  27. package/dist/domain/cycle.d.ts +148 -0
  28. package/dist/domain/cycle.d.ts.map +1 -0
  29. package/dist/domain/cycle.js +281 -0
  30. package/dist/domain/cycle.js.map +1 -0
  31. package/dist/domain/deadline.d.ts +128 -0
  32. package/dist/domain/deadline.d.ts.map +1 -0
  33. package/dist/domain/deadline.js +281 -0
  34. package/dist/domain/deadline.js.map +1 -0
  35. package/dist/eslint-rules/cycle-stage.d.ts +39 -0
  36. package/dist/eslint-rules/cycle-stage.d.ts.map +1 -0
  37. package/dist/eslint-rules/cycle-stage.js +119 -0
  38. package/dist/eslint-rules/cycle-stage.js.map +1 -0
  39. package/dist/lib/paper-config.d.ts.map +1 -1
  40. package/dist/lib/paper-config.js +2 -0
  41. package/dist/lib/paper-config.js.map +1 -1
  42. package/dist/paper-includes.d.ts +3 -0
  43. package/dist/paper-includes.d.ts.map +1 -1
  44. package/dist/paper-includes.js +17 -2
  45. package/dist/paper-includes.js.map +1 -1
  46. package/dist/paper-settings.d.ts +7 -0
  47. package/dist/paper-settings.d.ts.map +1 -1
  48. package/dist/paper-settings.js +61 -8
  49. package/dist/paper-settings.js.map +1 -1
  50. package/dist/ports/venue-deadlines.d.ts +14 -0
  51. package/dist/ports/venue-deadlines.d.ts.map +1 -0
  52. package/dist/ports/venue-deadlines.js +2 -0
  53. package/dist/ports/venue-deadlines.js.map +1 -0
  54. package/dist/presets.d.ts +22 -3
  55. package/dist/presets.d.ts.map +1 -1
  56. package/dist/presets.js +26 -17
  57. package/dist/presets.js.map +1 -1
  58. package/dist/submission.d.ts +16 -0
  59. package/dist/submission.d.ts.map +1 -1
  60. package/dist/submission.js +59 -16
  61. package/dist/submission.js.map +1 -1
  62. package/dist/tex-requirements.d.ts +12 -0
  63. package/dist/tex-requirements.d.ts.map +1 -1
  64. package/dist/tex-requirements.js +26 -3
  65. package/dist/tex-requirements.js.map +1 -1
  66. package/dist/tex-venue-rules.d.ts +24 -2
  67. package/dist/tex-venue-rules.d.ts.map +1 -1
  68. package/dist/tex-venue-rules.js +98 -10
  69. package/dist/tex-venue-rules.js.map +1 -1
  70. package/dist/venue-rules.d.ts +12 -1
  71. package/dist/venue-rules.d.ts.map +1 -1
  72. package/dist/venue-rules.js +81 -17
  73. package/dist/venue-rules.js.map +1 -1
  74. package/docs/configuration.md +140 -12
  75. package/docs/e2e.md +14 -12
  76. package/docs/rules/cycle/evidence.md +87 -0
  77. package/docs/rules/cycle/port-done.md +75 -0
  78. package/docs/rules/cycle/record.md +152 -0
  79. package/docs/rules/cycle/stage.md +85 -0
  80. package/docs/rules/format/page-limit.md +18 -1
  81. package/docs/rules/tex/template.md +25 -5
  82. package/docs/rules/tex/venue-leftover.md +13 -0
  83. package/docs/rules.md +68 -55
  84. package/docs/submission.md +10 -1
  85. package/eslint-rules/cycle-stage.test.mjs +131 -0
  86. package/eslint-rules/cycle-stage.ts +141 -0
  87. package/eslint.config.mjs +41 -1
  88. package/fixtures/build-e2e/msr/PIPELINE-STATUS.md +3 -0
  89. package/fixtures/build-e2e/msr/expect.json +18 -0
  90. package/fixtures/build-e2e/msr/paper.tex +49 -0
  91. package/fixtures/build-e2e/msr/paperlint.json +1 -0
  92. package/fixtures/build-e2e/msr-appendix-after/PIPELINE-STATUS.md +3 -0
  93. package/fixtures/build-e2e/msr-appendix-after/expect.json +20 -0
  94. package/fixtures/build-e2e/msr-appendix-after/paper.tex +48 -0
  95. package/fixtures/build-e2e/msr-appendix-after/paperlint.json +1 -0
  96. package/lib/paper-config.ts +3 -0
  97. package/package.json +1 -1
  98. package/presets/aidc.jsonc +24 -3
  99. package/presets/aidc.md +6 -3
  100. package/presets/ieee-conference.md +1 -1
  101. package/presets/msr.jsonc +95 -0
  102. package/presets/msr.md +98 -0
  103. package/presets/venue-profile.schema.json +64 -0
  104. package/scripts/check.ts +4 -0
  105. package/scripts/refresh-deadlines.test.ts +431 -0
  106. package/scripts/refresh-deadlines.ts +387 -0
  107. package/skills/camera-ready/SKILL.md +8 -1
  108. package/skills/camera-ready/SKILL.md.spec.ts +7 -0
  109. package/skills/submission-portal/SKILL.md +2 -2
  110. package/skills/submission-portal/SKILL.md.spec.ts +1 -1
  111. package/skills/submit-paper/SKILL.md +9 -1
  112. package/skills/submit-paper/SKILL.md.spec.ts +8 -0
  113. package/src/adapters/hotcrp/cassette/README.md +22 -0
  114. package/src/adapters/hotcrp/cassette/deadlines-aidc26.html +17 -0
  115. package/src/adapters/hotcrp/cassette/deadlines-fse2027.html +24 -0
  116. package/src/adapters/hotcrp/cassette/deadlines-icse2027.html +25 -0
  117. package/src/adapters/hotcrp/cassette/deadlines-msr2027.html +25 -0
  118. package/src/adapters/hotcrp/cassette/deadlines-nosuch.html +18 -0
  119. package/src/adapters/hotcrp/cassette/deadlines-sosp26.html +26 -0
  120. package/src/adapters/hotcrp/deadlines.io.test.ts +86 -0
  121. package/src/adapters/hotcrp/deadlines.io.ts +27 -0
  122. package/src/adapters/hotcrp/deadlines.test.ts +170 -0
  123. package/src/adapters/hotcrp/deadlines.ts +161 -0
  124. package/src/adapters/hotcrp/index.ts +2 -0
  125. package/src/cli.test.ts +9 -0
  126. package/src/cli.ts +50 -6
  127. package/src/cycle-rules.test.ts +278 -0
  128. package/src/cycle-rules.ts +273 -0
  129. package/src/domain/body-pages.test.ts +144 -0
  130. package/src/domain/body-pages.ts +61 -1
  131. package/src/domain/cycle.test.ts +456 -0
  132. package/src/domain/cycle.ts +432 -0
  133. package/src/domain/deadline.test.ts +386 -0
  134. package/src/domain/deadline.ts +472 -0
  135. package/src/new-paper.test.ts +28 -0
  136. package/src/paper-includes.test.ts +26 -0
  137. package/src/paper-includes.ts +20 -2
  138. package/src/paper-settings-commands.test.ts +1 -1
  139. package/src/paper-settings.test.ts +167 -0
  140. package/src/paper-settings.ts +85 -11
  141. package/src/ports/venue-deadlines.ts +14 -0
  142. package/src/preset-card-rule.test.ts +1 -0
  143. package/src/presets.test.ts +171 -1
  144. package/src/presets.ts +65 -15
  145. package/src/rule-cases.test.ts +144 -0
  146. package/src/submission.test.ts +147 -4
  147. package/src/submission.ts +100 -18
  148. package/src/tex-requirements.ts +52 -4
  149. package/src/tex-venue-rules.test.ts +237 -0
  150. package/src/tex-venue-rules.ts +130 -15
  151. package/src/venue-rules.test.ts +255 -2
  152. package/src/venue-rules.ts +115 -22
@@ -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`
@@ -51,6 +52,7 @@ import type { PaperSource } from "./domain/paper-source.ts";
51
52
  import { readPaper, reportInPaper } from "./tex-paper.ts";
52
53
  import type { RequiredSection } from "./tex-requirements.ts";
53
54
  import type { Finding, VenueRuleDeps } from "./venue-rules.ts";
55
+ import { isPorting } from "./domain/cycle.ts";
54
56
 
55
57
  /** A finding and where it points; null points at the top of the file. */
56
58
  export interface Located extends Finding {
@@ -107,20 +109,38 @@ type TemplateData = {
107
109
  readonly template: string;
108
110
  };
109
111
 
110
- /** A class line that names a class, against the template's: the same class, with every option. */
112
+ /** What the template requires of a class line: its class and options, and the options it forbids. */
113
+ interface Wanted {
114
+ readonly cls: DocumentClass;
115
+ readonly forbids: readonly string[];
116
+ }
117
+
118
+ /**
119
+ * A class line that names a class, against the template's: the same class, with every option, and
120
+ * none of the options the preset forbids.
121
+ */
111
122
  function judgeClass(
112
123
  line: Extract<ClassLine, { readonly kind: "class" }>,
113
- want: DocumentClass,
124
+ want: Wanted,
114
125
  data: TemplateData,
115
126
  ): readonly Located[] {
116
127
  const at = spanOf(line.place);
117
- if (line.cls !== want.cls)
128
+ if (line.cls !== want.cls.cls)
118
129
  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
- }));
130
+ const forbidden = want.forbids.filter((o) => line.options.includes(o));
131
+ const without = want.forbids.map((o) => `\`${o}\``).join(", ");
132
+ return [
133
+ ...forbidden.map((option) => ({
134
+ messageId: "forbiddenOption",
135
+ data: { ...data, option, without },
136
+ at,
137
+ })),
138
+ ...missingOptions(line, want.cls).map((option) => ({
139
+ messageId: "missingOption",
140
+ data: { ...data, option },
141
+ at,
142
+ })),
143
+ ];
124
144
  }
125
145
 
126
146
  /** A candidate as the message names it: its line, or an empty `\documentclass{}`. */
@@ -133,7 +153,7 @@ const candidateLine = (c: ClassCandidate): string =>
133
153
  */
134
154
  function judgeCandidates(
135
155
  candidates: readonly [ClassCandidate, ...ClassCandidate[]],
136
- want: DocumentClass,
156
+ want: Wanted,
137
157
  data: TemplateData,
138
158
  ): readonly Located[] {
139
159
  const matches = candidates.some(
@@ -150,7 +170,7 @@ function judgeCandidates(
150
170
  /** The paper's class line against a template that was read: present, naming a class, that class. */
151
171
  function judgeClassLine(
152
172
  line: ClassLine,
153
- want: DocumentClass,
173
+ want: Wanted,
154
174
  data: TemplateData,
155
175
  ): readonly Located[] {
156
176
  switch (line.kind) {
@@ -183,7 +203,7 @@ export function judgeTemplate(
183
203
  return [{ messageId: "badTemplate", data, at: null }];
184
204
  }
185
205
  const data = { venue: preset.label, template: documentClassLine(want) };
186
- return judgeClassLine(line, want, data);
206
+ return judgeClassLine(line, { cls: want, forbids: t.forbids }, data);
187
207
  }
188
208
 
189
209
  /** Where a heading starts, or null when the parser gave it no place. */
@@ -248,8 +268,17 @@ export function judgeRequiredSections(
248
268
  );
249
269
  }
250
270
 
251
- /** Another shipped venue: the word for it, and what it is called in a paper's text. */
252
- export type OtherVenue = NamedVenue;
271
+ /**
272
+ * Another shipped venue: the word for it, what it is called in a paper's text, and when a name
273
+ * counts as naming it (`mentions`).
274
+ */
275
+ export type OtherVenue = NamedVenue & Pick<Preset, "mentions">;
276
+
277
+ /**
278
+ * What must follow a name for it to name a `with-year` venue: a year beside it — `2027`, `'27` or
279
+ * `’27`, after spaces or none (`~` renders as a space). A lookahead, so the finding is the name.
280
+ */
281
+ const YEAR_BESIDE = "(?=[\\s\\u00a0]*(?:\\d{4}|['’]\\d{2})(?![\\p{L}\\p{N}]))";
253
282
 
254
283
  /** `s` as a pattern that matches it literally. */
255
284
  const literal = (s: string): string =>
@@ -271,7 +300,7 @@ export function judgeLeftover(
271
300
  .map((alias) => ({
272
301
  other: o.label,
273
302
  re: new RegExp(
274
- `(?<![\\p{L}\\p{N}])${literal(alias)}(?![\\p{L}\\p{N}])`,
303
+ `(?<![\\p{L}\\p{N}])${literal(alias)}(?![\\p{L}\\p{N}])${o.mentions === "with-year" ? YEAR_BESIDE : ""}`,
275
304
  "gu",
276
305
  ),
277
306
  })),
@@ -357,6 +386,8 @@ const RULES: Readonly<
357
386
  "the class is `{{got}}`, and {{venue}} requires `{{template}}` — the page size, fonts and layout the venue checks come from the class",
358
387
  missingOption:
359
388
  "the class option `{{option}}` is missing: {{venue}} requires `{{template}}`",
389
+ forbiddenOption:
390
+ "the class option `{{option}}` is forbidden: {{venue}} requires `{{template}}` without {{without}}",
360
391
  noCandidate:
361
392
  "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
393
  },
@@ -442,7 +473,7 @@ export function otherVenues(
442
473
  ): readonly OtherVenue[] {
443
474
  return shippedVenueNames(deps)
444
475
  .filter((v) => !v.chain.every((f) => preset.chain.includes(f)))
445
- .map(({ label, aliases }) => ({ label, aliases }));
476
+ .map(({ label, aliases, mentions }) => ({ label, aliases, mentions }));
446
477
  }
447
478
 
448
479
  /** What the venue-conformance rules are built with: the preset store, and the LaTeX reader. */
@@ -463,6 +494,10 @@ function readingOf(
463
494
  const dir = dirname(filename);
464
495
  const p = paperPreset(dir, deps);
465
496
  if (p.kind !== "resolved") return null;
497
+ // The port to this venue's template is declared open work: the class line is the previous
498
+ // venue's by declaration, and `cycle/port-done` watches that declaration.
499
+ if (p.settings.cycles !== null && isPorting(p.settings.cycles.current))
500
+ return null;
466
501
  const preset = p.preset;
467
502
  const others = () => otherVenues(preset, deps);
468
503
  const paper = readPaper(filename, src, deps);
@@ -508,3 +543,83 @@ export function formatRules(
508
543
  ): Readonly<Record<"layout-override", TexRuleModule>> {
509
544
  return { "layout-override": rule("layout-override", deps) };
510
545
  }
546
+
547
+ // ── cycle/port-done: the declared port is finished ───────────────────────────────────────
548
+
549
+ /**
550
+ * `cycle/port-done` (error): the current cycle declares `"phase": "porting"`, and the paper's
551
+ * `\documentclass` is already the venue's template. The declaration silences every venue rule
552
+ * (`src/venue-rules.ts`, `readingOf` above); once the class line is the venue's, the port is done
553
+ * and the declaration is stale — this is the other half that keeps `porting` from being a mute
554
+ * button. Silent when the preset names no template: there is nothing to compare, as for `tex/template`.
555
+ */
556
+ export function judgePortDone(
557
+ r: Pick<Reading, "src" | "latex" | "preset">,
558
+ cycleId: string,
559
+ ): readonly Located[] {
560
+ if (r.preset.template === null) return [];
561
+ const mismatch = judgeTemplate(
562
+ r.latex.documentClass(r.src),
563
+ r.preset,
564
+ r.latex,
565
+ );
566
+ return mismatch.length > 0
567
+ ? []
568
+ : [
569
+ {
570
+ messageId: "done",
571
+ data: {
572
+ venue: r.preset.label,
573
+ template: r.preset.template.text,
574
+ id: cycleId,
575
+ },
576
+ at: null,
577
+ },
578
+ ];
579
+ }
580
+
581
+ const PORT_DONE_META: TexRuleModule["meta"] = {
582
+ type: "problem",
583
+ docs: {
584
+ description:
585
+ "the current cycle still says `porting` while the \\documentclass is already the venue's template — the port is done, drop the phase",
586
+ url: "https://github.com/zernie/paperlint/blob/main/docs/rules/cycle/port-done.md",
587
+ },
588
+ schema: [],
589
+ messages: {
590
+ done: 'the \\documentclass is already `{{template}}`, which {{venue}} requires, and the cycle «{{id}}» still declares "phase": "porting" — the port is done: remove the phase, so the venue\'s format rules judge this paper again',
591
+ },
592
+ };
593
+
594
+ /** The `cycle` plugin's rule over the source, for every `paper.tex`. */
595
+ export function cyclePortRules(
596
+ deps: TexVenueRuleDeps,
597
+ ): Readonly<Record<"port-done", TexRuleModule>> {
598
+ return {
599
+ "port-done": {
600
+ meta: PORT_DONE_META,
601
+ create(context) {
602
+ if (basename(context.filename) !== "paper.tex") return {};
603
+ return {
604
+ root() {
605
+ const p = paperPreset(dirname(context.filename), deps);
606
+ if (p.kind !== "resolved") return;
607
+ const current = p.settings.cycles?.current;
608
+ if (current?.kind !== "cycle" || !isPorting(current)) return;
609
+ const sc = context.sourceCode;
610
+ const paper = readPaper(context.filename, sc.raw ?? sc.text, deps);
611
+ const found = judgePortDone(
612
+ { src: paper.text, latex: deps.latex, preset: p.preset },
613
+ current.cycle.id,
614
+ );
615
+ reportInPaper(context, PORT_DONE_META.messages, paper, found);
616
+ },
617
+ };
618
+ },
619
+ },
620
+ };
621
+ }
622
+
623
+ export const CYCLE_PORT_RULE_LEVELS: Readonly<
624
+ Record<"cycle/port-done", "error">
625
+ > = { "cycle/port-done": "error" };
@@ -231,6 +231,36 @@ describe("a paper that names no venue", () => {
231
231
  lint({ venue: DECL, facts: null }, `${PAPER}/PIPELINE-STATUS.md`),
232
232
  ).toEqual([]);
233
233
  });
234
+
235
+ it.each([
236
+ [
237
+ "a named venue",
238
+ {
239
+ id: "secdev-2027",
240
+ venue: { kind: "named", name: "SecDev 2027", url: "https://x.example" },
241
+ opened: "2027-01-10",
242
+ },
243
+ ],
244
+ [
245
+ "the last cycle closed, none open",
246
+ {
247
+ id: "first-2026",
248
+ venue: { kind: "preset", extends: "paperlint:aisec" },
249
+ opened: "2026-07-01",
250
+ outcome: { kind: "withdrawn", date: "2026-07-18", evidence: "n.md" },
251
+ },
252
+ ],
253
+ ])(
254
+ "with `cycles` and %s: the warning says to set the venue IN THE CYCLE — `extends` beside `cycles` is refused",
255
+ (_, cycle) => {
256
+ const fs = lint({ venue: { cycles: [cycle] }, facts: null, pdf: null });
257
+ expect(ids(fs)).toEqual(["pdf/measured:noPresetCycle"]);
258
+ expect(fs[0]?.message).toMatch(
259
+ /^this paper's current cycle names no venue preset, so its page limit, fonts and format are not checked — set the venue in the current cycle in .*paperlint\.json \("venue": \{ "kind": "preset", "extends": "paperlint:…" \}\), or open a new cycle when the last one has ended$/,
260
+ );
261
+ expect(fs[0]?.message).not.toMatch(/set "extends"/);
262
+ },
263
+ );
234
264
  });
235
265
 
236
266
  describe("pdf/profile — the declaration must resolve, or nothing is judged", () => {
@@ -239,13 +269,13 @@ describe("pdf/profile — the declaration must resolve, or nothing is judged", (
239
269
  "an unknown venue (a typo would silently disable every check)",
240
270
  { extends: "paperlint:agentic-dev", kind: "short" },
241
271
  "preset",
242
- /agenticdev, aidc, aisec, ieee-conference, realm/,
272
+ /agenticdev, aidc, aisec, ieee-conference, msr, realm/,
243
273
  ],
244
274
  [
245
275
  "the base TeX set is not a venue",
246
276
  { extends: "paperlint:tex-base" },
247
277
  "preset",
248
- /agenticdev, aidc, aisec, ieee-conference, realm/,
278
+ /agenticdev, aidc, aisec, ieee-conference, msr, realm/,
249
279
  ],
250
280
  [
251
281
  "paperlint.json that is not JSON",
@@ -863,6 +893,177 @@ describe("format/page-limit on AIDC: the two signals of where the references sta
863
893
  });
864
894
  });
865
895
 
896
+ /**
897
+ * A project's own venue that counts its appendices in the body (`appendix_in_body`): a body of ten
898
+ * pages, appendices included, and two pages holding only references — MSR's rule.
899
+ */
900
+ const OWN_APPENDIX_IN_BODY = {
901
+ [`${PAPER}/own.jsonc`]: JSON.stringify({
902
+ type: "venue",
903
+ name: "Own",
904
+ url: "https://example.org/cfp",
905
+ extends: "paperlint:ieee-conference",
906
+ format: {
907
+ body_ends_at: "references",
908
+ appendix_in_body: true,
909
+ kinds: {
910
+ technical: { body_pages_max: 10, ref_pages_max: 2 },
911
+ open: {},
912
+ refs: { ref_pages_max: 2 },
913
+ },
914
+ },
915
+ }),
916
+ };
917
+
918
+ /** `own.jsonc` papers of the kind named, over IEEE facts with `pages` and the two anchors. */
919
+ const ownSplit = (
920
+ pages: readonly string[],
921
+ bib: number | null,
922
+ appendix: number | null,
923
+ kind = "technical",
924
+ ) =>
925
+ lint({
926
+ venue: { extends: "./own.jsonc", kind },
927
+ extra: OWN_APPENDIX_IN_BODY,
928
+ facts: ieeeFacts((f) => {
929
+ f.pages_text = [...pages];
930
+ f.bib_anchor_page = bib;
931
+ f.appendix_anchor_page = appendix;
932
+ // banal's counts, which this counting replaces: neither is judged.
933
+ f.body_pages = 99;
934
+ f.ref_pages = 99;
935
+ }),
936
+ });
937
+
938
+ describe("format/page-limit with `appendix_in_body`: the appendix is body, the reference pages hold only references", () => {
939
+ const REFS = ["the end\nReferences\n[1] A.\n", "[2] B.\n", "[3] C.\n"];
940
+
941
+ it("ten pages of body and two of references pass; banal's counts are not judged", () => {
942
+ expect(ownSplit([...bodyPages(9), ...REFS], 10, null)).toEqual([]);
943
+ });
944
+
945
+ it("🔴 an appendix after the references that brings the body to eleven fails, naming where it starts", () => {
946
+ const fs = ownSplit(
947
+ [...bodyPages(9), ...REFS.slice(0, 2), "Appendix A.\nDetails\n"],
948
+ 10,
949
+ 12,
950
+ );
951
+ expect(fs.map((f) => f.message)).toEqual([
952
+ "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",
953
+ ]);
954
+ });
955
+
956
+ it("🔴 three pages holding only references fail", () => {
957
+ const fs = ownSplit([...bodyPages(9), ...REFS, "[4] D.\n"], 10, null);
958
+ expect(fs.map((f) => f.message)).toEqual([
959
+ "pages holding only references: 3, over the limit 2 for Own/technical — a desk reject; cut the text",
960
+ ]);
961
+ });
962
+
963
+ it("an eleventh page of body before the references fails, appendices included", () => {
964
+ expect(ids(ownSplit([...bodyPages(10), ...REFS], 11, null))).toEqual([
965
+ "format/page-limit:pages",
966
+ ]);
967
+ });
968
+
969
+ it.each<
970
+ [string, readonly string[], readonly [number | null, number | null], string]
971
+ >([
972
+ [
973
+ "no references heading",
974
+ bodyPages(3),
975
+ [null, null],
976
+ "format/page-limit:noReferences",
977
+ ],
978
+ [
979
+ "an appendix anchor on a page with no «Appendix» line",
980
+ [...bodyPages(3), ...REFS],
981
+ [4, 6],
982
+ "format/page-limit:unclear",
983
+ ],
984
+ ])("%s: said, not counted", (_, pages, [bib, appendix], want) => {
985
+ expect(ids(ownSplit(pages, bib, appendix))).toEqual([want]);
986
+ });
987
+
988
+ it("a kind with no limits judges nothing; one with a reference limit only judges that", () => {
989
+ expect(ownSplit(bodyPages(3), null, null, "open")).toEqual([]);
990
+ expect(
991
+ ids(ownSplit([...bodyPages(12), ...REFS, "[4] D.\n"], 13, null, "refs")),
992
+ ).toEqual(["format/page-limit:pages"]);
993
+ });
994
+ });
995
+
996
+ /** An MSR technical paper that declares who wrote it. */
997
+ const MSR = {
998
+ extends: "paperlint:msr",
999
+ kind: "technical",
1000
+ identity: ["Ada Example"],
1001
+ };
1002
+
1003
+ /** MSR facts: `pages` as the PDF's text, the two anchors where hyperref put them. */
1004
+ const msrFacts = (
1005
+ pages: readonly string[],
1006
+ bib: number,
1007
+ appendix: number | null = null,
1008
+ ) =>
1009
+ ieeeFacts((f) => {
1010
+ f.pages_text = [...pages];
1011
+ f.bib_anchor_page = bib;
1012
+ f.appendix_anchor_page = appendix;
1013
+ });
1014
+
1015
+ /** References that start below the last body text on their page, then `more` pages of them. */
1016
+ const refsFrom = (more: number): string[] => [
1017
+ "the last lines of the conclusion\nR EFERENCES\n[1] D. E. Knuth.\n",
1018
+ ...Array.from({ length: more }, (_, i) => `[${String(i + 2)}] More.\n`),
1019
+ ];
1020
+
1021
+ describe("format/page-limit on MSR: ten pages of main text, appendices included, and two of only references", () => {
1022
+ it("🔴 ten pages of body, the references starting on the tenth, and two pages of only references pass", () => {
1023
+ expect(
1024
+ lint({
1025
+ venue: MSR,
1026
+ facts: msrFacts([...bodyPages(9), ...refsFrom(2)], 10),
1027
+ }),
1028
+ ).toEqual([]);
1029
+ });
1030
+
1031
+ it("🔴 eleven pages of body fail", () => {
1032
+ const fs = lint({
1033
+ venue: MSR,
1034
+ facts: msrFacts([...bodyPages(10), ...refsFrom(2)], 11),
1035
+ });
1036
+ expect(fs.map((f) => f.message)).toEqual([
1037
+ "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",
1038
+ ]);
1039
+ });
1040
+
1041
+ it("🔴 an appendix before the references that brings the body to eleven fails", () => {
1042
+ const pages = [...bodyPages(9), "Appendix A.\nDetails\n", ...refsFrom(2)];
1043
+ expect(ids(lint({ venue: MSR, facts: msrFacts(pages, 11, 10) }))).toEqual([
1044
+ "format/page-limit:pages",
1045
+ ]);
1046
+ });
1047
+
1048
+ it("🔴 an appendix after the references that brings the body to eleven fails", () => {
1049
+ const pages = [...bodyPages(9), ...refsFrom(1), "Appendix A.\nDetails\n"];
1050
+ const fs = lint({ venue: MSR, facts: msrFacts(pages, 10, 12) });
1051
+ expect(fs[0]?.message).toMatch(
1052
+ /^body pages \(up to the references on page 10, and the appendix from page 12\): 11, over the limit 10/,
1053
+ );
1054
+ });
1055
+
1056
+ it("🔴 three pages of only references fail", () => {
1057
+ const fs = lint({
1058
+ venue: MSR,
1059
+ facts: msrFacts([...bodyPages(9), ...refsFrom(3)], 10),
1060
+ });
1061
+ expect(fs.map((f) => f.message)).toEqual([
1062
+ "pages holding only references: 3, over the limit 2 for MSR/technical — a desk reject; cut the text",
1063
+ ]);
1064
+ });
1065
+ });
1066
+
866
1067
  /** An AIDC paper that declares who wrote it. */
867
1068
  const BLIND = {
868
1069
  extends: "paperlint:aidc",
@@ -1022,3 +1223,55 @@ describe("judgeBodyEnd, on a venue it does not apply to", () => {
1022
1223
  expect(judgeBodyEnd(NO_TEXT, venue(null))).toEqual([]);
1023
1224
  });
1024
1225
  });
1226
+
1227
+ /** A paper on AIDC through `cycles`: one attempt, open unless `outcome` says otherwise. */
1228
+ const onCycle = (cycle: Record<string, unknown>) => ({
1229
+ identity: ["Ada Example"],
1230
+ cycles: [
1231
+ {
1232
+ id: "aidc-2026",
1233
+ venue: { kind: "preset", extends: "paperlint:aidc" },
1234
+ kind: "regular",
1235
+ opened: "2026-09-26",
1236
+ ...cycle,
1237
+ },
1238
+ ],
1239
+ });
1240
+
1241
+ describe("the cycle decides what applies", () => {
1242
+ it("an open cycle reads as the flat form: the same findings on the same facts", () => {
1243
+ const facts = said((f) => (f.columns = 1));
1244
+ expect(ids(lint({ venue: onCycle({}), facts }))).toEqual(
1245
+ ids(lint({ venue: BLIND, facts })),
1246
+ );
1247
+ expect(ids(lint({ venue: onCycle({}), facts }))).toContain(
1248
+ "pdf/geometry:columns",
1249
+ );
1250
+ });
1251
+
1252
+ it("`phase: porting` — the source is in the previous venue's template by declaration: every venue rule is silent", () => {
1253
+ const facts = said(
1254
+ (f) => ((f.columns = 1), (f.pages_text = ["Ada Example\n"])),
1255
+ );
1256
+ expect(lint({ venue: onCycle({ phase: "porting" }), facts })).toEqual([]);
1257
+ expect(lint({ venue: onCycle({ phase: "porting" }), facts: null })).toEqual(
1258
+ [],
1259
+ );
1260
+ });
1261
+
1262
+ it("an ACCEPTED cycle at a blind venue: the camera-ready carries the authors, anonymity/identity is silent", () => {
1263
+ const facts = said(
1264
+ (f) =>
1265
+ (f.pages_text = ["Ada Example\n", ...bodyPages(11), "References\n"]),
1266
+ );
1267
+ const accepted = {
1268
+ kind: "accepted",
1269
+ date: "2026-10-16",
1270
+ evidence: "reviews/decision.md",
1271
+ };
1272
+ expect(ids(lint({ venue: onCycle({}), facts }))).toContain(
1273
+ "anonymity/identity:leak",
1274
+ );
1275
+ expect(lint({ venue: onCycle({ outcome: accepted }), facts })).toEqual([]);
1276
+ });
1277
+ });