paperlint 10.4.1 → 11.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 (131) hide show
  1. package/.github/workflows/deadlines.yml +27 -7
  2. package/README.md +16 -15
  3. package/dist/adapters/hotcrp/index.d.ts +6 -4
  4. package/dist/adapters/hotcrp/index.d.ts.map +1 -1
  5. package/dist/adapters/hotcrp/index.js +6 -4
  6. package/dist/adapters/hotcrp/index.js.map +1 -1
  7. package/dist/adapters/hotcrp/portal.io.d.ts +3 -1
  8. package/dist/adapters/hotcrp/portal.io.d.ts.map +1 -1
  9. package/dist/adapters/hotcrp/portal.io.js +36 -11
  10. package/dist/adapters/hotcrp/portal.io.js.map +1 -1
  11. package/dist/adapters/hotcrp/response.d.ts +6 -1
  12. package/dist/adapters/hotcrp/response.d.ts.map +1 -1
  13. package/dist/adapters/hotcrp/response.js +17 -0
  14. package/dist/adapters/hotcrp/response.js.map +1 -1
  15. package/dist/adapters/hotcrp/site.d.ts +9 -0
  16. package/dist/adapters/hotcrp/site.d.ts.map +1 -0
  17. package/dist/adapters/hotcrp/site.js +22 -0
  18. package/dist/adapters/hotcrp/site.js.map +1 -0
  19. package/dist/adapters/hotcrp/token.d.ts +13 -0
  20. package/dist/adapters/hotcrp/token.d.ts.map +1 -0
  21. package/dist/adapters/hotcrp/token.js +48 -0
  22. package/dist/adapters/hotcrp/token.js.map +1 -0
  23. package/dist/adapters/latex/abstract.d.ts +22 -0
  24. package/dist/adapters/latex/abstract.d.ts.map +1 -0
  25. package/dist/adapters/latex/abstract.js +187 -0
  26. package/dist/adapters/latex/abstract.js.map +1 -0
  27. package/dist/adapters/latex/headings.d.ts +6 -0
  28. package/dist/adapters/latex/headings.d.ts.map +1 -1
  29. package/dist/adapters/latex/headings.js +5 -4
  30. package/dist/adapters/latex/headings.js.map +1 -1
  31. package/dist/adapters/latex/index.d.ts.map +1 -1
  32. package/dist/adapters/latex/index.js +2 -0
  33. package/dist/adapters/latex/index.js.map +1 -1
  34. package/dist/adapters/latex/parse.d.ts +15 -0
  35. package/dist/adapters/latex/parse.d.ts.map +1 -1
  36. package/dist/adapters/latex/parse.js +58 -1
  37. package/dist/adapters/latex/parse.js.map +1 -1
  38. package/dist/cli.d.ts.map +1 -1
  39. package/dist/cli.js +20 -8
  40. package/dist/cli.js.map +1 -1
  41. package/dist/cycle-rules.d.ts.map +1 -1
  42. package/dist/cycle-rules.js +3 -4
  43. package/dist/cycle-rules.js.map +1 -1
  44. package/dist/domain/deadline.d.ts +8 -1
  45. package/dist/domain/deadline.d.ts.map +1 -1
  46. package/dist/domain/deadline.js +26 -12
  47. package/dist/domain/deadline.js.map +1 -1
  48. package/dist/domain/portal-token.d.ts +44 -0
  49. package/dist/domain/portal-token.d.ts.map +1 -0
  50. package/dist/domain/portal-token.js +32 -0
  51. package/dist/domain/portal-token.js.map +1 -0
  52. package/dist/domain/submission.d.ts +30 -0
  53. package/dist/domain/submission.d.ts.map +1 -1
  54. package/dist/domain/submission.js +68 -0
  55. package/dist/domain/submission.js.map +1 -1
  56. package/dist/lib/paper-config.d.ts +1 -1
  57. package/dist/lib/paper-config.d.ts.map +1 -1
  58. package/dist/lib/paper-config.js +6 -4
  59. package/dist/lib/paper-config.js.map +1 -1
  60. package/dist/paper-settings.d.ts +3 -1
  61. package/dist/paper-settings.d.ts.map +1 -1
  62. package/dist/paper-settings.js +11 -5
  63. package/dist/paper-settings.js.map +1 -1
  64. package/dist/ports/latex.d.ts +23 -0
  65. package/dist/ports/latex.d.ts.map +1 -1
  66. package/dist/ports/submission-portal.d.ts +39 -2
  67. package/dist/ports/submission-portal.d.ts.map +1 -1
  68. package/dist/ports/venue-deadlines.d.ts +1 -1
  69. package/dist/submission.d.ts +49 -11
  70. package/dist/submission.d.ts.map +1 -1
  71. package/dist/submission.js +332 -44
  72. package/dist/submission.js.map +1 -1
  73. package/docs/configuration.md +2 -1
  74. package/docs/rules/cycle/record.md +9 -6
  75. package/docs/submission.md +47 -48
  76. package/eslint.config.mjs +3 -0
  77. package/lib/paper-config.ts +7 -4
  78. package/package.json +6 -1
  79. package/presets/msr.jsonc +9 -7
  80. package/presets/msr.md +14 -10
  81. package/scripts/check.ts +1 -1
  82. package/scripts/refresh-deadlines/compare.test.ts +75 -0
  83. package/scripts/refresh-deadlines/compare.ts +101 -0
  84. package/scripts/refresh-deadlines/main.test.ts +140 -0
  85. package/scripts/refresh-deadlines/main.ts +56 -0
  86. package/scripts/refresh-deadlines/refresh.test.ts +287 -0
  87. package/scripts/refresh-deadlines/refresh.ts +183 -0
  88. package/skills/submission-portal/SKILL.md +60 -44
  89. package/skills/submission-portal/SKILL.md.spec.ts +62 -43
  90. package/skills/submission-portal/submission-portal.test.ts +4 -73
  91. package/src/adapters/hotcrp/cassette/README.md +4 -4
  92. package/src/adapters/hotcrp/cassette/deadlines-msr2027.html +9 -9
  93. package/src/adapters/hotcrp/deadlines.io.test.ts +2 -2
  94. package/src/adapters/hotcrp/deadlines.test.ts +3 -3
  95. package/src/adapters/hotcrp/index.ts +15 -3
  96. package/src/adapters/hotcrp/portal.io.ts +61 -17
  97. package/src/adapters/hotcrp/portal.test.ts +105 -1
  98. package/src/adapters/hotcrp/response.ts +21 -0
  99. package/src/adapters/hotcrp/site.ts +34 -0
  100. package/src/adapters/hotcrp/token.test.ts +69 -0
  101. package/src/adapters/hotcrp/token.ts +51 -0
  102. package/src/adapters/latex/abstract.test.ts +280 -0
  103. package/src/adapters/latex/abstract.ts +272 -0
  104. package/src/adapters/latex/headings.ts +5 -4
  105. package/src/adapters/latex/index.test.ts +31 -1
  106. package/src/adapters/latex/index.ts +2 -0
  107. package/src/adapters/latex/parse.ts +64 -3
  108. package/src/cli.test.ts +20 -8
  109. package/src/cli.ts +22 -13
  110. package/src/cycle-rules.test.ts +6 -6
  111. package/src/cycle-rules.ts +7 -4
  112. package/src/domain/deadline.test.ts +31 -2
  113. package/src/domain/deadline.ts +42 -16
  114. package/src/domain/portal-token.test.ts +55 -0
  115. package/src/domain/portal-token.ts +63 -0
  116. package/src/domain/submission.test.ts +88 -0
  117. package/src/domain/submission.ts +99 -0
  118. package/src/new-paper.test.ts +1 -0
  119. package/src/paper-settings.test.ts +20 -0
  120. package/src/paper-settings.ts +17 -5
  121. package/src/ports/latex.ts +25 -0
  122. package/src/ports/submission-portal.ts +43 -1
  123. package/src/ports/venue-deadlines.ts +1 -1
  124. package/src/presets.test.ts +8 -5
  125. package/src/submission-new.test.ts +569 -0
  126. package/src/submission-show.test.ts +210 -0
  127. package/src/submission-update.test.ts +160 -0
  128. package/src/submission.test.ts +140 -518
  129. package/src/submission.ts +494 -61
  130. package/scripts/refresh-deadlines.test.ts +0 -431
  131. package/scripts/refresh-deadlines.ts +0 -387
