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
package/src/submission.ts CHANGED
@@ -30,7 +30,19 @@ import {
30
30
  } from "./domain/submission.ts";
31
31
  import type { Files } from "./ports/files.ts";
32
32
  import type { SubmissionPortal } from "./ports/submission-portal.ts";
33
- import { paperPreset, paperPresetProblem, type PresetDeps } from "./presets.ts";
33
+ import {
34
+ paperPreset,
35
+ unusablePresetLine,
36
+ type PaperPreset,
37
+ type PresetDeps,
38
+ } from "./presets.ts";
39
+ import type { PaperSettings } from "./paper-settings.ts";
40
+ import { currentDeadlines } from "./domain/cycle.ts";
41
+ import {
42
+ deadlinesInForce,
43
+ type InForce,
44
+ type Observation,
45
+ } from "./domain/deadline.ts";
34
46
  import { CONFIG_FILE } from "#lib/paper-config";
35
47
 
36
48
  /** The environment variable each portal kind's token is read from. */
@@ -80,22 +92,31 @@ export interface SubmissionTarget {
80
92
  readonly id: number;
81
93
  /** The paper's built PDF, absolute. */
82
94
  readonly pdf: string;
95
+ /** The venue's call for papers (the preset's `url`); null when the preset names no venue. */
96
+ readonly call: string | null;
97
+ /**
98
+ * The deadlines in force for the current attempt: the paper's own entries (with `cycles`) over the
99
+ * venue preset's readings. Instants and where each came from โ€” never how long is left.
100
+ */
101
+ readonly deadlines: readonly InForce[];
83
102
  }
84
103
 
85
104
  const configOf = (dir: string): string => join(dir, CONFIG_FILE);
86
105
 
