paperlint 7.1.0 → 8.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (261) hide show
  1. package/.github/workflows/ci.yml +3 -0
  2. package/.github/workflows/deadlines.yml +100 -0
  3. package/README.md +6 -0
  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/build.d.ts.map +1 -1
  17. package/dist/build.js +1 -6
  18. package/dist/build.js.map +1 -1
  19. package/dist/cli.d.ts +3 -4
  20. package/dist/cli.d.ts.map +1 -1
  21. package/dist/cli.js +53 -47
  22. package/dist/cli.js.map +1 -1
  23. package/dist/cycle-rules.d.ts +24 -0
  24. package/dist/cycle-rules.d.ts.map +1 -0
  25. package/dist/cycle-rules.js +212 -0
  26. package/dist/cycle-rules.js.map +1 -0
  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/eslint-rules/latex-language.d.ts.map +1 -1
  40. package/dist/eslint-rules/latex-language.js +10 -13
  41. package/dist/eslint-rules/latex-language.js.map +1 -1
  42. package/dist/eslint-rules/paper-context.js +2 -2
  43. package/dist/eslint-rules/paper-typography.d.ts.map +1 -1
  44. package/dist/eslint-rules/paper-typography.js +22 -91
  45. package/dist/eslint-rules/paper-typography.js.map +1 -1
  46. package/dist/eslint-rules/papers.d.ts +1 -3
  47. package/dist/eslint-rules/papers.d.ts.map +1 -1
  48. package/dist/eslint-rules/papers.js +2 -4
  49. package/dist/eslint-rules/papers.js.map +1 -1
  50. package/dist/eslint-rules/tex-build.d.ts.map +1 -1
  51. package/dist/eslint-rules/tex-build.js +8 -25
  52. package/dist/eslint-rules/tex-build.js.map +1 -1
  53. package/dist/init.d.ts +1 -4
  54. package/dist/init.d.ts.map +1 -1
  55. package/dist/init.js +3 -9
  56. package/dist/init.js.map +1 -1
  57. package/dist/lib/markdown.d.ts +5 -81
  58. package/dist/lib/markdown.d.ts.map +1 -1
  59. package/dist/lib/markdown.js +5 -116
  60. package/dist/lib/markdown.js.map +1 -1
  61. package/dist/lib/paper-config.d.ts.map +1 -1
  62. package/dist/lib/paper-config.js +2 -2
  63. package/dist/lib/paper-config.js.map +1 -1
  64. package/dist/lib/skill-eval-fixture.d.ts +6 -4
  65. package/dist/lib/skill-eval-fixture.d.ts.map +1 -1
  66. package/dist/lib/skill-eval-fixture.js +25 -18
  67. package/dist/lib/skill-eval-fixture.js.map +1 -1
  68. package/dist/new-paper.d.ts +5 -11
  69. package/dist/new-paper.d.ts.map +1 -1
  70. package/dist/new-paper.js +12 -23
  71. package/dist/new-paper.js.map +1 -1
  72. package/dist/paper-files.d.ts +4 -4
  73. package/dist/paper-prose.d.ts +17 -0
  74. package/dist/paper-prose.d.ts.map +1 -0
  75. package/dist/paper-prose.js +46 -0
  76. package/dist/paper-prose.js.map +1 -0
  77. package/dist/paper-settings.d.ts +7 -0
  78. package/dist/paper-settings.d.ts.map +1 -1
  79. package/dist/paper-settings.js +61 -8
  80. package/dist/paper-settings.js.map +1 -1
  81. package/dist/ports/venue-deadlines.d.ts +14 -0
  82. package/dist/ports/venue-deadlines.d.ts.map +1 -0
  83. package/dist/ports/venue-deadlines.js +2 -0
  84. package/dist/ports/venue-deadlines.js.map +1 -0
  85. package/dist/presets.d.ts +12 -0
  86. package/dist/presets.d.ts.map +1 -1
  87. package/dist/presets.js +19 -11
  88. package/dist/presets.js.map +1 -1
  89. package/dist/structure.d.ts.map +1 -1
  90. package/dist/structure.js +4 -3
  91. package/dist/structure.js.map +1 -1
  92. package/dist/submission.d.ts +8 -0
  93. package/dist/submission.d.ts.map +1 -1
  94. package/dist/submission.js +41 -10
  95. package/dist/submission.js.map +1 -1
  96. package/dist/tex-requirements.d.ts +3 -0
  97. package/dist/tex-requirements.d.ts.map +1 -1
  98. package/dist/tex-requirements.js +19 -0
  99. package/dist/tex-requirements.js.map +1 -1
  100. package/dist/tex-venue-rules.d.ts +19 -0
  101. package/dist/tex-venue-rules.d.ts.map +1 -1
  102. package/dist/tex-venue-rules.js +69 -0
  103. package/dist/tex-venue-rules.js.map +1 -1
  104. package/dist/types.d.ts +0 -2
  105. package/dist/types.d.ts.map +1 -1
  106. package/dist/venue-rules.d.ts +10 -0
  107. package/dist/venue-rules.d.ts.map +1 -1
  108. package/dist/venue-rules.js +26 -10
  109. package/dist/venue-rules.js.map +1 -1
  110. package/docs/configuration.md +146 -20
  111. package/docs/e2e.md +4 -6
  112. package/docs/prior-art/paper-folder-scaffolding.md +24 -29
  113. package/docs/rules/cycle/evidence.md +87 -0
  114. package/docs/rules/cycle/port-done.md +75 -0
  115. package/docs/rules/cycle/record.md +152 -0
  116. package/docs/rules/cycle/stage.md +85 -0
  117. package/docs/rules/tex/claim-provenance.md +0 -1
  118. package/docs/rules.md +48 -47
  119. package/docs/submission.md +5 -0
  120. package/eslint-rules/cycle-stage.test.mjs +131 -0
  121. package/eslint-rules/cycle-stage.ts +141 -0
  122. package/eslint-rules/latex-language.harness.mjs +3 -3
  123. package/eslint-rules/latex-language.ts +10 -13
  124. package/eslint-rules/paper-context.ts +2 -2
  125. package/eslint-rules/paper-typography.test.mjs +17 -85
  126. package/eslint-rules/paper-typography.ts +22 -98
  127. package/eslint-rules/papers.harness.mjs +1 -1
  128. package/eslint-rules/papers.ts +2 -4
  129. package/eslint-rules/tex-build.harness.mjs +2 -2
  130. package/eslint-rules/tex-build.ts +8 -25
  131. package/eslint.config.mjs +41 -2
  132. package/hooks/hooks.harness.ts +15 -15
  133. package/hooks/paper-edit-guard.hook.mjs +5 -8
  134. package/hooks/paper-edit-guard.test.ts +12 -3
  135. package/hooks/paper-skills-nudge.hook.mjs +2 -3
  136. package/hooks/paper-skills-nudge.test.ts +13 -3
  137. package/hooks/paper-status-gates.hook.mjs +2 -2
  138. package/hooks/paper-status-gates.test.ts +14 -2
  139. package/lib/markdown.test.mjs +5 -60
  140. package/lib/markdown.ts +9 -136
  141. package/lib/paper-config.ts +3 -2
  142. package/lib/skill-checks.test.mjs +4 -1
  143. package/lib/skill-eval-fixture.ts +25 -18
  144. package/package.json +1 -3
  145. package/presets/aidc.jsonc +24 -3
  146. package/presets/aidc.md +6 -3
  147. package/presets/msr.jsonc +25 -1
  148. package/presets/msr.md +5 -1
  149. package/presets/realm.jsonc +1 -1
  150. package/presets/venue-profile.schema.json +39 -0
  151. package/scripts/check.ts +4 -0
  152. package/scripts/harness-api.frozen.json +0 -5
  153. package/scripts/refresh-deadlines.test.ts +431 -0
  154. package/scripts/refresh-deadlines.ts +387 -0
  155. package/skills/cold-read-diff/SKILL.md +3 -3
  156. package/skills/cold-read-diff/SKILL.md.spec.ts +2 -2
  157. package/skills/grade-paper-writing/SKILL.md +14 -22
  158. package/skills/grade-paper-writing/SKILL.md.spec.ts +13 -21
  159. package/skills/grade-paper-writing/__snapshots__/prose-lint.test.ts.snap +40 -26
  160. package/skills/grade-paper-writing/prose-lint.mjs +108 -278
  161. package/skills/grade-paper-writing/prose-lint.test.ts +184 -81
  162. package/skills/paper-adversarial-review/SKILL.md +2 -2
  163. package/skills/paper-adversarial-review/SKILL.md.spec.ts +1 -1
  164. package/skills/paper-pipeline/SKILL.md +9 -12
  165. package/skills/paper-pipeline/SKILL.md.spec.ts +8 -11
  166. package/skills/paper-pipeline/description-language.eval.mjs +25 -15
  167. package/skills/paper-pipeline/framing-vs-vocabulary.eval.mjs +25 -15
  168. package/skills/paper-pipeline/grade-paper-writing-ablation.eval.mjs +25 -15
  169. package/skills/paper-pipeline/pipeline-firing.eval.mjs +27 -16
  170. package/skills/paper-pipeline/pipeline-language.eval.mjs +25 -15
  171. package/skills/paper-pipeline/references/acceptance-gate.md +16 -20
  172. package/skills/paper-pipeline/references/artifact-checklist.md +4 -5
  173. package/skills/paper-pipeline/references/pipeline-status-template.md +1 -1
  174. package/skills/paper-pipeline/scripts/README.md +0 -4
  175. package/skills/paper-pipeline/scripts/extract-ref-facts.harness.mjs +8 -72
  176. package/skills/paper-pipeline/scripts/extract-ref-facts.mjs +48 -156
  177. package/skills/paper-pipeline/scripts/extract-ref-facts.test.mjs +81 -75
  178. package/skills/paper-pipeline/scripts/generated-code.harness.mjs +3 -3
  179. package/skills/paper-status/SKILL.md +2 -2
  180. package/skills/paper-status/SKILL.md.spec.ts +1 -1
  181. package/skills/render-paper/SKILL.md +2 -2
  182. package/skills/render-paper/SKILL.md.spec.ts +1 -1
  183. package/skills/render-paper/extract-pdf-facts.mjs +2 -2
  184. package/skills/tighten-paper/SKILL.md +29 -42
  185. package/skills/tighten-paper/SKILL.md.spec.ts +28 -41
  186. package/skills/verify-citations/scripts/bib-authors.check.test.mjs +0 -13
  187. package/skills/verify-citations/scripts/bib-authors.cli.test.ts +23 -11
  188. package/skills/verify-citations/scripts/bib-authors.mjs +8 -93
  189. package/skills/verify-citations/scripts/bib-authors.test.mjs +1 -44
  190. package/src/adapters/hotcrp/cassette/README.md +22 -0
  191. package/src/adapters/hotcrp/cassette/deadlines-aidc26.html +17 -0
  192. package/src/adapters/hotcrp/cassette/deadlines-fse2027.html +24 -0
  193. package/src/adapters/hotcrp/cassette/deadlines-icse2027.html +25 -0
  194. package/src/adapters/hotcrp/cassette/deadlines-msr2027.html +25 -0
  195. package/src/adapters/hotcrp/cassette/deadlines-nosuch.html +18 -0
  196. package/src/adapters/hotcrp/cassette/deadlines-sosp26.html +26 -0
  197. package/src/adapters/hotcrp/deadlines.io.test.ts +86 -0
  198. package/src/adapters/hotcrp/deadlines.io.ts +27 -0
  199. package/src/adapters/hotcrp/deadlines.test.ts +170 -0
  200. package/src/adapters/hotcrp/deadlines.ts +161 -0
  201. package/src/adapters/hotcrp/index.ts +2 -0
  202. package/src/build.harness.mjs +2 -2
  203. package/src/build.test.ts +1 -1
  204. package/src/build.ts +1 -6
  205. package/src/cli.harness.mjs +26 -46
  206. package/src/cli.test.ts +41 -40
  207. package/src/cli.ts +57 -58
  208. package/src/cycle-rules.test.ts +278 -0
  209. package/src/cycle-rules.ts +273 -0
  210. package/src/domain/cycle.test.ts +456 -0
  211. package/src/domain/cycle.ts +432 -0
  212. package/src/domain/deadline.test.ts +386 -0
  213. package/src/domain/deadline.ts +472 -0
  214. package/src/folder-venue-rule.test.ts +1 -1
  215. package/src/init.test.ts +6 -7
  216. package/src/init.ts +3 -22
  217. package/src/lint-fix.test.ts +10 -4
  218. package/src/new-paper.harness.mjs +9 -28
  219. package/src/new-paper.test.ts +7 -6
  220. package/src/new-paper.ts +11 -30
  221. package/src/paper-files.test.ts +5 -4
  222. package/src/paper-files.ts +4 -4
  223. package/src/paper-prose.test.ts +49 -0
  224. package/src/paper-prose.ts +67 -0
  225. package/src/paper-settings.test.ts +167 -0
  226. package/src/paper-settings.ts +85 -11
  227. package/src/parse-args.test.ts +0 -1
  228. package/src/ports/venue-deadlines.ts +14 -0
  229. package/src/presets.test.ts +105 -0
  230. package/src/presets.ts +43 -10
  231. package/src/rule-cases.test.ts +144 -0
  232. package/src/structure.harness.ts +5 -11
  233. package/src/structure.ts +4 -3
  234. package/src/submission.test.ts +105 -1
  235. package/src/submission.ts +73 -12
  236. package/src/tex-requirements.ts +31 -0
  237. package/src/tex-venue-rules.test.ts +76 -0
  238. package/src/tex-venue-rules.ts +85 -0
  239. package/src/types.ts +0 -2
  240. package/src/venue-rules.test.ts +82 -0
  241. package/src/venue-rules.ts +44 -12
  242. package/fixtures/real-markdown-paper/baseline.json +0 -22
  243. package/fixtures/real-markdown-paper/baseline.ts +0 -70
  244. package/skills/paper-pipeline/PIPELINE-MAP.md +0 -381
  245. package/skills/paper-pipeline/pipeline-firing.baseline.json +0 -270
  246. package/skills/paper-pipeline/references/sweep-2026-08-09-ideal-pipeline.md +0 -585
  247. package/skills/paper-pipeline/scripts/artifact-coverage.harness.mjs +0 -492
  248. package/skills/paper-pipeline/scripts/artifact-coverage.mjs +0 -397
  249. package/skills/paper-pipeline/scripts/artifact-coverage.test.ts +0 -86
  250. package/skills/paper-pipeline/scripts/check-provenance.mjs +0 -184
  251. package/skills/paper-pipeline/scripts/population-map.harness.mjs +0 -135
  252. package/skills/paper-pipeline/scripts/population-map.mjs +0 -188
  253. package/skills/paper-pipeline/scripts/population-map.test.mjs +0 -154
  254. package/skills/paper-pipeline/scripts/provenance.harness.mjs +0 -266
  255. package/skills/paper-pipeline/scripts/round-diff.harness.ts +0 -907
  256. package/skills/paper-pipeline/scripts/round-diff.mjs +0 -583
  257. package/skills/paper-pipeline/scripts/round-diff.test.mjs +0 -159
  258. package/skills/tighten-paper/__snapshots__/structure.test.ts.snap +0 -133
  259. package/skills/tighten-paper/structure.mjs +0 -364
  260. package/skills/tighten-paper/structure.test.ts +0 -117
  261. package/templates/paper/paper.md +0 -4