@@ -1,73 +1,72 @@
1
1
  # The submission on the venue's portal
2
2
 
3
- `paperlint submission` reads and updates a paper's submission on the venue's submission portal,
4
- through the portal's API. Only [HotCRP](https://github.com/kohler/hotcrp) is supported.
3
+ `paperlint submission` creates, reads and updates a paper's submission on the venue's submission
4
+ portal. Only [HotCRP](https://github.com/kohler/hotcrp) is supported; any other portal is refused
5
+ with `portal kind "<kind>" is not supported; upload by hand`.
5
6
 
6
7
  ```sh
8
+ npx paperlint submission new papers/my-paper # a dry run: the portal checks it and keeps nothing
9
+ npx paperlint submission new papers/my-paper --save # creates the real submission
7
10
  npx paperlint submission show papers/my-paper
8
- npx paperlint submission update papers/my-paper # a dry run: nothing is saved
9
- npx paperlint submission update papers/my-paper --save # changes the real submission
11
+ npx paperlint submission update papers/my-paper # a dry run
12
+ npx paperlint submission update papers/my-paper --save # changes the real submission
10
13
  ```
11
14
 
12
15
  ## What it needs
13
16
 
14
- Two keys, each in the file that owns the fact, and a token:
17
+ | where | key | what it says |
18
+ | ---------------------------- | ---------------------------------------------------- | --------------------------------------------------------------------- |
19
+ | the venue preset | `"portal": { "kind": "hotcrp", "url": "https://…" }` | where the venue takes submissions (`paperlint:aidc`, `paperlint:msr`) |
20
+ | the paper's `paperlint.json` | `"authors": [{ "name", "email", "affiliation"? }]` | for `new`: every author, in the paper's order — never guessed |
21
+ | the paper's `paperlint.json` | `"submission": { "id": 7 }` | which submission is this paper's — `new --save` writes it |
22
+ | the environment | `HOTCRP_TOKEN_<SITE>`, e.g. `HOTCRP_TOKEN_MSR2027` | your API token for that site |
15
23
 
16
- | where | key | what it says |
17
- | ---------------------------- | ---------------------------------------------------- | ----------------------------------------------- |
18
- | the venue preset | `"portal": { "kind": "hotcrp", "url": "https://…" }` | where the venue takes submissions |
19
- | the paper's `paperlint.json` | `"submission": { "id": 7 }` | which submission on that portal is this paper's |
20
- | the environment | `HOTCRP_TOKEN` | an API token for your HotCRP account |
24
+ **The token** works on the site it was made on only, so each site has its own variable:
25
+ `HOTCRP_TOKEN_MSR2027` for `msr2027.hotcrp.com`; for a site not on hotcrp.com, its whole host
26
+ (with a port other than the default) and path, every other character becoming `_`, so two paths
27
+ on one host that differ only in punctuation (`/conf-a`, `/conf/a`) share one variable. A plain
28
+ `HOTCRP_TOKEN` is not read. When the token is missing, misnamed or refused, the command prints
29
+ where on that site to make one, which permissions to tick, and the exact variable to set. Set it
30
+ in your environment — never paste it into a chat; paperlint never prints it.
21
31
 
22
- `paperlint:aidc` and `paperlint:msr` declare their portals. For another venue, add `portal` to your own preset (see
23
- [`rules.md`](rules.md)) — or upload by hand: a kind paperlint has no adapter for is refused with
24
- `portal kind "<kind>" is not supported; upload by hand`.
32
+ A missing key or token stops the command with exit 2 and a line naming what to set.
25
33
 
26
- ```jsonc
27
- // papers/my-paper/paperlint.json
28
- { "extends": "paperlint:aidc", "kind": "regular", "submission": { "id": 7 } }
29
- ```
34
+ ## `new`
30
35
 
31
- **The token** is read only from `HOTCRP_TOKEN`. Create it on the HotCRP site under **Account
32
- settings → Developer**; `show` needs the scopes `submeta:read` and `document:read`, `update` also
33
- `submeta:write` and `document:write`. paperlint sends it as `Authorization: bearer <token>` and
34
- never prints it or writes it anywhere.
36
+ Creates the submission and sends four things, printing them first — the abstract in full:
35
37
 
36
- A missing key or token stops the command with exit 2 and a line naming the file and key, or the
37
- variable, to set.
38
+ - **the title** — the paper's `\title`; when it holds a command of your own, the PDF's title;
39
+ - **the abstract** — `\begin{abstract}…\end{abstract}` as plain text: your argument-less macros
40
+ expanded, dashes and quotes as TeX sets them, citations and comments dropped. Anything it cannot
41
+ read without guessing stops the command with the macro and its line; then pass
42
+ `--abstract <file>`, plain text;
43
+ - **the authors** — from `"authors"`; without it, `new` stops and names the key;
44
+ - **the PDF** — the paper's own, or `--pdf <file>`.
38
45
 
39
- ## `show`
46
+ Without `--save` it is a dry run. With `--save` the new id is written into `paperlint.json` (the
47
+ open cycle, with `cycles`), and the next command is `show`. Every run prints what is left to do on
48
+ the site (conflicts, topics, the venue's own fields). `new` is refused when the paper already has a
49
+ submission id.
40
50
 
41
- Prints the submission's status (and when it was submitted), its id, title, paper type, topics, the
42
- abstract's length in words, and the PDF the portal holds — its hash, size and upload time — then
43
- whether that PDF is the local one: HotCRP stores a PDF's sha256 as `sha2-<hex>`, and paperlint
44
- compares it with the sha256 of the paper's `paper.pdf` (or its `pdf` setting, or `--pdf <file>`).
45
- `match yes` means the portal holds exactly the file you built. Exit 0 whenever the portal answered.
51
+ ## `show`
46
52
 
47
- Then one `deadline` line per deadline in force for the current attempt — the venue preset's portal
48
- readings with the paper's own call readings and overrides over them ([Deadlines](configuration.md#deadlines)):
49
- the instant that binds, what set it (a reading and the day it was read, or an override with its
50
- reason and evidence), and the other readings of it. Instants only; how long is left is not printed.
53
+ Prints the submission's status, title, type, topics, abstract length and the PDF the portal holds,
54
+ then `match`: `yes` means the portal holds exactly the PDF you built (compared by sha256). Then the
55
+ deadlines in force for the current attempt, each with when and where it was read
56
+ ([Deadlines](configuration.md#deadlines)). Exit 0 whenever the portal answered.
51
57
 
52
58
  ## `update`
53
59
 
54
- Sends the PDF — the paper's own unless `--pdf <file>` names another — and, when asked, a new
55
- abstract (`--abstract <file>`, plain text) and the status "submitted" (`--submit`).
56
-
57
- **Without `--save` it is a dry run**: HotCRP's `dry_run=1` checks the change and keeps nothing. With
58
- `--save` the real submission changes. Either way it prints HotCRP's verdict — `valid`, the fields the
59
- change touched (`change_list`), HotCRP's messages — and the local file's sha256. Exit 1 when the
60
- change is not valid or the HTTP status is not 2xx.
61
-
62
- Before it sends anything, dry run or not, it prints one line asking you to open that exact PDF and
63
- check it against the venue's call for papers — named by the preset's `url` when the preset is a
64
- venue: what paperlint checks is an aid, and the venue's reading of the PDF is what counts.
60
+ Sends the PDF and, when asked, a new abstract (`--abstract <file>`) and "submitted" (`--submit`).
61
+ Without `--save` it is a dry run. It prints the portal's verdict and its messages; exit 1 when the
62
+ portal would not take the change. Before sending, it asks you to open that exact PDF and check it
63
+ against the venue's call for papers.
65
64
 
66
- The safe order: `show`, `update` (dry run), read what it says, `update --save`, then `show` again and
67
- check `match yes`.
65
+ The safe order: `new`, `new --save` once; then `show`, `update`, `update --save`, and `show` again
66
+ until it says `match yes`. Every `--save` comes after the author has read the dry run and said yes.
68
67
 
69
68
  ## What it does not do
70
69
 
71
- - It does not create a submission, edit authors, topics or other form fields, or upload
72
- supplementary files.
70
+ - It does not set conflicts, topics or the venue's other fields, change the authors after `new`, or
71
+ upload supplementary files.
73
72
  - It does not check the PDF against the venue; that is `paperlint lint`.
package/eslint.config.mjs CHANGED
@@ -225,7 +225,10 @@ export const TOOL_PACKAGES = [
225
225
  "@eslint/plugin-kit",
226
226
  "@retorquere/bibtex-parser", // BibTeX
227
227
  "@unified-latex/unified-latex-types", // LaTeX
228
+ "@unified-latex/unified-latex-util-ligatures",
229
+ "@unified-latex/unified-latex-util-macros",
228
230
  "@unified-latex/unified-latex-util-parse",
231
+ "@unified-latex/unified-latex-util-replace",
229
232
  "@unified-latex/unified-latex-util-visit",
230
233
  "ajv", // JSON Schema
231
234
  "eslint",
@@ -138,11 +138,13 @@ export const SETTINGS_KEYS: Readonly<Record<string, string>> = Object.freeze({
138
138
  pdf: "where the built PDF is, relative to the paper, when it is not paper.pdf — the facts",
139
139
  identity:
140
140
  "what identifies the authors — names, handles, emails, affiliations, own project names; a blind venue's PDF must not say any of them — anonymity/identity. The root's list and a paper's are joined",
141
- talk: 'how this paper is presented: { "mode": "remote-video", "dir"?, "files"?, "one_slide"? } — the talk/ rules. A paper\'s own; refused in the root file',
141
+ talk: 'how this paper is presented: { "mode": "remote-video", "dir"?, "files"?, "one_slide"? } — the talk/ rules. Set only in a paper\'s own paperlint.json; in the root paperlint.json it is an error',
142
142
  submission:
143
- "which submission on the venue's portal is this paper's: { \"id\": <number> } — paperlint submission, with the preset's \"portal\". A paper's own; refused in the root file",
143
+ "which submission on the venue's portal is this paper's: { \"id\": <number> } — paperlint submission, with the preset's \"portal\". Set only in a paper's own paperlint.json; in the root paperlint.json it is an error",
144
+ authors:
145
+ 'the authors as the venue\'s portal should record them, in order: [{ "name", "email", "affiliation"? }] — paperlint submission new. Set only in a paper\'s own paperlint.json; in the root paperlint.json it is an error',
144
146
  cycles:
145
- "the paper's attempts at venues, oldest first: [{ id, venue, kind, opened, phase?, deadlines, submission?, outcome }] — extends, kind and submission are then the current attempt's, and the cycle/ rules judge the record. A paper's own; refused in the root file",
147
+ "the paper's attempts at venues, oldest first: [{ id, venue, kind, opened, phase?, deadlines, submission?, outcome }] — extends, kind and submission are then the current attempt's, and the cycle/ rules judge the record. Set only in a paper's own paperlint.json; in the root paperlint.json it is an error",
146
148
  scripts: "how skill prose names the scripts — consumer.mjs",
147
149
  timezone: "the zone deadline anchors are written in — consumer.mjs",
148
150
  contactEmail: "the polite-pool address for citation lookups — consumer.mjs",
@@ -151,10 +153,11 @@ export const SETTINGS_KEYS: Readonly<Record<string, string>> = Object.freeze({
151
153
  $comment: "a note for humans (JSON Schema's own comment keyword); ignored",
152
154
  });
153
155
 
154
- /** The keys that only mean something for one paper: refused in the root `paperlint.json`. */
156
+ /** The keys that only mean something for one paper: an error in the root `paperlint.json`. */
155
157
  export const PAPER_ONLY_KEYS: readonly string[] = Object.freeze([
156
158
  "talk",
157
159
  "submission",
160
+ "authors",
158
161
  "cycles",
159
162
  ]);
160
163
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "paperlint",
3
- "version": "10.4.1",
3
+ "version": "11.0.0",
4
4
  "description": "A linter for scientific papers written in LaTeX: catches mistakes before you submit to a conference or journal.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -15,6 +15,7 @@
15
15
  "coverage": "NODE_OPTIONS=\"--import=./test/coverage-src.ts $NODE_OPTIONS\" c8 --check-coverage npm test --",
16
16
  "lint": "eslint .",
17
17
  "check": "node scripts/check.ts",
18
+ "refresh-deadlines": "node --conditions=paperlint-source scripts/refresh-deadlines/main.ts",
18
19
  "fmt": "prettier --write .",
19
20
  "fmt:check": "prettier --check ."
20
21
  },
@@ -23,7 +24,10 @@
23
24
  "@eslint/plugin-kit": "^0.7.2",
24
25
  "@retorquere/bibtex-parser": "^10.0.1",
25
26
  "@unified-latex/unified-latex-types": "^1.8.4",
27
+ "@unified-latex/unified-latex-util-ligatures": "^1.8.4",
28
+ "@unified-latex/unified-latex-util-macros": "^1.8.4",
26
29
  "@unified-latex/unified-latex-util-parse": "^1.8.4",
30
+ "@unified-latex/unified-latex-util-replace": "^1.8.4",
27
31
  "@unified-latex/unified-latex-util-visit": "^1.8.4",
28
32
  "ajv": "^6.12.6",
29
33
  "eslint": "^10.9.1",
@@ -52,6 +56,7 @@
52
56
  "eslint-plugin-n": "^18.3.0",
53
57
  "eslint-plugin-project-structure": "^3.14.5",
54
58
  "eslint-plugin-sonarjs": "^4.2.1",
59
+ "jsonc-parser": "^3.3.1",
55
60
  "prettier": "^3.8.1",
56
61
  "semantic-release": "25.0.9",
57
62
  "typescript-eslint": "^8.70.1",
package/presets/msr.jsonc CHANGED
@@ -38,23 +38,25 @@
38
38
  "portal": { "kind": "hotcrp", "url": "https://msr2027.hotcrp.com" },
39
39
 
40
40
  // Read from the portal's public deadlines page (no account) by the adapter in src/adapters/hotcrp/.
41
- // «Submissions must be completed by this deadline» is the `submission`: on the CALL's abstract
42
- // day, 04:00 UTC — four days before the call's paper day. «Completed submissions may be updated
43
- // until this deadline» is the `resubmission`.
41
+ // «Submissions must be completed by this deadline» is the `submission`, «Completed submissions
42
+ // may be updated until this deadline» the `resubmission`. Read 2026-10-19 18:05 UTC: «Wednesday
43
+ // Oct 21, 2026, 7:59:59 AM EDT» (`"sub":1792583999`) and «Saturday Oct 24, 2026, 7:59:59 AM EDT»
44
+ // — the ends of the call's abstract and paper days AoE. The reading of 2026-10-05 was 04:00 UTC
45
+ // on the abstract and paper days themselves; the portal moved both later on or before 2026-10-19.
44
46
  "deadlines": [
45
47
  {
46
48
  "what": "submission",
47
- "at": "2026-10-20T04:00:00Z",
49
+ "at": "2026-10-21T11:59:59Z",
48
50
  "source": "portal",
49
51
  "url": "https://msr2027.hotcrp.com/deadlines",
50
- "read": "2026-10-05",
52
+ "read": "2026-10-19",
51
53
  },
52
54
  {
53
55
  "what": "resubmission",
54
- "at": "2026-10-23T04:00:00Z",
56
+ "at": "2026-10-24T11:59:59Z",
55
57
  "source": "portal",
56
58
  "url": "https://msr2027.hotcrp.com/deadlines",
57
- "read": "2026-10-05",
59
+ "read": "2026-10-19",
58
60
  },
59
61
  ],
60
62
 
package/presets/msr.md CHANGED
@@ -23,16 +23,20 @@ whereas last year it was ACM format.»), and limits and dates change between edi
23
23
  2027-01-08; camera-ready 2027-01-26. The call's prose names the weekdays «Mon, 20 Oct 2026» and
24
24
  «Thurs, 23 Oct 2026», its date table «Tue 20 Oct 2026» and «Fri 23 Oct 2026»; the calendar sides
25
25
  with the table (2026-10-20 is a Tuesday).
26
- - 🔴 **The portal closes earlier than the call says.** Read 2026-10-05 on
27
- `https://msr2027.hotcrp.com/deadlines`: «Submission deadline: Tuesday Oct 20, 2026, 12 AM EDT»
28
- (`"sub":1792468800`, 2026-10-20 04:00 UTC) and «Resubmission deadline: Friday Oct 23, 2026, 12 AM
29
- EDT» («Completed submissions may be updated until this deadline»). Midnight EDT at the start of
30
- each day is about a day and a half before the end of that day AoE. The portal is what refuses the
31
- upload: plan for its times. The preset carries them as `deadlines` (`source: "portal"`, with the
32
- day read), refreshed by paperlint's scheduled workflow when the portal changes them; a paper whose
33
- current cycle names `paperlint:msr` derives them. Recording the call's days in the paper's own
34
- cycle maps «abstract» to `registration` and «paper» to `submission` — and the portal's earlier
35
- `submission` is then the one in force.
26
+ - 🔴 **The portal's submission deadline is the call's ABSTRACT day, not its paper day.** Read
27
+ 2026-10-19 18:05 UTC on `https://msr2027.hotcrp.com/deadlines`: «Submission deadline: Wednesday
28
+ Oct 21, 2026, 7:59:59 AM EDT — Submissions must be completed by this deadline to be reviewed»
29
+ (`"sub":1792583999`, 2026-10-21 11:59:59 UTC, the end of 2026-10-20 AoE) and «Resubmission
30
+ deadline: Saturday Oct 24, 2026, 7:59:59 AM EDT — Completed submissions may be updated until this
31
+ deadline» (2026-10-24 11:59:59 UTC, the end of 2026-10-23 AoE). So a submission must be complete
32
+ by the abstract day, and the paper day is the last day to update it. The reading of 2026-10-05
33
+ was earlier on both («Tuesday Oct 20, 2026, 12 AM EDT», `"sub":1792468800`, and «Friday Oct 23,
34
+ 2026, 12 AM EDT»): the portal moved them. The portal is what refuses the upload: plan for its
35
+ times. The preset carries them as `deadlines` (`source: "portal"`, with the day read), refreshed
36
+ by paperlint's scheduled workflow when the portal changes them; a paper whose current cycle names
37
+ `paperlint:msr` derives them. Recording the call's days in the paper's own cycle maps «abstract»
38
+ to `registration` and «paper» to `submission` — and the portal's `submission`, at the end of the
39
+ abstract day, is then the one in force.
36
40
  - **Format**, verbatim from the call: «LaTeX users must use \documentclass[10pt,conference]{IEEEtran}
37
41
  without including the compsoc or compsocconf options», «title in 24pt font and full text in 10pt
38
42
  type», and «Alterations of spacing, font size, and other changes that deviate from the
package/scripts/check.ts CHANGED
@@ -213,7 +213,7 @@ export const NOT_COVERED: Readonly<Record<string, string>> = {
213
213
  refresh:
214
214
  "deadlines.yml reads the venue presets' portals over the network once a day and opens a pull " +
215
215
  "request when a deadline moved; npm test replays the recorded portal pages through " +
216
- "scripts/refresh-deadlines.test.ts, and nothing local should read the live portals on every check.",
216
+ "scripts/refresh-deadlines/main.test.ts, and nothing local should read the live portals on every check.",
217
217
  };
218
218
 
219
219
  /**
@@ -0,0 +1,75 @@
1
+ /** A preset's portal readings against the page: kept, rewritten, added, noted — a call's never touched. */
2
+ import { describe, expect, test } from "vitest";
3
+ import { refreshReadings } from "./compare.ts";
4
+
5
+ const reading = (
6
+ what: "submission" | "resubmission" | "camera-ready",
7
+ at: string,
8
+ source: "portal" | "call" = "portal",
9
+ ) => ({
10
+ what,
11
+ at,
12
+ source,
13
+ url: "https://x.example/deadlines",
14
+ read: "2026-10-01",
15
+ });
16
+ const page = (
17
+ deadlines: readonly {
18
+ what: "submission" | "camera-ready" | null;
19
+ at: string;
20
+ }[],
21
+ ) => ({
22
+ now: 1,
23
+ open: true,
24
+ blind: true,
25
+ deadlines: deadlines.map((d) => ({ ...d, label: String(d.what) })),
26
+ });
27
+
28
+ describe("refreshReadings — what the page says, over what the preset said", () => {
29
+ test("a reading the portal no longer lists is kept and noted; a call's reading is never touched", () => {
30
+ const r = refreshReadings(
31
+ [
32
+ reading("submission", "2026-10-21T11:59:59Z"),
33
+ reading("submission", "2026-10-21T11:59:59Z", "call"),
34
+ reading("camera-ready", "2026-12-01T00:00:00Z"),
35
+ ],
36
+ page([{ what: "submission", at: "2026-10-21T11:59:59Z" }]),
37
+ "https://x.example/deadlines",
38
+ "2026-10-06",
39
+ );
40
+ expect(r.changes).toEqual([]);
41
+ expect(r.notes).toEqual([
42
+ "camera-ready: no longer on the page — kept as read 2026-10-01",
43
+ ]);
44
+ expect(r.readings).toEqual([
45
+ reading("submission", "2026-10-21T11:59:59Z"),
46
+ reading("submission", "2026-10-21T11:59:59Z", "call"),
47
+ reading("camera-ready", "2026-12-01T00:00:00Z"),
48
+ ]);
49
+ });
50
+
51
+ test("a kind the page lists twice binds at the earlier instant; a label paperlint names no kind for is left out", () => {
52
+ const r = refreshReadings(
53
+ [],
54
+ page([
55
+ { what: "submission", at: "2026-10-22T04:00:00Z" },
56
+ { what: "submission", at: "2026-10-21T11:59:59Z" },
57
+ { what: null, at: "2026-09-01T00:00:00Z" },
58
+ ]),
59
+ "https://x.example/deadlines",
60
+ "2026-10-06",
61
+ );
62
+ expect(r.readings).toEqual([
63
+ {
64
+ what: "submission",
65
+ at: "2026-10-21T11:59:59Z",
66
+ source: "portal",
67
+ url: "https://x.example/deadlines",
68
+ read: "2026-10-06",
69
+ },
70
+ ]);
71
+ expect(r.notes).toEqual([
72
+ "submission: the page lists 2026-10-21T11:59:59Z, 2026-10-22T04:00:00Z — the earliest is recorded",
73
+ ]);
74
+ });
75
+ });
@@ -0,0 +1,101 @@
1
+ /**
2
+ * A PRESET'S PORTAL READINGS AGAINST WHAT THE PORTAL'S PAGE SAYS NOW — the comparison the scheduled
3
+ * refresh of the shipped presets makes (`main.ts` beside this file). Pure.
4
+ *
5
+ * a kind the page lists at the instant the preset holds kept as it is, its `read` day too
6
+ * a kind the page lists at another instant rewritten, `read` = today
7
+ * a kind the page lists that the preset does not hold added, `read` = today
8
+ * a kind the preset holds that the page no longer lists KEPT and noted — a portal may drop a
9
+ * passed deadline; the reading stays true
10
+ * a reading from the call never touched: it is not the portal's
11
+ */
12
+ import {
13
+ DEADLINE_WHATS,
14
+ type DeadlineWhat,
15
+ type IsoDate,
16
+ type PortalDeadlines,
17
+ type Reading,
18
+ } from "#src/domain/deadline";
19
+
20
+ /** The preset's readings after the page's answer, what changed, and what is worth a note. */
21
+ export interface Refreshed {
22
+ readonly readings: readonly Reading[];
23
+ readonly changes: readonly string[];
24
+ readonly notes: readonly string[];
25
+ }
26
+
27
+ /** The page's instants for one kind, earliest first. */
28
+ const pageAts = (
29
+ page: PortalDeadlines,
30
+ what: DeadlineWhat,
31
+ ): readonly string[] =>
32
+ page.deadlines
33
+ .filter((d) => d.what === what)
34
+ .map((d) => d.at)
35
+ .toSorted();
36
+
37
+ /** A kind the page does not list: the preset's reading, if it holds one, is kept and noted. */
38
+ const offThePage = (
39
+ what: DeadlineWhat,
40
+ held: Reading | undefined,
41
+ ): Refreshed =>
42
+ held === undefined
43
+ ? { readings: [], changes: [], notes: [] }
44
+ : {
45
+ readings: [held],
46
+ changes: [],
47
+ notes: [`${what}: no longer on the page — kept as read ${held.read}`],
48
+ };
49
+
50
+ /** One kind: the preset's portal reading against the page's. */
51
+ function oneKind(
52
+ what: DeadlineWhat,
53
+ held: Reading | undefined,
54
+ ats: readonly string[],
55
+ o: { readonly url: string; readonly today: IsoDate },
56
+ ): Refreshed {
57
+ const notes =
58
+ ats.length > 1
59
+ ? [`${what}: the page lists ${ats.join(", ")} — the earliest is recorded`]
60
+ : [];
61
+ const at = ats[0];
62
+ if (at === undefined) return offThePage(what, held);
63
+ if (held?.at === at) return { readings: [held], changes: [], notes };
64
+ const fresh: Reading = {
65
+ what,
66
+ at,
67
+ source: "portal",
68
+ url: o.url,
69
+ read: o.today,
70
+ };
71
+ const change =
72
+ held === undefined ? `${what}: added ${at}` : `${what}: ${held.at} → ${at}`;
73
+ return { readings: [fresh], changes: [change], notes };
74
+ }
75
+
76
+ /**
77
+ * The preset's readings after the portal answered `page` at `url` on `today`. The call's readings
78
+ * are kept as they are; the portal's are kept, rewritten or added kind by kind. Pure.
79
+ */
80
+ export function refreshReadings(
81
+ current: readonly Reading[],
82
+ page: PortalDeadlines,
83
+ url: string,
84
+ today: IsoDate,
85
+ ): Refreshed {
86
+ const perKind = DEADLINE_WHATS.map((what) => {
87
+ const portal = oneKind(
88
+ what,
89
+ current.find((r) => r.what === what && r.source === "portal"),
90
+ pageAts(page, what),
91
+ { url, today },
92
+ );
93
+ const call = current.filter((r) => r.what === what && r.source === "call");
94
+ return { ...portal, readings: [...portal.readings, ...call] };
95
+ });
96
+ return {
97
+ readings: perKind.flatMap((k) => k.readings),
98
+ changes: perKind.flatMap((k) => k.changes),
99
+ notes: perKind.flatMap((k) => k.notes),
100
+ };
101
+ }
@@ -0,0 +1,140 @@
1
+ /**
2
+ * The entry of the scheduled refresh: the npm script the workflow runs, end to end, and the
3
+ * command line and files main.ts hands the logic.
4
+ */
5
+ import { execFile } from "node:child_process";
6
+ import {
7
+ mkdtempSync,
8
+ readFileSync,
9
+ realpathSync,
10
+ writeFileSync,
11
+ } from "node:fs";
12
+ import {
13
+ createServer,
14
+ type IncomingMessage,
15
+ type ServerResponse,
16
+ } from "node:http";
17
+ import { tmpdir } from "node:os";
18
+ import { dirname, join } from "node:path";
19
+ import { fileURLToPath } from "node:url";
20
+ import { promisify } from "node:util";
21
+ import { afterAll, beforeAll, describe, expect, test } from "vitest";
22
+ import { parsePreset } from "#src/tex-requirements";
23
+ import { argsOf, presetsIn, summaryTo } from "./main.ts";
24
+
25
+ const ROOT = dirname(dirname(dirname(fileURLToPath(import.meta.url))));
26
+ const PRESETS = join(ROOT, "presets");
27
+ const CASSETTE = join(ROOT, "src", "adapters", "hotcrp", "cassette");
28
+ const MSR = join(PRESETS, "msr.jsonc");
29
+ const msrText = readFileSync(MSR, "utf8");
30
+ const readings = (text: string) => parsePreset(text, MSR).deadlines ?? [];
31
+ /** MSR with its submission deadline two days before the portal's. */
32
+ const earlier = msrText.replace(
33
+ '"at": "2026-10-21T11:59:59Z"',
34
+ '"at": "2026-10-19T04:00:00Z"',
35
+ );
36
+ const tempDir = (prefix: string) =>
37
+ realpathSync(mkdtempSync(join(tmpdir(), prefix)));
38
+
39
+ const MSR_HTML = readFileSync(join(CASSETTE, "deadlines-msr2027.html"), "utf8");
40
+ const server = createServer((req: IncomingMessage, res: ServerResponse) => {
41
+ const ok = req.url === "/msr/deadlines";
42
+ res.writeHead(ok ? 200 : 404, { "content-type": "text/html" });
43
+ res.end(ok ? MSR_HTML : '<h1 id="h-title">No such conference</h1>');
44
+ });
45
+ const base = { url: "" };
46
+ beforeAll(async () => {
47
+ await new Promise<void>((r) => server.listen(0, "127.0.0.1", r));
48
+ const a = server.address();
49
+ base.url = `http://127.0.0.1:${String(typeof a === "object" && a !== null ? a.port : 0)}`;
50
+ });
51
+ afterAll(
52
+ () =>
53
+ new Promise<void>((r) =>
54
+ server.close(() => {
55
+ r();
56
+ }),
57
+ ),
58
+ );
59
+
60
+ // The one end-to-end test: main.ts's isMain block wires the real HotCRP adapter, run against the cassette.
61
+ describe("`npm run refresh-deadlines`, the command the workflow runs — with no dist/", () => {
62
+ // A real `node` process: Node strips the types of the script and everything it imports cold —
63
+ // about a second alone, several on a loaded machine, past vitest's 5 s.
64
+ test(
65
+ "rewrites the moved deadline, reports the site it could not read, writes the summary — and exits 0",
66
+ { timeout: 60_000 },
67
+ async () => {
68
+ const dir = tempDir("deadlines-");
69
+ const atServer = (text: string, path: string) =>
70
+ text.split("https://msr2027.hotcrp.com").join(`${base.url}/${path}`);
71
+ writeFileSync(join(dir, "msr.jsonc"), atServer(earlier, "msr"));
72
+ writeFileSync(join(dir, "gone.jsonc"), atServer(msrText, "gone"));
73
+ writeFileSync(join(dir, "notes.md"), "not a preset\n");
74
+ const summary = join(dir, "summary.md");
75
+ // Asynchronously: the local server answers from THIS process, which a synchronous spawn blocks.
76
+ // The inherited NODE_OPTIONS are replaced by test/no-dist.ts, which refuses any module in
77
+ // `dist/` — the job installs with --ignore-scripts.
78
+ const r = await promisify(execFile)(
79
+ "npm",
80
+ [
81
+ "run",
82
+ "-s",
83
+ "refresh-deadlines",
84
+ "--",
85
+ "--presets",
86
+ dir,
87
+ "--summary",
88
+ summary,
89
+ ],
90
+ {
91
+ env: {
92
+ ...process.env,
93
+ NODE_OPTIONS: `--import ${join(ROOT, "test", "no-dist.ts")}`,
94
+ },
95
+ cwd: ROOT,
96
+ },
97
+ ); // rejects on a non-zero exit, with the output
98
+ expect(
99
+ readings(readFileSync(join(dir, "msr.jsonc"), "utf8"))[0]?.at,
100
+ ).toBe("2026-10-21T11:59:59Z");
101
+ expect(r.stdout).toMatch(
102
+ /^::warning title=deadlines::gone\.jsonc: could not read http:\/\/127\.0\.0\.1:\d+\/gone\/deadlines — the portal refused \(HTTP 404\): No such conference$/m,
103
+ );
104
+ expect(readFileSync(summary, "utf8")).toMatch(
105
+ /^- ⚠️ `gone\.jsonc`: could not read /m,
106
+ );
107
+ },
108
+ );
109
+ });
110
+
111
+ test("argsOf: the repository's presets and no summary by default; a flag without a value is no value", () => {
112
+ expect(argsOf([])).toEqual({ presets: PRESETS, summary: null });
113
+ expect(argsOf(["--presets", "/p", "--summary"])).toEqual({
114
+ presets: "/p",
115
+ summary: null,
116
+ });
117
+ });
118
+
119
+ test("presetsIn: the *.jsonc files of a directory, sorted, read and written there; summaryTo: a file, or nowhere", () => {
120
+ const dir = tempDir("presets-");
121
+ writeFileSync(join(dir, "b.jsonc"), "B");
122
+ writeFileSync(join(dir, "a.jsonc"), "A");
123
+ writeFileSync(join(dir, "notes.md"), "not a preset");
124
+ const p = presetsIn(dir);
125
+ p.write("a.jsonc", "A2");
126
+ summaryTo(join(dir, "s.md"))?.("body\n");
127
+ expect([
128
+ p.names,
129
+ p.read("a.jsonc"),
130
+ p.path("b.jsonc"),
131
+ readFileSync(join(dir, "s.md"), "utf8"),
132
+ summaryTo(null),
133
+ ]).toStrictEqual([
134
+ ["a.jsonc", "b.jsonc"],
135
+ "A2",
136
+ join(dir, "b.jsonc"),
137
+ "body\n",
138
+ null,
139
+ ]);
140
+ });