87
- /** The paper directory โ†’ its portal and submission, or the line naming the file and key to set. */
88
- export function submissionTarget(
106
+ /** Why a paper without a venue preset has no portal: the key to set, or โ€” with cycles โ€” where. */
107
+ const noPresetLine = (
89
108
  paperDir: string,
90
- deps: PresetDeps,
109
+ settings: PaperSettings | null,
110
+ ): string =>
111
+ settings === null || settings.cycles === null
112
+ ? `${configOf(paperDir)}: no "extends" โ€” the venue preset it names declares the portal ("portal": { "kind": โ€ฆ, "url": โ€ฆ })`
113
+ : `${configOf(paperDir)}: the current cycle names no venue preset, and the preset is what declares the portal โ€” set the venue in the current cycle ("venue": { "kind": "preset", "extends": "paperlint:โ€ฆ" }), or upload by hand`;
114
+
115
+ /** A resolved paper โ†’ its portal and submission, or the line naming the file and key to set. */
116
+ function targetOf(
117
+ paperDir: string,
118
+ p: Extract<PaperPreset, { readonly kind: "resolved" }>,
91
119
  ): Result<SubmissionTarget, string> {
92
- const p = paperPreset(paperDir, deps);
93
- const problem = paperPresetProblem(paperDir, p);
94
- if (problem !== null) return err(problem);
95
- if (p.kind !== "resolved")
96
- return err(
97
- `${configOf(paperDir)}: no "extends" โ€” the venue preset it names declares the portal ("portal": { "kind": โ€ฆ, "url": โ€ฆ })`,
98
- );
99
120
  const declared = p.preset.portal;
100
121
  if (declared === null)
101
122
  return err(
@@ -109,7 +130,31 @@ export function submissionTarget(
109
130
  const portal = supportedPortal(declared);
110
131
  if (!portal.ok) return portal;
111
132
  const pdf = join(paperDir, p.settings.pdf ?? "paper.pdf");
112
- return ok({ portal: portal.value, id: sub.id, pdf });
133
+ const identity = p.preset.identity;
134
+ const call = identity.type === "venue" ? identity.url : null;
135
+ const own =
136
+ p.settings.cycles === null
137
+ ? []
138
+ : currentDeadlines(p.settings.cycles.current);
139
+ const deadlines = deadlinesInForce(own, p.preset.deadlines);
140
+ return ok({ portal: portal.value, id: sub.id, pdf, call, deadlines });
141
+ }
142
+
143
+ /** The paper directory โ†’ its portal and submission, or the line naming the file and key to set. */
144
+ export function submissionTarget(
145
+ paperDir: string,
146
+ deps: PresetDeps,
147
+ ): Result<SubmissionTarget, string> {
148
+ const p = paperPreset(paperDir, deps);
149
+ switch (p.kind) {
150
+ case "none":
151
+ return err(noPresetLine(paperDir, p.settings));
152
+ case "settings-problem":
153
+ case "preset-problem":
154
+ return err(unusablePresetLine(paperDir, p));
155
+ case "resolved":
156
+ return targetOf(paperDir, p);
157
+ }
113
158
  }
114
159
 
115
160
  /** The token from the environment, or the line naming the variable and where to get one. */
@@ -162,6 +207,25 @@ const pdfLine = (view: SubmissionView): string =>
162
207
  ? "portal PDF none"
163
208
  : `portal PDF ${view.pdf.hash}, ${view.pdf.size === null ? "?" : String(view.pdf.size)} bytes, uploaded ${iso(view.pdf.uploadedAt)}`;
164
209
 
210
+ const readingText = (o: Observation): string =>
211
+ `${o.source} ${o.at}, read ${o.read}`;
212
+
213
+ /** One deadline in force, what set it, and the other readings of it. Pure. */
214
+ export function deadlineLine(d: InForce): string {
215
+ const by = d.by;
216
+ const why =
217
+ by.kind === "override"
218
+ ? `override: ${by.override.reason} (${by.override.evidence})`
219
+ : `${by.reading.source}, read ${by.reading.read}`;
220
+ const others = d.readings.filter(
221
+ (r) => by.kind === "override" || r !== by.reading,
222
+ );
223
+ const also = others.length
224
+ ? `; also ${others.map(readingText).join("; ")}`
225
+ : "";
226
+ return `deadline ${d.what} ${d.at} โ€” ${why}${also}`;
227
+ }
228
+
165
229
  /** `show`'s report. Pure. */
166
230
  export function showLines(
167
231
  t: SubmissionTarget,
@@ -178,10 +242,19 @@ export function showLines(
178
242
  pdfLine(view),
179
243
  `local PDF ${local.shown} ${local.sha256 === null ? "(missing)" : `sha2-${local.sha256}`}`,
180
244
  matchLine(view, local.sha256),
245
+ ...t.deadlines.map(deadlineLine),
181
246
  ...view.messages.map(messageLine),
182
247
  ];
183
248
  }
184
249
 
250
+ /**
251
+ * What `update` says before it sends a PDF, dry run or not: the author opens that file and checks it
252
+ * against the venue's call. paperlint's checks are an aid; the venue's reading of the PDF is what
253
+ * counts. Pure.
254
+ */
255
+ export const checkPdfLine = (shown: string, call: string | null): string =>
256
+ `before you rely on it, open ${shown} yourself and check it against ${call === null ? "the venue's call for papers" : `the call for papers, ${call}`}: the page count and what counts toward the limit, the template and its class options, anonymity, and that every figure, table and reference renders โ€” paperlint passing is not the venue accepting the format`;
257
+
185
258
  /** `update`'s report. Pure. */
186
259
  export function updateLines(
187
260
  o: UpdateOutcome,
@@ -243,6 +316,17 @@ function abstractOf(
243
316
  : ok(new TextDecoder().decode(bytes).trim());
244
317
  }
245
318
 
319
+ /** What `update` sends: the PDF's bytes, the abstract's text, and whether to mark it submitted. */
320
+ const changeOf = (
321
+ a: SubmissionArgs & { readonly pdfPath: string },
322
+ bytes: Uint8Array,
323
+ abstract: string | null,
324
+ ) => ({
325
+ pdf: { name: basename(a.pdfPath), bytes },
326
+ abstract,
327
+ submit: a.submit,
328
+ });
329
+
246
330
  /** `update`: send the PDF (and the abstract, and "submitted" when asked); a dry run unless saving. */
247
331
  async function runUpdate(
248
332
  t: SubmissionTarget,
@@ -259,12 +343,10 @@ async function runUpdate(
259
343
  );
260
344
  const abstract = abstractOf(a, deps);
261
345
  if (!abstract.ok) return (deps.err(abstract.error), 2);
262
- const change = {
263
- pdf: { name: basename(a.pdfPath), bytes },
264
- abstract: abstract.value,
265
- submit: a.submit,
266
- };
267
- const r = await portal.update(t.id, change, { save: a.save });
346
+ deps.log(checkPdfLine(shown, t.call));
347
+ const r = await portal.update(t.id, changeOf(a, bytes, abstract.value), {
348
+ save: a.save,
349
+ });
268
350
  if (!r.ok) return (deps.err(failureText(r.error)), 1);
269
351
  const local = { shown, sha256: sha256Hex(bytes) };
270
352
  updateLines(r.value, local, a.save).forEach((l) => {
@@ -35,6 +35,7 @@ import {
35
35
  type VenueTalkJson,
36
36
  } from "./domain/talk.ts";
37
37
  import type { VenuePortal } from "./domain/submission.ts";
38
+ import type { Reading } from "./domain/deadline.ts";
38
39
 
39
40
  /** CTAN package name โ†’ the names that prove it is installed. */
40
41
  export type PackageProofs = Readonly<Record<string, readonly string[]>>;
@@ -140,6 +141,11 @@ export interface VenueFormat {
140
141
  readonly fontsTitle: string | null;
141
142
  /** How the body is counted: null is banal's count; `references`, the pages before the references. */
142
143
  readonly bodyEndsAt: "references" | null;
144
+ /**
145
+ * With `bodyEndsAt`: appendices count as body wherever they sit, and the reference pages are the
146
+ * ones holding only references. Null: the appendix ends the body, as `references` alone counts it.
147
+ */
148
+ readonly appendixInBody: boolean | null;
143
149
  readonly kinds: ReadonlyMap<string, KindLimits>;
144
150
  }
145
151
 
@@ -155,6 +161,7 @@ export const NO_FORMAT: VenueFormat = {
155
161
  fontsText: null,
156
162
  fontsTitle: null,
157
163
  bodyEndsAt: null,
164
+ appendixInBody: null,
158
165
  kinds: new Map(),
159
166
  };
160
167
 
@@ -189,10 +196,14 @@ export interface PresetFile {
189
196
  readonly extends: string | null;
190
197
  /** The `\\documentclass` the venue's template uses, as the file spells it; null when it names none. */
191
198
  readonly template: string | null;
199
+ /** Class options the venue forbids, read with `template`; empty when the file names none. */
200
+ readonly templateForbids: readonly string[];
192
201
  /** Other names the venue goes by in a paper's text; empty when the file names none. */
193
202
  readonly aliases: readonly string[];
194
203
  /** Whether the venue reviews double-blind; null when the file does not say. */
195
204
  readonly blind: boolean | null;
205
+ /** When the venue's names count as a mention in a paper's text; null when the file does not say. */
206
+ readonly mentions: "with-year" | null;
196
207
  /** Sections the venue requires; null when the file names none (a child's list replaces its parent's). */
197
208
  readonly requiredSections: readonly RequiredSection[] | null;
198
209
  /** Null when the file declares no `tex` block (allowed only with `extends`). */
@@ -209,6 +220,8 @@ export interface PresetFile {
209
220
  readonly talk: VenueTalk | null;
210
221
  /** Where the venue takes submissions (`paperlint submission`); null when the file does not say. */
211
222
  readonly portal: VenuePortal | null;
223
+ /** The edition's deadlines as they were read; null when the file names none (a child's list replaces its parent's). */
224
+ readonly deadlines: readonly Reading[] | null;
212
225
  }
213
226
 
214
227
  type KindsJson = Readonly<
@@ -227,6 +240,7 @@ interface FormatJson {
227
240
  readonly fonts_text?: string;
228
241
  readonly fonts_title?: string;
229
242
  readonly body_ends_at?: "references";
243
+ readonly appendix_in_body?: boolean;
230
244
  readonly kinds?: KindsJson;
231
245
  }
232
246
 
@@ -244,8 +258,10 @@ type PresetJson = PresetBodyJson &
244
258
  interface PresetBodyJson {
245
259
  readonly extends?: string;
246
260
  readonly template?: string;
261
+ readonly template_forbids?: readonly string[];
247
262
  readonly aliases?: readonly string[];
248
263
  readonly blind?: boolean;
264
+ readonly mentions?: "with-year";
249
265
  readonly required_sections?: readonly {
250
266
  readonly title: string;
251
267
  readonly position?: "last";
@@ -263,6 +279,7 @@ interface PresetBodyJson {
263
279
  };
264
280
  readonly talk?: VenueTalkJson;
265
281
  readonly portal?: VenuePortal;
282
+ readonly deadlines?: readonly Reading[];
266
283
  }
267
284
 
268
285
  /** An optional field as the typed preset holds it: absent is null. */
@@ -292,6 +309,7 @@ function formatOf(j: FormatJson = {}): VenueFormat {
292
309
  fontsText: orNull(j.fonts_text),
293
310
  fontsTitle: orNull(j.fonts_title),
294
311
  bodyEndsAt: orNull(j.body_ends_at),
312
+ appendixInBody: orNull(j.appendix_in_body),
295
313
  kinds: kindsOf(j.kinds),
296
314
  };
297
315
  }
@@ -376,6 +394,36 @@ const anchorsOf = (
376
394
  },
377
395
  }));
378
396
 
397
+ /** A preset's `portal`, or null when it names none. */
398
+ const portalOf = (p: PresetJson["portal"]): Readonly<VenuePortal> | null =>
399
+ p === undefined ? null : { kind: p.kind, url: p.url };
400
+
401
+ /**
402
+ * A preset's `deadlines`, or null when it names none. Two readings of one kind from one source are
403
+ * refused: the schema cannot say it, and a paper could not tell which binds.
404
+ */
405
+ function deadlinesOf(
406
+ d: PresetJson["deadlines"],
407
+ file: string,
408
+ ): readonly Reading[] | null {
409
+ if (d === undefined) return null;
410
+ const twice = d.find(
411
+ (r, i) =>
412
+ d.findIndex((x) => x.what === r.what && x.source === r.source) !== i,
413
+ );
414
+ if (twice !== undefined)
415
+ throw new Error(
416
+ `${file}: "deadlines" holds two "${twice.source}" readings of "${twice.what}" โ€” keep one, the latest`,
417
+ );
418
+ return d.map(({ what, at, source, url, read }) => ({
419
+ what,
420
+ at,
421
+ source,
422
+ url,
423
+ read,
424
+ }));
425
+ }
426
+
379
427
  /**
380
428
  * The text of one preset file โ†’ the typed file, or an Error naming every problem. The ONE parser
381
429
  * of a preset: the toolchain reads its `tex`, the venue rules its `format`, the config its `rules`.
@@ -400,18 +448,18 @@ export function parsePreset(
400
448
  identity: identityOf(j),
401
449
  extends: orNull(j.extends),
402
450
  template: orNull(j.template),
451
+ templateForbids: j.template_forbids ?? [],
403
452
  aliases: j.aliases ?? [],
404
453
  blind: orNull(j.blind),
454
+ mentions: orNull(j.mentions),
405
455
  requiredSections: sectionsOf(j.required_sections),
406
456
  tex: texOf(j.tex),
407
457
  format: formatOf(j.format),
408
458
  rules: j.rules ?? {},
409
459
  registerAnchors: anchorsOf(j.register),
410
460
  talk: j.talk === undefined ? null : venueTalkOf(j.talk),
411
- portal:
412
- j.portal === undefined
413
- ? null
414
- : { kind: j.portal.kind, url: j.portal.url },
461
+ portal: portalOf(j.portal),
462
+ deadlines: deadlinesOf(j.deadlines, file),
415
463
  };
416
464
  }
417
465
 
@@ -10,6 +10,7 @@ import { memoryFiles } from "./adapters/memory/index.ts";
10
10
  import { presetsDir } from "./package-dirs.ts";
11
11
  import { latexReader } from "./adapters/latex/index.ts";
12
12
  import {
13
+ cyclePortRules,
13
14
  judgeRequiredSections,
14
15
  judgeTemplate,
15
16
  otherVenues,
@@ -77,6 +78,9 @@ function lint(
77
78
  ...Object.entries(formatRules(deps)).map(
78
79
  ([n, r]) => [`format/${n}`, r] as const,
79
80
  ),
81
+ ...Object.entries(cyclePortRules(deps)).map(
82
+ ([n, r]) => [`cycle/${n}`, r] as const,
83
+ ),
80
84
  ]) {
81
85
  const visitor = rule.create({
82
86
  filename,
@@ -302,6 +306,78 @@ describe("tex/template โ€” the shipped ACM and ACL templates", () => {
302
306
  });
303
307
  });
304
308
 
309
+ const PORTING = {
310
+ id: "aidc-2026",
311
+ venue: { kind: "preset", extends: "paperlint:aidc" },
312
+ kind: "regular",
313
+ opened: "2026-09-26",
314
+ phase: "porting",
315
+ };
316
+
317
+ describe("tex/template โ€” a cycle in `porting`", () => {
318
+ it("the class line is the previous venue's by declaration: silent, where the flat form reports", () => {
319
+ const src = paper("\\documentclass[sigconf]{acmart}");
320
+ expect(lint(src, AIDC).map((f) => f.messageId)).toEqual(["wrongClass"]);
321
+ expect(lint(src, { cycles: [PORTING] })).toEqual([]);
322
+ expect(
323
+ lint(src, { cycles: [{ ...PORTING, phase: "prepared" }] }).map(
324
+ (f) => f.messageId,
325
+ ),
326
+ ).toEqual(["wrongClass"]);
327
+ });
328
+ });
329
+
330
+ describe("cycle/port-done โ€” the declared port is finished", () => {
331
+ it("porting declared and the class line already the venue's: the one finding, naming the cycle", () => {
332
+ const fs = lint(OFFICIAL, { cycles: [PORTING] });
333
+ expect(fs.map((f) => `${f.rule}:${f.messageId}`)).toEqual([
334
+ "cycle/port-done:done",
335
+ ]);
336
+ expect(fs[0]?.message).toMatch(
337
+ /already `\\documentclass\[conference,compsoc\]\{IEEEtran\}`, which AIDC requires, and the cycle ยซaidc-2026ยป still declares "phase": "porting"/,
338
+ );
339
+ });
340
+
341
+ it("silent while the class line is still the old venue's, once the phase is dropped, and on the flat form", () => {
342
+ const old = paper("\\documentclass[sigconf]{acmart}");
343
+ expect(lint(old, { cycles: [PORTING] })).toEqual([]);
344
+ expect(
345
+ lint(OFFICIAL, { cycles: [{ ...PORTING, phase: "prepared" }] }),
346
+ ).toEqual([]);
347
+ expect(lint(OFFICIAL, AIDC)).toEqual([]);
348
+ });
349
+
350
+ it("judges the text when the source carries no `raw`", () => {
351
+ expect(
352
+ lint(OFFICIAL, { cycles: [PORTING] }, { raw: false }).map(
353
+ (f) => `${f.rule}:${f.messageId}`,
354
+ ),
355
+ ).toContain("cycle/port-done:done");
356
+ });
357
+
358
+ it("silent when the venue names no template, and when the preset does not resolve", () => {
359
+ const own = `${PAPER}/own.jsonc`;
360
+ const porting = (ext: string) => ({
361
+ cycles: [{ ...PORTING, venue: { kind: "preset", extends: ext } }],
362
+ });
363
+ expect(
364
+ lint(OFFICIAL, porting("./own.jsonc"), {
365
+ extra: {
366
+ [own]: JSON.stringify({
367
+ type: "family",
368
+ tex: { packages: { x: ["x"] } },
369
+ }),
370
+ },
371
+ }).filter((f) => f.rule === "cycle/port-done"),
372
+ ).toEqual([]);
373
+ expect(
374
+ lint(OFFICIAL, porting("./missing.jsonc")).filter(
375
+ (f) => f.rule === "cycle/port-done",
376
+ ),
377
+ ).toEqual([]);
378
+ });
379
+ });
380
+
305
381
  describe("tex/template โ€” a project's own preset, and nothing to judge", () => {
306
382
  it("a preset with no template: nothing to compare, no finding", () => {
307
383
  const own = `${PAPER}/own.jsonc`;
@@ -322,6 +398,119 @@ describe("tex/template โ€” a project's own preset, and nothing to judge", () =>
322
398
  });
323
399
  });
324
400
 
401
+ /** A project's own venue on IEEEtran's conference class that forbids the two compsoc options. */
402
+ const forbidding = (body: Record<string, unknown> = {}) => ({
403
+ [`${PAPER}/own.jsonc`]: JSON.stringify(
404
+ venuePreset("Own", {
405
+ extends: "paperlint:ieee-conference",
406
+ template: "\\documentclass[10pt,conference]{IEEEtran}",
407
+ template_forbids: ["compsoc", "compsocconf"],
408
+ ...body,
409
+ }),
410
+ ),
411
+ });
412
+
413
+ describe("tex/template โ€” class options the preset forbids (`template_forbids`)", () => {
414
+ const judge = (cls: string, extra = forbidding()) =>
415
+ lint(paper(cls, "Text."), { extends: "./own.jsonc" }, { extra });
416
+
417
+ it("the template's own line passes, and so does an extra option the preset does not forbid", () => {
418
+ expect(judge("\\documentclass[10pt,conference]{IEEEtran}")).toEqual([]);
419
+ expect(
420
+ judge("\\documentclass[10pt,conference,letterpaper]{IEEEtran}"),
421
+ ).toEqual([]);
422
+ });
423
+
424
+ it("๐Ÿ”ด a forbidden option is reported at the class line, naming it and the venue's line", () => {
425
+ const fs = judge("\\documentclass[10pt,conference,compsoc]{IEEEtran}");
426
+ expect(ids(fs)).toEqual(["tex/template:forbiddenOption"]);
427
+ expect(fs[0]?.message).toBe(
428
+ "the class option `compsoc` is forbidden: Own requires `\\documentclass[10pt,conference]{IEEEtran}` without `compsoc`, `compsocconf`",
429
+ );
430
+ expect(fs[0]?.line).toBe(2);
431
+ });
432
+
433
+ it("each forbidden option is one finding, beside a missing one", () => {
434
+ expect(
435
+ ids(judge("\\documentclass[compsoc,compsocconf,conference]{IEEEtran}")),
436
+ ).toEqual([
437
+ "tex/template:forbiddenOption",
438
+ "tex/template:forbiddenOption",
439
+ "tex/template:missingOption",
440
+ ]);
441
+ });
442
+ });
443
+
444
+ describe("tex/template under paperlint:msr โ€” `[10pt,conference]`, without compsoc or compsocconf", () => {
445
+ const MSR = { extends: "paperlint:msr", kind: "technical" };
446
+
447
+ it("the class line MSR's call names passes", () => {
448
+ expect(
449
+ lint(paper("\\documentclass[10pt,conference]{IEEEtran}", "Text."), MSR),
450
+ ).toEqual([]);
451
+ });
452
+
453
+ it.each([["compsoc"], ["compsocconf"]])(
454
+ "๐Ÿ”ด `%s` in the class options fails tex/template",
455
+ (option) => {
456
+ const fs = lint(
457
+ paper(`\\documentclass[10pt,conference,${option}]{IEEEtran}`, "Text."),
458
+ MSR,
459
+ );
460
+ expect(ids(fs)).toEqual(["tex/template:forbiddenOption"]);
461
+ expect(fs[0]?.message).toMatch(
462
+ `the class option \`${option}\` is forbidden: MSR requires`,
463
+ );
464
+ },
465
+ );
466
+
467
+ it("AIDC's class line fails MSR on both counts: compsoc forbidden, 10pt missing", () => {
468
+ expect(ids(lint(OFFICIAL, MSR))).toEqual([
469
+ "tex/template:forbiddenOption",
470
+ "tex/template:missingOption",
471
+ ]);
472
+ });
473
+ });
474
+
475
+ describe("tex/template โ€” forbidden options behind a switch, and down a chain", () => {
476
+ it("behind a TeX switch, a candidate with a forbidden option is not the venue's class", () => {
477
+ const two = (a: string, b: string) =>
478
+ `\\def\\v{1}\n\\if\\v1 ${a} \\fi\n\\if\\v2 ${b} \\fi\n\\begin{document}\nText.\n\\end{document}\n`;
479
+ const compsoc = "\\documentclass[10pt,conference,compsoc]{IEEEtran}";
480
+ const opts = { extra: forbidding() };
481
+ expect(
482
+ ids(lint(two(compsoc, compsoc), { extends: "./own.jsonc" }, opts)),
483
+ ).toEqual(["tex/template:noCandidate"]);
484
+ expect(
485
+ lint(
486
+ two(compsoc, "\\documentclass[10pt,conference]{IEEEtran}"),
487
+ { extends: "./own.jsonc" },
488
+ opts,
489
+ ),
490
+ ).toEqual([]);
491
+ });
492
+
493
+ it("a child's template replaces its parent's, the forbidden options with it", () => {
494
+ const child = `${PAPER}/child.jsonc`;
495
+ const extra = {
496
+ ...forbidding(),
497
+ [child]: JSON.stringify(
498
+ venuePreset("Child", {
499
+ extends: "./own.jsonc",
500
+ template: "\\documentclass[conference]{IEEEtran}",
501
+ }),
502
+ ),
503
+ };
504
+ expect(
505
+ lint(
506
+ paper("\\documentclass[conference,compsoc]{IEEEtran}", "Text."),
507
+ { extends: "./child.jsonc" },
508
+ { extra },
509
+ ),
510
+ ).toEqual([]);
511
+ });
512
+ });
513
+
325
514
  describe("tex/template โ€” a template the reader cannot read, a bare class name, nothing to judge", () => {
326
515
  it("a template the reader cannot read is said once, naming its file โ€” the class is not judged", () => {
327
516
  const own = `${PAPER}/own.jsonc`;
@@ -580,6 +769,53 @@ describe("tex/venue-leftover โ€” another venue named in the text", () => {
580
769
  });
581
770
  });
582
771
 
772
+ describe("tex/venue-leftover โ€” MSR, a name that is also an acronym, counts only with a year (`mentions`)", () => {
773
+ const under = (text: string) =>
774
+ leftovers(lint(aidcPaper(`${text}\n${BODY}${STATEMENT}`), AIDC));
775
+
776
+ it.each([
777
+ [
778
+ "an acronym the paper defines",
779
+ "We score it by Manual Speech Recognition (MSR), and MSR scores rise.",
780
+ ],
781
+ [
782
+ "Microsoft Research",
783
+ "Researchers at Microsoft Research (MSR) built it, and MSR released it.",
784
+ ],
785
+ [
786
+ "a citation of an MSR paper: the key, and the entry in the bibliography",
787
+ "As shown~\\cite{msr2024}.\n\\begin{thebibliography}{1}\\bibitem{msr2024} A. Author, ``Mining,'' in \\emph{Proc. MSR 2024}, 2024.\\end{thebibliography}",
788
+ ],
789
+ ["a year that is not beside the name", "MSR, in 2027, scored best."],
790
+ ])("silent: %s", (_, text) => {
791
+ expect(under(text)).toEqual([]);
792
+ });
793
+
794
+ it.each([
795
+ ["First written for MSR 2027.", "MSR"],
796
+ ["A version appeared as MSR'27 work.", "MSR"],
797
+ ["Submitted to MSR~2027.", "MSR"],
798
+ ])("๐Ÿ”ด reported: ยซ%sยป", (text, name) => {
799
+ const fs = under(text);
800
+ expect(fs.map((f) => f.message)).toEqual([
801
+ expect.stringMatching(
802
+ new RegExp(`^ยซ${name}ยป names MSR, and this paper extends AIDC`),
803
+ ),
804
+ ]);
805
+ expect(fs[0]?.line).toBe(4);
806
+ });
807
+
808
+ it("under msr itself, its own name with a year is never a leftover", () => {
809
+ const tex = paper(
810
+ "\\documentclass[10pt,conference]{IEEEtran}",
811
+ "First written for MSR 2027.",
812
+ );
813
+ expect(
814
+ leftovers(lint(tex, { extends: "paperlint:msr", kind: "technical" })),
815
+ ).toEqual([]);
816
+ });
817
+ });
818
+
583
819
  describe("tex/venue-leftover โ€” a URL is not text, a definition body is", () => {
584
820
  it("\\href: a name in the URL is not reported, a name in the link text is", () => {
585
821
  const inUrl = aidcPaper(
@@ -736,6 +972,7 @@ describe("otherVenues", () => {
736
972
  "acm-sigconf",
737
973
  "AgenticDev",
738
974
  "AISec",
975
+ "MSR",
739
976
  ]);
740
977
  });
741
978
  });