@@ -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>>;
@@ -476,6 +480,28 @@ test("🔴 the file and key to set: no portal in the preset, no submission in th
476
480
  const noExtends = files({ paper: { submission: { id: 7 } } });
477
481
  const t3 = submissionTarget(PAPER, deps(noExtends));
478
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
+ );
479
505
  const broken = files({
480
506
  paper: { extends: "../../venue.jsonc", submission: { id: "7" } },
481
507
  });
@@ -534,3 +560,81 @@ test("usage: a subcommand is required, one paper folder, update-only flags refus
534
560
  );
535
561
  assert.equal(usageProblem({ ...ARGS, sub: "update", save: true }), null);
536
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
+ });
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. */
@@ -82,22 +94,29 @@ export interface SubmissionTarget {
82
94
  readonly pdf: string;
83
95
  /** The venue's call for papers (the preset's `url`); null when the preset names no venue. */
84
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[];
85
102
  }
86
103
 
87
104
  const configOf = (dir: string): string => join(dir, CONFIG_FILE);
88
105
 
89
- /** The paper directory → its portal and submission, or the line naming the file and key to set. */
90
- 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 = (
91
108
  paperDir: string,
92
- 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" }>,
93
119
  ): Result<SubmissionTarget, string> {
94
- const p = paperPreset(paperDir, deps);
95
- const problem = paperPresetProblem(paperDir, p);
96
- if (problem !== null) return err(problem);
97
- if (p.kind !== "resolved")
98
- return err(
99
- `${configOf(paperDir)}: no "extends" — the venue preset it names declares the portal ("portal": { "kind": …, "url": … })`,
100
- );
101
120
  const declared = p.preset.portal;
102
121
  if (declared === null)
103
122
  return err(
@@ -113,7 +132,29 @@ export function submissionTarget(
113
132
  const pdf = join(paperDir, p.settings.pdf ?? "paper.pdf");
114
133
  const identity = p.preset.identity;
115
134
  const call = identity.type === "venue" ? identity.url : null;
116
- return ok({ portal: portal.value, id: sub.id, pdf, call });
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
+ }
117
158
  }
118
159
 
119
160
  /** The token from the environment, or the line naming the variable and where to get one. */
@@ -166,6 +207,25 @@ const pdfLine = (view: SubmissionView): string =>
166
207
  ? "portal PDF none"
167
208
  : `portal PDF ${view.pdf.hash}, ${view.pdf.size === null ? "?" : String(view.pdf.size)} bytes, uploaded ${iso(view.pdf.uploadedAt)}`;
168
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
+
169
229
  /** `show`'s report. Pure. */
170
230
  export function showLines(
171
231
  t: SubmissionTarget,
@@ -182,6 +242,7 @@ export function showLines(
182
242
  pdfLine(view),
183
243
  `local PDF ${local.shown} ${local.sha256 === null ? "(missing)" : `sha2-${local.sha256}`}`,
184
244
  matchLine(view, local.sha256),
245
+ ...t.deadlines.map(deadlineLine),
185
246
  ...view.messages.map(messageLine),
186
247
  ];
187
248
  }
@@ -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[]>>;
@@ -219,6 +220,8 @@ export interface PresetFile {
219
220
  readonly talk: VenueTalk | null;
220
221
  /** Where the venue takes submissions (`paperlint submission`); null when the file does not say. */
221
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;
222
225
  }
223
226
 
224
227
  type KindsJson = Readonly<
@@ -276,6 +279,7 @@ interface PresetBodyJson {
276
279
  };
277
280
  readonly talk?: VenueTalkJson;
278
281
  readonly portal?: VenuePortal;
282
+ readonly deadlines?: readonly Reading[];
279
283
  }
