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/presets.ts CHANGED
@@ -25,8 +25,8 @@
25
25
  * tex union — a child never removes a package its parent needs
26
26
  * format per key, the child wins; `kinds` by kind name, a child's kind replaces that kind
27
27
  * rules per rule id, the child wins; `ruleOrigins` keeps which file set each
28
- * template the child wins; so does `name`, and `required_sections`, `register`, `talk` and
29
- * `portal` (each whole)
28
+ * template the child wins, with its `template_forbids`; so does `name`, and `required_sections`, `register`, `talk`,
29
+ * `portal` and `deadlines` (each whole)
30
30
  * aliases union, with every `name` — what the venue is called along the chain
31
31
  *
32
32
  * ── THE ID AND THE LABEL ──────────────────────────────────────────────────────────
@@ -63,6 +63,7 @@ import { messageOf } from "./domain/text.ts";
63
63
  import type { RegisterAnchor } from "./domain/register.ts";
64
64
  import type { VenueTalk } from "./domain/talk.ts";
65
65
  import type { VenuePortal } from "./domain/submission.ts";
66
+ import type { Reading } from "./domain/deadline.ts";
66
67
 
67
68
  /** The prefix of a shipped preset's spec. */
68
69
  export const SHIPPED_PREFIX = "paperlint:";
@@ -75,10 +76,12 @@ export interface PresetDeps {
75
76
  readonly venuesDir: string;
76
77
  }
77
78
 
78
- /** A preset's `template`, as its file spells it. */
79
+ /** A preset's `template`, as its file spells it, and the class options that file forbids. */
79
80
  export interface PresetTemplate {
80
81
  readonly text: string;
81
82
  readonly file: string;
83
+ /** `template_forbids` of the same file: options the paper's class line must not carry. */
84
+ readonly forbids: readonly string[];
82
85
  }
83
86
 
84
87
  /** A resolved chain, merged. */
@@ -104,6 +107,11 @@ export interface Preset {
104
107
  readonly aliases: readonly string[];
105
108
  /** Whether the venue reviews double-blind (`anonymity/identity`); false when no file of the chain says. */
106
109
  readonly blind: boolean;
110
+ /**
111
+ * When the venue's names count as a mention in a paper's text (`tex/venue-leftover`): `any`
112
+ * whole-word use, or `with-year` beside it; `any` when no file of the chain says.
113
+ */
114
+ readonly mentions: "any" | "with-year";
107
115
  readonly requiredSections: readonly RequiredSection[];
108
116
  readonly tex: TexRequirements;
109
117
  readonly format: VenueFormat;
@@ -116,6 +124,11 @@ export interface Preset {
116
124
  readonly talk: VenueTalk | null;
117
125
  /** Where the venue takes submissions (`paperlint submission`); null when no file of the chain says. */
118
126
  readonly portal: VenuePortal | null;
127
+ /**
128
+ * The edition's deadlines as they were read (the portal's, refreshed by paperlint's scheduled
129
+ * workflow); empty when no file of the chain names any. A paper's current cycle derives them.
130
+ */
131
+ readonly deadlines: readonly Reading[];
119
132
  }
120
133
 
121
134
  /** Why a spec does not resolve. */
@@ -254,6 +267,7 @@ export function mergeFormat(
254
267
  fontsText: pick("fontsText"),
255
268
  fontsTitle: pick("fontsTitle"),
256
269
  bodyEndsAt: pick("bodyEndsAt"),
270
+ appendixInBody: pick("appendixInBody"),
257
271
  kinds: new Map([...parent.kinds, ...child.kinds]),
258
272
  };
259
273
  }
