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
@@ -0,0 +1,18 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta http-equiv="content-type" content="text/html; charset=utf-8">
5
+ <link href="@1790985506/stylesheets/style.css" rel="stylesheet">
6
+ <link rel="icon" type="image/svg+xml" href="images/review48.svg">
7
+ <title>No such conference - acsac2026</title>
8
+ </head>
9
+ <body data-upload-limit="104857600" data-document-max-size="600000000"><div id="p-page" class="need-banner-offset"><header id="p-header"><hr class="c"><div id="h-site" class="header-site-page"><a class="q" href="https://acsac2026.hotcrp.com/"><span class="header-site-name">acsac2026</span></a></div><div id="h-right"></div><div id="h-page"><h1 id="h-title">No such conference</h1></div><hr class="c">
10
+ <div id="h-messages" class="want-mx-auto"></div></header>
11
+ <main id="p-body">
12
+ <p>Did you mean:</p><ul><li><a href="https://acsac20posters.hotcrp.com">ACSAC 2020 Posters &amp; WIPS</a> (<a href="https://acsac20posters.hotcrp.com">acsac20posters.hotcrp.com</a>)</li><li><a href="https://acsac20.hotcrp.com">ACSAC 2020</a> (<a href="https://acsac20.hotcrp.com">acsac20.hotcrp.com</a>)</li><li><a href="https://acsac19.hotcrp.com">ACSAC 2019</a> (<a href="https://acsac19.hotcrp.com">acsac19.hotcrp.com</a>)</li></ul><hr class="c"></main></div><footer id="p-footer" class="need-banner-offset banner-bottom"><a class="noq" href="https://hotcrp.com/">HotCRP</a><!-- Version 3.4 --></footer><!--[if lt IE 9]><script src="@1749780400/scripts/jquery-1.12.4.min.js" integrity="sha256-ZosEbRLbNQzLpnKIkEdrPv7lOy9C27hHQ+Xp8a4MxAQ="></script><![endif]-->
13
+ <![if !IE|gte IE 9]><script src="@1749780400/scripts/jquery-3.7.1.min.js" integrity="sha256-/JqT3SQfawRcv/BIHPThkBvs0OEvtFFmqPF/lYI/Cxo="></script><![endif]>
14
+ <script>window.siteinfo={"site_relative":"","base":"\/","suffix":"","assets":"","icons_url":"","cookie_params":"; Domain=.hotcrp.com; SameSite=Lax","postvalue":".empty","download_prefix":"acsac2026-","snouns":["submission","submissions","Submission","Submissions"],"user":[]}</script><script src="@1790985505/scripts/script.js"></script>
15
+ <script src="@1777496734/scripts/markdown-it.min.js"></script>
16
+ <script src="@1787955704/scripts/plugin.js"></script>
17
+ <script>hotcrp.onload.time(0,0)</script></body>
18
+ </html>
@@ -0,0 +1,26 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta http-equiv="content-type" content="text/html; charset=utf-8">
5
+ <meta name="google" content="notranslate">
6
+ <meta name="theme-color" content="#ffb7ca">
7
+ <meta name="viewport" content="width=device-width, initial-scale=1">
8
+ <link href="https://static.hotcrp.com/@1790985506/stylesheets/style.css" rel="stylesheet">
9
+ <link href="https://static.hotcrp.com/@1786633883/stylesheets/mobile.css" media="screen and (max-width: 1100px)" rel="stylesheet">
10
+ <link href="https://static.hotcrp.com/@1788721683/stylesheets/plugin.css" rel="stylesheet">
11
+ <link rel="icon" type="image/svg+xml" href="https://static.hotcrp.com/images/review48.svg">
12
+ <title>Deadlines - SOSP 2026</title>
13
+ </head>
14
+ <body id="m-deadlines" data-upload-limit="104857600" data-document-max-size="600000000"><div id="p-page" class="need-banner-offset"><header id="p-header"><hr class="c"><div id="h-site" class="header-site-page"><a class="q" href="/"><span class="header-site-name">SOSP 2026</span></a></div><div id="h-right"></div><div id="h-page"><h1 id="h-title">Deadlines</h1></div><nav id="p-quicklinks" aria-label="Submission search"></nav><hr class="c">
15
+ <div id="h-messages" class="want-mx-auto"></div></header>
16
+ <main id="p-body">
17
+ <h3>Past</h3><dl class="bsp"><dt><strong>Resubmission deadline</strong>: <span class="need-usertime" data-ts="1786751999">Friday Aug 14, 2026, 7:59:59 PM EDT</span></dt><dd>Completed submissions may be updated until this deadline.</dd>
18
+ <dt><strong>Submission deadline</strong>: <span class="need-usertime" data-ts="1775131199">Thursday Apr 2, 2026, 7:59:59 AM EDT</span></dt><dd>Submissions must be completed by this deadline to be reviewed.</dd>
19
+ <dt><strong>Registration deadline</strong>: <span class="need-usertime" data-ts="1774612799">Friday Mar 27, 2026, 7:59:59 AM EDT</span></dt><dd>You can register new submissions until this deadline.</dd></dl>
20
+ <hr class="c"></main></div><footer id="p-footer" class="need-banner-offset banner-bottom"><a class="noq" href="https://hotcrp.com/">HotCRP</a><!-- Version 3.4 --></footer><!--[if lt IE 9]><script nonce="LW8qxXSv09MzquWfF8dAZw==" src="https://static.hotcrp.com/@1749780400/scripts/jquery-1.12.4.min.js" crossorigin="anonymous" integrity="sha256-ZosEbRLbNQzLpnKIkEdrPv7lOy9C27hHQ+Xp8a4MxAQ="></script><![endif]-->
21
+ <![if !IE|gte IE 9]><script nonce="LW8qxXSv09MzquWfF8dAZw==" src="https://static.hotcrp.com/@1749780400/scripts/jquery-3.7.1.min.js" crossorigin="anonymous" integrity="sha256-/JqT3SQfawRcv/BIHPThkBvs0OEvtFFmqPF/lYI/Cxo="></script><![endif]>
22
+ <script nonce="LW8qxXSv09MzquWfF8dAZw==">window.siteinfo={"site_relative":"","base":"\/","suffix":"","assets":"https:\/\/static.hotcrp.com\/","icons_url":"@1787797517\/images\/icons.svg","cookie_params":"; Domain=.hotcrp.com; Secure; SameSite=Lax","postvalue":".empty","download_prefix":"sosp26-","snouns":["submission","submissions","Submission","Submissions"],"user":[]}</script><script nonce="LW8qxXSv09MzquWfF8dAZw==" src="https://static.hotcrp.com/@1790985505/scripts/script.js" crossorigin="anonymous"></script>
23
+ <script nonce="LW8qxXSv09MzquWfF8dAZw==" src="https://static.hotcrp.com/@1777496734/scripts/markdown-it.min.js" crossorigin="anonymous"></script>
24
+ <script nonce="LW8qxXSv09MzquWfF8dAZw==" src="https://static.hotcrp.com/@1787955704/scripts/plugin.js" crossorigin="anonymous"></script>
25
+ <script nonce="LW8qxXSv09MzquWfF8dAZw==">hotcrp.onload.time(240,0);hotcrp.init_deadlines({"now":1791194489.724288,"email":null,"sub":{"open":true,"sub":1775131199,"reg":1774612799,"blind":true},"final":{"open":true,"done":1788004799}})</script></body>
26
+ </html>
@@ -0,0 +1,86 @@
1
+ /**
2
+ * `hotcrpDeadlines` over a local server that answers `GET /deadlines` with a recorded page: the
3
+ * request paperlint sends (the path, no credential) and what comes back through the parser; and a
4
+ * site that does not answer at all.
5
+ */
6
+ import assert from "node:assert/strict";
7
+ import { readFileSync } from "node:fs";
8
+ import {
9
+ createServer,
10
+ type IncomingMessage,
11
+ type Server,
12
+ type ServerResponse,
13
+ } from "node:http";
14
+ import { afterAll, beforeAll, test } from "vitest";
15
+ import { hotcrpDeadlines } from "./deadlines.io.ts";
16
+
17
+ const page = readFileSync(
18
+ new URL("./cassette/deadlines-msr2027.html", import.meta.url),
19
+ "utf8",
20
+ );
21
+
22
+ const seen: { url: string; authorization: string | undefined }[] = [];
23
+ const server = createServer((req: IncomingMessage, res: ServerResponse) => {
24
+ seen.push({ url: req.url ?? "", authorization: req.headers.authorization });
25
+ if (req.url === "/deadlines") {
26
+ res.writeHead(200, { "content-type": "text/html" });
27
+ res.end(page);
28
+ } else {
29
+ res.writeHead(404, { "content-type": "text/html" });
30
+ res.end('<h1 id="h-title">No such conference</h1>');
31
+ }
32
+ });
33
+
34
+ const portOf = (s: Server): number => {
35
+ const a = s.address();
36
+ assert.ok(a !== null && typeof a === "object", "the server is not listening");
37
+ return a.port;
38
+ };
39
+ const closeServer = (s: Server): Promise<void> =>
40
+ new Promise<void>((r) => {
41
+ s.close(() => {
42
+ r();
43
+ });
44
+ });
45
+
46
+ let base = "";
47
+ beforeAll(async () => {
48
+ await new Promise<void>((r) => server.listen(0, "127.0.0.1", r));
49
+ base = `http://127.0.0.1:${String(portOf(server))}`;
50
+ });
51
+ afterAll(() => closeServer(server));
52
+
53
+ test("GET <site>/deadlines, no credential; the page parses to the venue's deadlines", async () => {
54
+ const r = await hotcrpDeadlines({ url: base }).read();
55
+ assert.deepEqual(seen.at(-1), {
56
+ url: "/deadlines",
57
+ authorization: undefined,
58
+ });
59
+ assert.ok(r.ok, JSON.stringify(r));
60
+ assert.equal(r.value.open, true);
61
+ assert.deepEqual(
62
+ r.value.deadlines.map((d) => [d.what, d.at]),
63
+ [
64
+ ["submission", "2026-10-20T04:00:00Z"],
65
+ ["resubmission", "2026-10-23T04:00:00Z"],
66
+ ],
67
+ );
68
+ });
69
+
70
+ test("a site that is not a conference: refused with the page's title", async () => {
71
+ const r = await hotcrpDeadlines({ url: `${base}/nosuch` }).read();
72
+ assert.ok(!r.ok);
73
+ assert.equal(r.error.kind, "refused");
74
+ });
75
+
76
+ test("a site that does not answer is unreachable, with the network's words", async () => {
77
+ const closed = createServer();
78
+ await new Promise<void>((r) => closed.listen(0, "127.0.0.1", r));
79
+ const port = portOf(closed);
80
+ await closeServer(closed);
81
+ const r = await hotcrpDeadlines({
82
+ url: `http://127.0.0.1:${String(port)}`,
83
+ }).read();
84
+ assert.ok(!r.ok);
85
+ assert.equal(r.error.kind, "unreachable");
86
+ });
@@ -0,0 +1,27 @@
1
+ /**
2
+ * HotCRP's deadlines page over Node's `fetch`: `GET <site>/deadlines`, no token — the page is
3
+ * public, and a deadline is nobody's secret.
4
+ */
5
+ import { messageOf } from "../../domain/text.ts";
6
+ import { err } from "../../domain/result.ts";
7
+ import type { VenueDeadlines } from "../../ports/venue-deadlines.ts";
8
+ import { parseDeadlinesPage } from "./deadlines.ts";
9
+
10
+ const READ_TIMEOUT_MS = 60_000;
11
+
12
+ /** The deadlines of the HotCRP site at `url` (no trailing slash). */
13
+ export function hotcrpDeadlines(o: { readonly url: string }): VenueDeadlines {
14
+ return {
15
+ async read() {
16
+ try {
17
+ const r = await fetch(`${o.url}/deadlines`, {
18
+ headers: { Accept: "text/html" },
19
+ signal: AbortSignal.timeout(READ_TIMEOUT_MS),
20
+ });
21
+ return parseDeadlinesPage(r.status, await r.text());
22
+ } catch (e) {
23
+ return err({ kind: "unreachable", detail: messageOf(e) });
24
+ }
25
+ },
26
+ };
27
+ }
@@ -0,0 +1,170 @@
1
+ /**
2
+ * The deadlines page parser on the pages five live HotCRP sites answered on 2026-10-05
3
+ * (`cassette/deadlines-*.html`, recorded whole), and on a site that does not exist. Each expected
4
+ * value was read off the page's text by hand before the parser existed.
5
+ */
6
+ import { readFileSync } from "node:fs";
7
+ import { describe, expect, it } from "vitest";
8
+ import { parseDeadlinesPage, whatOf } from "./index.ts";
9
+
10
+ type Site = "msr2027" | "aidc26" | "sosp26" | "icse2027" | "fse2027" | "nosuch";
11
+ const page = (site: Site): string =>
12
+ readFileSync(
13
+ new URL(`./cassette/deadlines-${site}.html`, import.meta.url),
14
+ "utf8",
15
+ );
16
+
17
+ const read = (site: Site, status = 200) => {
18
+ const r = parseDeadlinesPage(status, page(site));
19
+ if (!r.ok) throw new Error(JSON.stringify(r.error));
20
+ return r.value;
21
+ };
22
+
23
+ describe("parseDeadlinesPage", () => {
24
+ it("a conference with a resubmission deadline: the list carries it, the status object does not", () => {
25
+ expect(read("msr2027")).toEqual({
26
+ now: 1791194488.329003,
27
+ open: true,
28
+ blind: true,
29
+ deadlines: [
30
+ {
31
+ what: "submission",
32
+ label: "Submission deadline",
33
+ at: "2026-10-20T04:00:00Z",
34
+ },
35
+ {
36
+ what: "resubmission",
37
+ label: "Resubmission deadline",
38
+ at: "2026-10-23T04:00:00Z",
39
+ },
40
+ ],
41
+ });
42
+ });
43
+
44
+ it("a workshop past its submission deadline, with a final-version deadline only the status object names", () => {
45
+ expect(read("aidc26")).toEqual({
46
+ now: 1791194489.473936,
47
+ open: true,
48
+ blind: true,
49
+ deadlines: [
50
+ {
51
+ what: "submission",
52
+ label: "Submission deadline",
53
+ at: "2026-10-03T11:59:59Z",
54
+ },
55
+ {
56
+ what: "camera-ready",
57
+ label: "camera-ready",
58
+ at: "2026-10-31T11:59:59Z",
59
+ },
60
+ ],
61
+ });
62
+ });
63
+ });
64
+
65
+ describe("parseDeadlinesPage — every kind at once", () => {
66
+ it("registration, submission, a late resubmission and a final version: sorted by time", () => {
67
+ expect(read("sosp26").deadlines).toEqual([
68
+ {
69
+ what: "registration",
70
+ label: "Registration deadline",
71
+ at: "2026-03-27T11:59:59Z",
72
+ },
73
+ {
74
+ what: "submission",
75
+ label: "Submission deadline",
76
+ at: "2026-04-02T11:59:59Z",
77
+ },
78
+ {
79
+ what: "resubmission",
80
+ label: "Resubmission deadline",
81
+ at: "2026-08-14T23:59:59Z",
82
+ },
83
+ {
84
+ what: "camera-ready",
85
+ label: "camera-ready",
86
+ at: "2026-08-29T11:59:59Z",
87
+ },
88
+ ]);
89
+ });
90
+ });
91
+
92
+ describe("parseDeadlinesPage — a closed round, AoE, and pages that are not deadlines", () => {
93
+ it("a round that is not open: `open` is false and the dates still read", () => {
94
+ const r = read("icse2027");
95
+ expect(r.open).toBe(false);
96
+ expect(r.deadlines.map((d) => [d.what, d.at])).toEqual([
97
+ ["registration", "2026-06-24T11:59:59Z"],
98
+ ["submission", "2026-07-09T11:59:00Z"],
99
+ ]);
100
+ });
101
+ });
102
+
103
+ describe("parseDeadlinesPage — AoE and non-pages", () => {
104
+ it("the AoE a page prints is already an instant in UTC: «Oct 3, 11:59 PM AoE» is Oct 4, 11:59 UTC", () => {
105
+ expect(read("fse2027").deadlines).toEqual([
106
+ {
107
+ what: "submission",
108
+ label: "Submission deadline",
109
+ at: "2026-10-04T11:59:00Z",
110
+ },
111
+ ]);
112
+ });
113
+
114
+ it("a status object with no submission round: closed, blindness unknown, no deadlines", () => {
115
+ expect(
116
+ parseDeadlinesPage(200, 'hotcrp.init_deadlines({"now": 1})'),
117
+ ).toEqual({
118
+ ok: true,
119
+ value: { now: 1, open: false, blind: null, deadlines: [] },
120
+ });
121
+ });
122
+
123
+ it("a refusal without a page title carries no message", () => {
124
+ expect(parseDeadlinesPage(503, "<html>down</html>")).toEqual({
125
+ ok: false,
126
+ error: { kind: "refused", httpStatus: 503, messages: [] },
127
+ });
128
+ });
129
+
130
+ it("a site that does not exist: refused with the page's title", () => {
131
+ expect(parseDeadlinesPage(404, page("nosuch"))).toEqual({
132
+ ok: false,
133
+ error: {
134
+ kind: "refused",
135
+ httpStatus: 404,
136
+ messages: [
137
+ { message: "No such conference", field: null, status: null },
138
+ ],
139
+ },
140
+ });
141
+ });
142
+
143
+ it("a 200 page that is not a deadlines page, and a status object of another shape: malformed", () => {
144
+ const detail: unknown = expect.stringContaining(
145
+ "no `hotcrp.init_deadlines(…)` call",
146
+ );
147
+ expect(parseDeadlinesPage(200, "<html><body>hello</body></html>")).toEqual({
148
+ ok: false,
149
+ error: {
150
+ kind: "malformed",
151
+ httpStatus: 200,
152
+ detail,
153
+ },
154
+ });
155
+ const r = parseDeadlinesPage(200, 'hotcrp.init_deadlines({"sub":1})');
156
+ expect(r.ok ? "parsed" : r.error.kind).toBe("malformed");
157
+ const notJson = parseDeadlinesPage(200, "hotcrp.init_deadlines({now: 1})");
158
+ expect(notJson.ok ? "parsed" : notJson.error.kind).toBe("malformed");
159
+ });
160
+ });
161
+
162
+ describe("whatOf", () => {
163
+ it("names the deadlines paperlint knows by their HotCRP labels, with or without a round name", () => {
164
+ expect(whatOf("Submission deadline")).toBe("submission");
165
+ expect(whatOf("Resubmission deadline")).toBe("resubmission");
166
+ expect(whatOf("Short papers registration deadline")).toBe("registration");
167
+ expect(whatOf("Final version deadline")).toBe("camera-ready");
168
+ expect(whatOf("Response deadline")).toBeNull();
169
+ });
170
+ });
@@ -0,0 +1,161 @@
1
+ /**
2
+ * HotCRP's deadlines page (`<site>/deadlines`), parsed at the boundary into paperlint's
3
+ * `PortalDeadlines`. Pure.
4
+ *
5
+ * ── WHAT THE PAGE IS, measured 2026-10-05 on five live sites (`cassette/deadlines-*.html`) ───────
6
+ * The page is HTML, public, no account needed; the API endpoint of the same name (`/api/deadlines`)
7
+ * answers 401 «Missing credentials» without a token. The machine-readable part is the argument of
8
+ * `hotcrp.init_deadlines({…})` in the page's last script: the author's `status_json()`
9
+ * (kohler/hotcrp, src/contact.php), of which an anonymous visitor gets
10
+ *
11
+ * now the server's clock, Unix seconds with a fraction
12
+ * sub.open whether the submission round is open now
13
+ * sub.sub the submission deadline, Unix seconds (SubmissionRound::$submit)
14
+ * sub.reg the registration deadline, when it is earlier (SubmissionRound::$register)
15
+ * sub.blind true | "optional" | "until-review" (Conf::submission_blindness)
16
+ * final.done the final-version (camera-ready) deadline (final_deadline_for_display)
17
+ *
18
+ * The RESUBMISSION deadline (updates allowed until, `SubmissionRound::$resubmit`) is not in that
19
+ * object; the page lists it only in its `<dl>`, each entry a `<dt>` with the deadline's label and a
20
+ * `<span data-ts="…">` holding the same Unix seconds (pages/p_deadlines.php, `dl1`). So both are
21
+ * read: the JSON for what it names, the list for every deadline the page shows, matched by label.
22
+ */
23
+ import { z } from "zod";
24
+ import {
25
+ instantOfUnix,
26
+ type DeadlineWhat,
27
+ type PortalDeadline,
28
+ type PortalDeadlines,
29
+ } from "../../domain/deadline.ts";
30
+ import { err, ok, type Result } from "../../domain/result.ts";
31
+ import type { PortalFailure } from "../../domain/submission.ts";
32
+
33
+ const Blind = z.union([
34
+ z.boolean(),
35
+ z.literal("optional"),
36
+ z.literal("until-review"),
37
+ ]);
38
+ const Status = z.looseObject({
39
+ now: z.number(),
40
+ sub: z
41
+ .looseObject({
42
+ open: z.boolean().optional(),
43
+ sub: z.number().optional(),
44
+ reg: z.number().optional(),
45
+ blind: Blind.optional(),
46
+ })
47
+ .optional(),
48
+ final: z.looseObject({ done: z.number().optional() }).optional(),
49
+ });
50
+
51
+ /** The script call that carries the status object; the argument is JSON. */
52
+ const INIT = /hotcrp\.init_deadlines\((\{.*?\})\)/s;
53
+ /** One listed deadline: its label in `<strong>`, its time in `data-ts`. */
54
+ const ENTRY = /<dt><strong>([^<]*)<\/strong>:\s*<span[^>]*\bdata-ts="(\d+)"/g;
55
+ /** The page's title, for a page that is no deadlines page (a site that does not exist). */
56
+ const TITLE = /<h1 id="h-title">([^<]*)</;
57
+
58
+ /** What a listed deadline's label names, in paperlint's words; null for one paperlint does not name. */
59
+ export function whatOf(label: string): DeadlineWhat | null {
60
+ const l = label.toLowerCase();
61
+ if (/\bresubmission deadline\b/.test(l)) return "resubmission";
62
+ if (/\bregistration deadline\b/.test(l)) return "registration";
63
+ if (/\bsubmission deadline\b/.test(l)) return "submission";
64
+ if (/\bfinal\b|\bcamera[- ]ready\b/.test(l)) return "camera-ready";
65
+ return null;
66
+ }
67
+
68
+ /** The deadlines the status object names, each with the label the page would give it. */
69
+ type StatusJson = Readonly<z.infer<typeof Status>>;
70
+
71
+ function fromStatus(s: StatusJson): readonly PortalDeadline[] {
72
+ const named: readonly (readonly [DeadlineWhat, number | undefined])[] = [
73
+ ["registration", s.sub?.reg],
74
+ ["submission", s.sub?.sub],
75
+ ["camera-ready", s.final?.done],
76
+ ];
77
+ return named.flatMap(([what, at]) =>
78
+ at === undefined ? [] : [{ what, label: what, at: instantOfUnix(at) }],
79
+ );
80
+ }
81
+
82
+ /** Every deadline the page lists, with its label as the page spells it. */
83
+ function fromList(html: string): readonly PortalDeadline[] {
84
+ return [...html.matchAll(ENTRY)].map((m) => {
85
+ // Both groups always participate in a match of ENTRY: there is no fallback to take.
86
+ const label = String(m[1]).trim();
87
+ return { what: whatOf(label), label, at: instantOfUnix(Number(m[2])) };
88
+ });
89
+ }
90
+
91
+ /** Listed entries first (they carry the page's label); a status entry only when the list lacks its kind. */
92
+ function merged(
93
+ listed: readonly PortalDeadline[],
94
+ named: readonly PortalDeadline[],
95
+ ): readonly PortalDeadline[] {
96
+ const extra = named.filter(
97
+ (n) => !listed.some((l) => l.what === n.what && l.at === n.at),
98
+ );
99
+ return [...listed, ...extra].toSorted((a, b) => a.at.localeCompare(b.at));
100
+ }
101
+
102
+ const refused = (httpStatus: number, html: string): PortalFailure => {
103
+ const title = TITLE.exec(html)?.[1]?.trim();
104
+ return {
105
+ kind: "refused",
106
+ httpStatus,
107
+ messages:
108
+ title === undefined
109
+ ? []
110
+ : [{ message: title, field: null, status: null }],
111
+ };
112
+ };
113
+
114
+ /** The status object in the page's `init_deadlines` call, or why there is none. */
115
+ function statusOf(
116
+ httpStatus: number,
117
+ html: string,
118
+ ): Result<StatusJson, PortalFailure> {
119
+ const m = INIT.exec(html);
120
+ if (m === null)
121
+ return err({
122
+ kind: "malformed",
123
+ httpStatus,
124
+ detail:
125
+ "the page has no `hotcrp.init_deadlines(…)` call — not a HotCRP deadlines page",
126
+ });
127
+ const json = ((): unknown => {
128
+ try {
129
+ return JSON.parse(String(m[1])); // the group always participates in a match of INIT
130
+ } catch {
131
+ return undefined;
132
+ }
133
+ })();
134
+ const parsed = Status.safeParse(json);
135
+ return parsed.success
136
+ ? ok(parsed.data)
137
+ : err({
138
+ kind: "malformed",
139
+ httpStatus,
140
+ detail:
141
+ "the `init_deadlines` argument is not the status object HotCRP documents",
142
+ });
143
+ }
144
+
145
+ /** `GET /deadlines` → the portal's deadlines, or why the page is not them. */
146
+ export function parseDeadlinesPage(
147
+ httpStatus: number,
148
+ html: string,
149
+ ): Result<PortalDeadlines, PortalFailure> {
150
+ if (httpStatus < 200 || httpStatus >= 300)
151
+ return err(refused(httpStatus, html));
152
+ const status = statusOf(httpStatus, html);
153
+ if (!status.ok) return status;
154
+ const s = status.value;
155
+ return ok({
156
+ now: s.now,
157
+ open: s.sub?.open ?? false,
158
+ blind: s.sub?.blind ?? null,
159
+ deadlines: merged(fromList(html), fromStatus(s)),
160
+ });
161
+ }
@@ -9,3 +9,5 @@ export {
9
9
  type HotcrpOptions,
10
10
  } from "./portal.io.ts";