280
284
 
281
285
  /** An optional field as the typed preset holds it: absent is null. */
@@ -394,6 +398,32 @@ const anchorsOf = (
394
398
  const portalOf = (p: PresetJson["portal"]): Readonly<VenuePortal> | null =>
395
399
  p === undefined ? null : { kind: p.kind, url: p.url };
396
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
+
397
427
  /**
398
428
  * The text of one preset file → the typed file, or an Error naming every problem. The ONE parser
399
429
  * of a preset: the toolchain reads its `tex`, the venue rules its `format`, the config its `rules`.
@@ -429,6 +459,7 @@ export function parsePreset(
429
459
  registerAnchors: anchorsOf(j.register),
430
460
  talk: j.talk === undefined ? null : venueTalkOf(j.talk),
431
461
  portal: portalOf(j.portal),
462
+ deadlines: deadlinesOf(j.deadlines, file),
432
463
  };
433
464
  }
434
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`;
@@ -52,6 +52,7 @@ import type { PaperSource } from "./domain/paper-source.ts";
52
52
  import { readPaper, reportInPaper } from "./tex-paper.ts";
53
53
  import type { RequiredSection } from "./tex-requirements.ts";
54
54
  import type { Finding, VenueRuleDeps } from "./venue-rules.ts";
55
+ import { isPorting } from "./domain/cycle.ts";
55
56
 
56
57
  /** A finding and where it points; null points at the top of the file. */
57
58
  export interface Located extends Finding {
@@ -493,6 +494,10 @@ function readingOf(
493
494
  const dir = dirname(filename);
494
495
  const p = paperPreset(dir, deps);
495
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;
496
501
  const preset = p.preset;
497
502
  const others = () => otherVenues(preset, deps);
498
503
  const paper = readPaper(filename, src, deps);
@@ -538,3 +543,83 @@ export function formatRules(
538
543
  ): Readonly<Record<"layout-override", TexRuleModule>> {
539
544
  return { "layout-override": rule("layout-override", deps) };
540
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" };
package/src/types.ts CHANGED
@@ -62,8 +62,6 @@ export interface Args {
62
62
  noHooks: boolean;
63
63
  /** `--paper <name>`: `init` creates this paper. */
64
64
  paper: string | null;
65
- /** `--format tex|md` for `new` and `init --paper`. Validated by the command, not here. */
66
- format: string | null;
67
65
  /** `--venue <preset>` for `new`: a shipped preset name, or a path to the project's own. */
68
66
  venue: string | null;
69
67
  /** `--kind <kind>` for `new`: one of the venue preset's kinds. */
@@ -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", () => {
@@ -1193,3 +1223,55 @@ describe("judgeBodyEnd, on a venue it does not apply to", () => {
1193
1223
  expect(judgeBodyEnd(NO_TEXT, venue(null))).toEqual([]);
1194
1224
  });
1195
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
+ });