@@ -276,6 +290,7 @@ const NOTHING_MERGED: Merging = {
276
290
  template: null,
277
291
  aliases: [],
278
292
  blind: false,
293
+ mentions: "any",
279
294
  requiredSections: [],
280
295
  tex: NO_REQUIREMENTS,
281
296
  format: NO_FORMAT,
@@ -284,18 +299,37 @@ const NOTHING_MERGED: Merging = {
284
299
  registerAnchors: [],
285
300
  talk: null,
286
301
  portal: null,
302
+ deadlines: [],
287
303
  };
288
304
 
305
+ /** The blocks a child replaces whole: the child's when it names one, else its parents'. Pure. */
306
+ const wholeBlocks = (
307
+ acc: Merging,
308
+ p: PresetFile,
309
+ ): Pick<
310
+ Merging,
311
+ "requiredSections" | "registerAnchors" | "talk" | "portal" | "deadlines"
312
+ > => ({
313
+ requiredSections: p.requiredSections ?? acc.requiredSections,
314
+ registerAnchors: p.registerAnchors ?? acc.registerAnchors,
315
+ talk: p.talk ?? acc.talk,
316
+ portal: p.portal ?? acc.portal,
317
+ deadlines: p.deadlines ?? acc.deadlines,
318
+ });
319
+
289
320
  /** One file of the chain, `file`, merged over what its parents gave. Pure. */
290
321
  const mergeOne = (
291
322
  acc: Merging,
292
323
  { preset: p, file }: { readonly preset: PresetFile; readonly file: string },
293
324
  ): Merging => ({
294
325
  name: nameOf(p.identity) ?? acc.name,
295
- template: p.template === null ? acc.template : { text: p.template, file },
326
+ template:
327
+ p.template === null
328
+ ? acc.template
329
+ : { text: p.template, file, forbids: p.templateForbids },
296
330
  aliases: [...new Set([...acc.aliases, ...namesOf(p.identity), ...p.aliases])],
297
331
  blind: p.blind ?? acc.blind,
298
- requiredSections: p.requiredSections ?? acc.requiredSections,
332
+ mentions: p.mentions ?? acc.mentions,
299
333
  tex: p.tex ? mergeRequirements(acc.tex, p.tex) : acc.tex,
300
334
  format: mergeFormat(acc.format, p.format),
301
335
  rules: { ...acc.rules, ...p.rules },
@@ -303,9 +337,7 @@ const mergeOne = (
303
337
  ...acc.ruleOrigins,
304
338
  ...Object.fromEntries(Object.keys(p.rules).map((id) => [id, file])),
305
339
  },
306
- registerAnchors: p.registerAnchors ?? acc.registerAnchors,
307
- talk: p.talk ?? acc.talk,
308
- portal: p.portal ?? acc.portal,
340
+ ...wholeBlocks(acc, p),
309
341
  });
310
342
 
311
343
  /** The chain, root first, merged into one preset. Pure. */
@@ -368,17 +400,21 @@ export function resolveShipped(deps: PresetDeps): readonly Preset[] {
368
400
  }
369
401
 
370
402
  /** A shipped venue as a name knows it, and the files of its chain: what tells it from a paper's own. */
371
- export type ShippedVenue = Pick<Preset, "label" | "aliases" | "chain">;
403
+ export type ShippedVenue = Pick<
404
+ Preset,
405
+ "label" | "aliases" | "chain" | "mentions"
406
+ >;
372
407
 
373
408
  /**
374
409
  * A resolved preset as a name knows it: its label, its aliases, its chain. Pure. (Not Remeda's
375
410
  * `pick`: the app layer may not import it — `boundaries/dependencies`, `APP_EXTERNALS`.)
376
411
  */
377
- export const venueOf = ({ label, aliases, chain }: Preset): ShippedVenue => ({
412
+ export const venueOf = ({
378
413
  label,
379
414
  aliases,
380
415
  chain,
381
- });
416
+ mentions,
417
+ }: Preset): ShippedVenue => ({ label, aliases, chain, mentions });
382
418
 
383
419
  /**
384
420
  * THE ONE SOURCE OF VENUE NAMES: every shipped venue's label and `aliases` (with each `name` along
@@ -440,15 +476,29 @@ export function paperPreset(paperDir: string, deps: PresetDeps): PaperPreset {
440
476
  : { kind: "preset-problem", settings, problem: r.error };
441
477
  }
442
478
 
479
+ /** A paper's settings or preset that cannot be used. */
480
+ export type UnusablePreset = Extract<
481
+ PaperPreset,
482
+ { readonly kind: "settings-problem" | "preset-problem" }
483
+ >;
484
+
485
+ /** The one-line reason a paper's settings or preset cannot be used. */
486
+ export const unusablePresetLine = (
487
+ paperDir: string,
488
+ p: UnusablePreset,
489
+ ): string =>
490
+ p.kind === "preset-problem"
491
+ ? `${join(paperDir, CONFIG_FILE)}: ${presetProblemText(p.problem)}`
492
+ : settingsProblemLine(paperDir, p.problem);
493
+
443
494
  /** The one-line reason a paper's settings or preset cannot be used, or null when they can. */
444
495
  export function paperPresetProblem(
445
496
  paperDir: string,
446
497
  p: PaperPreset,
447
498
  ): string | null {
448
- if (p.kind === "preset-problem")
449
- return `${join(paperDir, CONFIG_FILE)}: ${presetProblemText(p.problem)}`;
450
- if (p.kind !== "settings-problem") return null;
451
- return settingsProblemLine(paperDir, p.problem);
499
+ return p.kind === "preset-problem" || p.kind === "settings-problem"
500
+ ? unusablePresetLine(paperDir, p)
501
+ : null;
452
502
  }
453
503
 
454
504
  /** The one line naming a paper's `paperlint.json` that does not parse, and why. */
@@ -316,6 +316,88 @@ const onPdf = (tree: Record<string, string>, line = 1) => ({
316
316
  line,
317
317
  });
318
318
 
319
+ /** One open cycle on the ACM family: what a flat `{ "extends": "paperlint:acm-sigconf" }` says, as a record. */
320
+ const OPEN_CYCLE = JSON.stringify({
321
+ cycles: [
322
+ {
323
+ id: "open-2027",
324
+ venue: { kind: "preset", extends: "paperlint:acm-sigconf" },
325
+ opened: "2026-09-09",
326
+ },
327
+ ],
328
+ });
329
+ /**
330
+ * One open cycle whose registration deadline is after its submission deadline: a date typed wrong.
331
+ * The record's STRUCTURAL problems — two open cycles, a bare date, an unknown key — never reach a
332
+ * rule: `paperRuleBlocks` refuses the settings file before lint runs (measured 2026-10-05: one line
333
+ * naming the file and both ids, exit 2), as it refuses any unreadable `paperlint.json`. The order of
334
+ * the deadlines is what the record parses with and the rule still judges.
335
+ */
336
+ const DISORDERED = JSON.stringify({
337
+ cycles: [
338
+ {
339
+ id: "open-2027",
340
+ venue: { kind: "preset", extends: "paperlint:acm-sigconf" },
341
+ opened: "2026-09-09",
342
+ // The call's own abstract day after its paper day: a date typed wrong. (The portal's and the
343
+ // call's readings disagreeing is not a finding: the earlier binds.)
344
+ deadlines: [
345
+ {
346
+ what: "registration",
347
+ observed: [
348
+ {
349
+ at: "2026-10-25 AoE",
350
+ source: "call",
351
+ url: "https://conf.example/cfp",
352
+ read: "2026-09-09",
353
+ },
354
+ ],
355
+ },
356
+ {
357
+ what: "submission",
358
+ observed: [
359
+ {
360
+ at: "2026-10-20T04:00:00Z",
361
+ source: "portal",
362
+ url: "https://conf.example/deadlines",
363
+ read: "2026-09-09",
364
+ },
365
+ {
366
+ at: "2026-10-23 AoE",
367
+ source: "call",
368
+ url: "https://conf.example/cfp",
369
+ read: "2026-09-09",
370
+ },
371
+ ],
372
+ },
373
+ ],
374
+ },
375
+ ],
376
+ });
377
+ /** One closed cycle whose evidence is `reviews/decision.md`. */
378
+ const CLOSED_CYCLE = JSON.stringify({
379
+ cycles: [
380
+ {
381
+ id: "first-2026",
382
+ venue: { kind: "preset", extends: "paperlint:acm-sigconf" },
383
+ opened: "2026-07-01",
384
+ outcome: {
385
+ kind: "rejected",
386
+ date: "2026-09-08",
387
+ evidence: "reviews/decision.md",
388
+ },
389
+ },
390
+ ],
391
+ });
392
+ /** A BlindConf cycle still declared porting — on a paper whose class line is already BlindConf's. */
393
+ const BLIND_PORTING = {
394
+ id: "blindconf-2027",
395
+ venue: { kind: "preset", extends: "./blindconf.jsonc" },
396
+ opened: "2026-09-09",
397
+ phase: "porting",
398
+ // What `blind()` declares flat; with cycles it moves into the record's siblings, not the cycle.
399
+ };
400
+
319
401
  const CASES: Readonly<Record<string, RuleCases>> = {
320
402
  "paper/stages": {
321
403
  reports: {
@@ -326,6 +408,68 @@ const CASES: Readonly<Record<string, RuleCases>> = {
326
408
  },
327
409
  silent: paper(tex("x")),
328
410
  },
411
+ // ── the cycle rules: the paper's attempts in paperlint.json, judged on PIPELINE-STATUS.md ──────
412
+ "cycle/record": {
413
+ reports: {
414
+ tree: { ...paper(tex("x")), [`${P}/paperlint.json`]: DISORDERED },
415
+ file: STATUS_FILE,
416
+ severity: 2,
417
+ line: 1,
418
+ },
419
+ silent: paper(tex("x")),
420
+ },
421
+ "cycle/evidence": {
422
+ reports: {
423
+ tree: { ...paper(tex("x")), [`${P}/paperlint.json`]: CLOSED_CYCLE },
424
+ file: STATUS_FILE,
425
+ severity: 2,
426
+ line: 1,
427
+ },
428
+ silent: {
429
+ ...paper(tex("x")),
430
+ [`${P}/paperlint.json`]: CLOSED_CYCLE,
431
+ [`${P}/reviews/decision.md`]: "# decision\n",
432
+ },
433
+ },
434
+ "cycle/stage": {
435
+ reports: {
436
+ tree: {
437
+ ...paper(tex("x")),
438
+ [STATUS_FILE]: SHIPPED_STATUS,
439
+ [`${P}/paperlint.json`]: OPEN_CYCLE,
440
+ [`${P}/versions/a.pdf`]: "x",
441
+ },
442
+ file: STATUS_FILE,
443
+ severity: 2,
444
+ line: 1,
445
+ },
446
+ silent: {
447
+ ...paper(tex("x")),
448
+ [STATUS_FILE]: SHIPPED_STATUS.replace(
449
+ "date: 2026-07-22",
450
+ "cycle: open-2027\n date: 2026-07-22",
451
+ ),
452
+ [`${P}/paperlint.json`]: OPEN_CYCLE,
453
+ [`${P}/versions/a.pdf`]: "x",
454
+ },
455
+ },
456
+ "cycle/port-done": {
457
+ reports: {
458
+ tree: {
459
+ ...blind("Text."),
460
+ [`${P}/paperlint.json`]: JSON.stringify({ cycles: [BLIND_PORTING] }),
461
+ },
462
+ file: TEX_FILE,
463
+ severity: 2,
464
+ line: 1,
465
+ },
466
+ silent: {
467
+ ...blind("Text."),
468
+ [`${P}/paperlint.json`]: JSON.stringify({
469
+ cycles: [{ ...BLIND_PORTING, phase: "prepared" }],
470
+ }),
471
+ },
472
+ },
329
473
  "paper/source": {
330
474
  reports: {
331
475
  tree: { ...paper(tex("x")), [STATUS_FILE]: SHIPPED_STATUS },
@@ -22,6 +22,7 @@ import type { SubmissionPortal } from "./ports/submission-portal.ts";
22
22
  import {
23
23
  failureText,
24
24
  runSubmission,
25
+ deadlineLine,
25
26
  submissionTarget,
26
27
  tokenFor,
27
28
  usageProblem,
@@ -41,8 +42,11 @@ const withPresets = (fs: Files): Files => ({
41
42
  readBytes: (p) => fs.readBytes(p) ?? nodeFiles.readBytes(p),
42
43
  });
43
44
 
45
+ /** A venue over aidc's format with a portal of its own — so none of aidc's deadline readings. */
44
46
  const venue = (portal: unknown): string =>
45
- JSON.stringify(venuePreset("venue", { extends: "paperlint:aidc", portal }));
47
+ JSON.stringify(
48
+ venuePreset("venue", { extends: "paperlint:aidc", portal, deadlines: [] }),
49
+ );
46
50
  const files = (o: {
47
51
  readonly portal?: unknown;
48
52
  readonly paper?: Readonly<Record<string, unknown>>;
@@ -117,6 +121,10 @@ const fakePortal = (answers: {
117
121
  return { seen, portalFor };
118
122
  };
119
123
 
124
+ /** The line `update` prints before it sends `pdf`, naming `call`. */
125
+ const CHECK = (pdf: string, call: string): string =>
126
+ `before you rely on it, open ${pdf} yourself and check it against ${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`;
127
+
120
128
  const ARGS: SubmissionArgs = {
121
129
  sub: "show",
122
130
  paperDir: "papers/p",
@@ -287,6 +295,7 @@ test("update: a dry run by default — the paper's PDF sent, nothing saved, exit
287
295
  ],
288
296
  ]);
289
297
  assert.deepEqual(r.out, [
298
+ CHECK("papers/p/paper.pdf", "the call for papers, https://example.org/cfp"),
290
299
  "dry run — the portal checked the change and kept nothing; --save sends it for real",
291
300
  "HTTP 200",
292
301
  "valid yes",
@@ -322,7 +331,41 @@ test("update --save --submit --abstract --pdf: all of it sent, and said to be sa
322
331
  { save: true },
323
332
  ],
324
333
  ]);
325
- assert.equal(r.out[0], "saved");
334
+ // Guards: the PDF that is sent is the one named, and --save is told to check it too.
335
+ assert.deepEqual(r.out.slice(0, 2), [
336
+ CHECK("other.pdf", "the call for papers, https://example.org/cfp"),
337
+ "saved",
338
+ ]);
339
+ });
340
+
341
+ test("🔴 update: the check line names the venue's call from the preset's `url`; a preset that names no venue gets the generic line", async () => {
342
+ const family = memoryFiles({
343
+ "/w/package.json": "{}",
344
+ "/w/house.jsonc": JSON.stringify({
345
+ type: "family",
346
+ extends: "paperlint:ieee-conference",
347
+ portal: { kind: "hotcrp", url: `${SITE}/` },
348
+ }),
349
+ [`${PAPER}/paperlint.json`]: JSON.stringify({
350
+ extends: "../../house.jsonc",
351
+ submission: { id: 7 },
352
+ }),
353
+ [`${PAPER}/paper.pdf`]: PDF,
354
+ });
355
+ const r = await run({ sub: "update" }, { files: family });
356
+ assert.equal(r.code, 0);
357
+ assert.equal(
358
+ r.out[0],
359
+ CHECK("papers/p/paper.pdf", "the venue's call for papers"),
360
+ );
361
+ });
362
+
363
+ test("show sends nothing, so it asks for no check", async () => {
364
+ const r = await run({});
365
+ assert.equal(
366
+ r.out.some((l) => l.includes("call for papers")),
367
+ false,
368
+ );
326
369
  });
327
370
 
328
371
  test("update: valid:false, or a non-2xx status, exits 1 with the portal's messages", async () => {
@@ -338,7 +381,7 @@ test("update: valid:false, or a non-2xx status, exits 1 with the portal's messag
338
381
  { portal: fakePortal({ update: invalid }) },
339
382
  );
340
383
  assert.equal(r1.code, 1);
341
- assert.deepEqual(r1.out.slice(2), [
384
+ assert.deepEqual(r1.out.slice(3), [
342
385
  "valid NO",
343
386
  "changes —",
344
387
  `local PDF papers/p/paper.pdf sha2-${HEX}`,
@@ -380,7 +423,7 @@ test("update --save that the portal withheld says so", async () => {
380
423
  { sub: "update", save: true },
381
424
  { portal: fakePortal({ update: withheld }) },
382
425
  );
383
- assert.equal(r.out[0], "SAVE WITHHELD by the portal — nothing changed");
426
+ assert.equal(r.out[1], "SAVE WITHHELD by the portal — nothing changed");
384
427
  });
385
428
 
386
429
  test("update: no PDF to send, or no abstract file, is refused before the portal is asked", async () => {
@@ -437,6 +480,28 @@ test("🔴 the file and key to set: no portal in the preset, no submission in th
437
480
  const noExtends = files({ paper: { submission: { id: 7 } } });
438
481
  const t3 = submissionTarget(PAPER, deps(noExtends));
439
482
  assert.match(!t3.ok ? t3.error : "", /paperlint\.json: no "extends"/);
483
+ // With cycles, `extends` beside them is refused: the message names the cycle, not the key.
484
+ const named = files({
485
+ paper: {
486
+ cycles: [
487
+ {
488
+ id: "secdev-2027",
489
+ venue: {
490
+ kind: "named",
491
+ name: "SecDev 2027",
492
+ url: "https://x.example",
493
+ },
494
+ opened: "2027-01-10",
495
+ submission: { id: 3 },
496
+ },
497
+ ],
498
+ },
499
+ });
500
+ const t5 = submissionTarget(PAPER, deps(named));
501
+ assert.equal(
502
+ !t5.ok ? t5.error : "",
503
+ `${PAPER}/paperlint.json: 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`,
504
+ );
440
505
  const broken = files({
441
506
  paper: { extends: "../../venue.jsonc", submission: { id: "7" } },
442
507
  });
@@ -495,3 +560,81 @@ test("usage: a subcommand is required, one paper folder, update-only flags refus
495
560
  );
496
561
  assert.equal(usageProblem({ ...ARGS, sub: "update", save: true }), null);
497
562
  });
563
+
564
+ test("show prints the current attempt's deadlines in force: the preset's portal readings, the paper's call readings and override over them", () => {
565
+ const fs = memoryFiles({
566
+ "/w/package.json": "{}",
567
+ [`${PAPER}/paperlint.json`]: JSON.stringify({
568
+ cycles: [
569
+ {
570
+ id: "msr-2027",
571
+ venue: { kind: "preset", extends: "paperlint:msr" },
572
+ kind: "technical",
573
+ opened: "2026-10-05",
574
+ submission: { id: 7 },
575
+ deadlines: [
576
+ {
577
+ what: "registration",
578
+ observed: [
579
+ {
580
+ at: "2026-10-20 AoE",
581
+ source: "call",
582
+ url: "https://2027.msrconf.org/cfp",
583
+ read: "2026-10-05",
584
+ },
585
+ ],
586
+ },
587
+ {
588
+ what: "submission",
589
+ observed: [
590
+ {
591
+ at: "2026-10-23 AoE",
592
+ source: "call",
593
+ url: "https://2027.msrconf.org/cfp",
594
+ read: "2026-10-05",
595
+ },
596
+ ],
597
+ },
598
+ {
599
+ what: "resubmission",
600
+ override: {
601
+ at: "2026-10-27T04:00:00Z",
602
+ reason: "the chairs extended updates by mail",
603
+ evidence: "mail/extension.eml",
604
+ },
605
+ },
606
+ ],
607
+ },
608
+ ],
609
+ }),
610
+ });
611
+ const t = submissionTarget(PAPER, {
612
+ files: withPresets(fs),
613
+ venuesDir: presetsDir(),
614
+ });
615
+ assert.ok(t.ok, JSON.stringify(t));
616
+ assert.deepEqual(t.value.deadlines.map(deadlineLine), [
617
+ "deadline registration 2026-10-21T11:59:59Z — call, read 2026-10-05",
618
+ "deadline submission 2026-10-20T04:00:00Z — portal, read 2026-10-05; also call 2026-10-24T11:59:59Z, read 2026-10-05",
619
+ "deadline resubmission 2026-10-27T04:00:00Z — override: the chairs extended updates by mail (mail/extension.eml); also portal 2026-10-23T04:00:00Z, read 2026-10-05",
620
+ ]);
621
+ });
622
+
623
+ test("show, in the flat form: the deadlines in force are the preset's readings alone", () => {
624
+ const flat = memoryFiles({
625
+ "/w/package.json": "{}",
626
+ [`${PAPER}/paperlint.json`]: JSON.stringify({
627
+ extends: "paperlint:msr",
628
+ kind: "technical",
629
+ submission: { id: 7 },
630
+ }),
631
+ });
632
+ const f = submissionTarget(PAPER, {
633
+ files: withPresets(flat),
634
+ venuesDir: presetsDir(),
635
+ });
636
+ assert.deepEqual(f.ok ? f.value.deadlines.map((d) => [d.what, d.at]) : f, [
637
+ ["submission", "2026-10-20T04:00:00Z"],
638
+ ["resubmission", "2026-10-23T04:00:00Z"],
639
+ ]);
640
+ });