11
11
  export { parseShow, parseUpdate, sha256OfHash } from "./response.ts";
12
+ export { hotcrpDeadlines } from "./deadlines.io.ts";
13
+ export { parseDeadlinesPage, whatOf } from "./deadlines.ts";
package/src/cli.test.ts CHANGED
@@ -344,6 +344,15 @@ test("no command prints the usage and exits 2; --kind with no value is refused",
344
344
  assert.equal(parseArgs(["new", "x", "--kind"]).missingValue, "--kind");
345
345
  });
346
346
 
347
+ test("--help names the cycles and where a deadline comes from — the only place a newcomer looks first", async () => {
348
+ const r = await cli(["--help"], root);
349
+ assert.equal(r.code, 0);
350
+ assert.match(r.out, /"cycles": \[ \{ "id"/);
351
+ assert.match(r.out, /A venue preset carries the portal's deadlines/);
352
+ assert.match(r.out, /"override": \{ "at", "reason", "evidence" \}/);
353
+ assert.match(r.out, /the earlier reading binds/);
354
+ });
355
+
347
356
  test("src/cli.ts run as the program itself answers like the bin", () => {
348
357
  const r = runNode(join(import.meta.dirname, "cli.ts"), ["--help"]);
349
358
  assert.equal(r.status, 0);
package/src/cli.ts CHANGED
@@ -42,7 +42,12 @@ import {
42
42
  asEslintResults,
43
43
  } from "./structure.ts";
44
44
  import { buildPapers, papersIn, anyFailed, remedyFor, MAIN } from "./build.ts";
45
- import { includeBlocks, paperBodies, unreadLines } from "./paper-includes.ts";
45
+ import {
46
+ FRAGMENT_FILES,
47
+ includeBlocks,
48
+ paperBodies,
49
+ unreadLines,
50
+ } from "./paper-includes.ts";
46
51
  import { prepareEngine } from "./build-engine.ts";
47
52
  import { cacheRoot, cachedTree, runToolchain } from "./toolchain.ts";
48
53
  import { banalInstaller, parseBanalSettings } from "./adapters/banal/index.ts";
@@ -69,6 +74,8 @@ import {
69
74
  import {
70
75
  FORMAT_RULE_LEVELS,
71
76
  formatRules,
77
+ CYCLE_PORT_RULE_LEVELS,
78
+ cyclePortRules,
72
79
  TEX_VENUE_RULE_LEVELS,
73
80
  texVenueRules,
74
81
  } from "./tex-venue-rules.ts";
@@ -159,6 +166,8 @@ export { init };
159
166
  export { nextSteps } from "./init.ts";
160
167
 
161
168
  import paperStages from "#eslint-rules/paper-stages";
169
+ import cycleStage from "#eslint-rules/cycle-stage";
170
+ import { CYCLE_RULE_LEVELS, cycleRules } from "./cycle-rules.ts";
162
171
  import {
163
172
  FOLDER_VENUE_RULE_LEVELS,
164
173
  folderVenueRules,
@@ -275,6 +284,24 @@ settings — paperlint.json, at two levels, one schema. Both are optional.
275
284
  "extends" names a venue preset: paperlint:<name> (shipped: ${SHIPPED_VENUES().join(", ")})
276
285
  or ./path.jsonc, relative to the paperlint.json. npm presets are not supported yet.
277
286
 
287
+ cycles and deadlines — a paper's attempts at venues, oldest first, instead of "extends",
288
+ "kind" and "submission" (those are then the current attempt's, and refused beside "cycles"):
289
+
290
+ { "cycles": [ { "id": "msr-2027", "venue": { "kind": "preset", "extends": "paperlint:msr" },
291
+ "kind": "technical", "opened": "2026-10-05",
292
+ "deadlines": [ { "what": "submission",
293
+ "observed": [ { "at": "2026-10-23 AoE", "source": "call",
294
+ "url": "<the call>", "read": "2026-10-05" } ] } ] } ] }
295
+
296
+ A venue preset carries the portal's deadlines, read and dated; the package updates them and
297
+ your dependency update brings them. A paper adds what the call says ("source": "call") and,
298
+ for a deadline moved by the chairs, "override": { "at", "reason", "evidence" } — evidence a
299
+ file in the paper folder. The override wins; otherwise the earlier reading binds. "what":
300
+ registration (the call's abstract), submission (the full paper), resubmission, notification,
301
+ camera-ready. A venue without a preset: "venue": { "kind": "named", "name", "url" }, every
302
+ deadline typed from its call. Lint never reads the clock: it checks the record, not the time
303
+ left. docs/configuration.md#the-papers-cycles
304
+
278
305
  "rules" is { "<rule>": "<severity>" } for every paper file in scope, or ESLint flat-config
279
306
  blocks (files, ignores, rules) with globs relative to that file. Order, later wins: paperlint's
280
307
  own, the venue preset's, the root file's, the paper's. Optional rules (off unless turned on):
@@ -298,6 +325,22 @@ const REGISTER_AND_CASE_RULES = {
298
325
  "heading-case": headingCaseRule(TEX_RULE_DEPS),
299
326
  };
300
327
 
328
+ /**
329
+ * The paper's attempts (`cycles` in paperlint.json), judged on `PIPELINE-STATUS.md`: the record itself
330
+ * and its evidence (src/cycle-rules.ts), and the stages that name a cycle (eslint-rules/cycle-stage.ts).
331
+ * The `cycle` plugin's other rule, `port-done`, reads `paper.tex` (src/tex-venue-rules.ts).
332
+ */
333
+ /** The markdown language with YAML frontmatter: what every markdown block of the config lints as. */
334
+ const MARKDOWN_WITH_FRONTMATTER = {
335
+ language: "markdown/gfm",
336
+ languageOptions: { frontmatter: "yaml" },
337
+ };
338
+
339
+ const CYCLE_STATUS_RULES = {
340
+ ...cycleRules({ files: nodeFiles, venuesDir: presetsDir() }),
341
+ ...cycleStage.rules,
342
+ };
343
+
301
344
  /** The rules of the files `paper.tex` includes, each read on its own as a fragment (#144). */
302
345
  const FRAGMENT_RULES = {
303
346
  "future-promise": texBuild["future-promise"],
@@ -328,10 +371,7 @@ export function buildConfig(
328
371
  "paper/section-word": "warn",
329
372
  "paper/leading-zero": "warn",
330
373
  };
331
- const md = {
332
- language: "markdown/gfm",
333
- languageOptions: { frontmatter: "yaml" },
334
- };
374
+ const md = MARKDOWN_WITH_FRONTMATTER;
335
375
 
336
376
  const cfg: ConfigBlock[] = [
337
377
  // 🔴 THE PROJECT'S PAPER TEMPLATE IS NOT A PAPER. `paperlint new` reads `<papers>/.template/`, and
@@ -369,12 +409,14 @@ export function buildConfig(
369
409
  ...folderVenueRules({ files: nodeFiles, venuesDir: presetsDir() }),
370
410
  },
371
411
  },
412
+ cycle: { rules: CYCLE_STATUS_RULES },
372
413
  },
373
414
  ...md,
374
415
  rules: {
375
416
  "paper/stages": "error",
376
417
  "paper/source": "error",
377
418
  ...FOLDER_VENUE_RULE_LEVELS,
419
+ ...CYCLE_RULE_LEVELS,
378
420
  },
379
421
  },
380
422
  {
@@ -427,6 +469,7 @@ export function buildConfig(
427
469
  ...pageLimitRules({ files: nodeFiles, venuesDir: presetsDir() }),
428
470
  },
429
471
  },
472
+ cycle: { rules: cyclePortRules(TEX_RULE_DEPS) },
430
473
  },
431
474
  language: "tex/latex",
432
475
  rules: {
@@ -449,6 +492,7 @@ export function buildConfig(
449
492
  ...TEX_VENUE_RULE_LEVELS,
450
493
  ...FORMAT_RULE_LEVELS,
451
494
  ...PAGE_LIMIT_RULE_LEVELS,
495
+ ...CYCLE_PORT_RULE_LEVELS,
452
496
  // Silent for a paper that declares no `talk` (src/talk-rules.ts), but talk/undeclared.
453
497
  ...TALK_RULE_LEVELS,
454
498
  },
@@ -462,7 +506,7 @@ export function buildConfig(
462
506
  const fragments: readonly ConfigBlock[] = texLanguage
463
507
  ? [
464
508
  {
465
- files: ["**/*.tex"],
509
+ files: [...FRAGMENT_FILES],
466
510
  ignores: ["**/paper.tex"],
467
511
  plugins: {
468
512
  tex: {