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.
- package/.github/workflows/deadlines.yml +27 -7
- package/README.md +16 -15
- package/dist/adapters/hotcrp/index.d.ts +6 -4
- package/dist/adapters/hotcrp/index.d.ts.map +1 -1
- package/dist/adapters/hotcrp/index.js +6 -4
- package/dist/adapters/hotcrp/index.js.map +1 -1
- package/dist/adapters/hotcrp/portal.io.d.ts +3 -1
- package/dist/adapters/hotcrp/portal.io.d.ts.map +1 -1
- package/dist/adapters/hotcrp/portal.io.js +36 -11
- package/dist/adapters/hotcrp/portal.io.js.map +1 -1
- package/dist/adapters/hotcrp/response.d.ts +6 -1
- package/dist/adapters/hotcrp/response.d.ts.map +1 -1
- package/dist/adapters/hotcrp/response.js +17 -0
- package/dist/adapters/hotcrp/response.js.map +1 -1
- package/dist/adapters/hotcrp/site.d.ts +9 -0
- package/dist/adapters/hotcrp/site.d.ts.map +1 -0
- package/dist/adapters/hotcrp/site.js +22 -0
- package/dist/adapters/hotcrp/site.js.map +1 -0
- package/dist/adapters/hotcrp/token.d.ts +13 -0
- package/dist/adapters/hotcrp/token.d.ts.map +1 -0
- package/dist/adapters/hotcrp/token.js +48 -0
- package/dist/adapters/hotcrp/token.js.map +1 -0
- package/dist/adapters/latex/abstract.d.ts +22 -0
- package/dist/adapters/latex/abstract.d.ts.map +1 -0
- package/dist/adapters/latex/abstract.js +187 -0
- package/dist/adapters/latex/abstract.js.map +1 -0
- package/dist/adapters/latex/headings.d.ts +6 -0
- package/dist/adapters/latex/headings.d.ts.map +1 -1
- package/dist/adapters/latex/headings.js +5 -4
- package/dist/adapters/latex/headings.js.map +1 -1
- package/dist/adapters/latex/index.d.ts.map +1 -1
- package/dist/adapters/latex/index.js +2 -0
- package/dist/adapters/latex/index.js.map +1 -1
- package/dist/adapters/latex/parse.d.ts +15 -0
- package/dist/adapters/latex/parse.d.ts.map +1 -1
- package/dist/adapters/latex/parse.js +58 -1
- package/dist/adapters/latex/parse.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +20 -8
- package/dist/cli.js.map +1 -1
- package/dist/cycle-rules.d.ts.map +1 -1
- package/dist/cycle-rules.js +3 -4
- package/dist/cycle-rules.js.map +1 -1
- package/dist/domain/deadline.d.ts +8 -1
- package/dist/domain/deadline.d.ts.map +1 -1
- package/dist/domain/deadline.js +26 -12
- package/dist/domain/deadline.js.map +1 -1
- package/dist/domain/portal-token.d.ts +44 -0
- package/dist/domain/portal-token.d.ts.map +1 -0
- package/dist/domain/portal-token.js +32 -0
- package/dist/domain/portal-token.js.map +1 -0
- package/dist/domain/submission.d.ts +30 -0
- package/dist/domain/submission.d.ts.map +1 -1
- package/dist/domain/submission.js +68 -0
- package/dist/domain/submission.js.map +1 -1
- package/dist/lib/paper-config.d.ts +1 -1
- package/dist/lib/paper-config.d.ts.map +1 -1
- package/dist/lib/paper-config.js +6 -4
- package/dist/lib/paper-config.js.map +1 -1
- package/dist/paper-settings.d.ts +3 -1
- package/dist/paper-settings.d.ts.map +1 -1
- package/dist/paper-settings.js +11 -5
- package/dist/paper-settings.js.map +1 -1
- package/dist/ports/latex.d.ts +23 -0
- package/dist/ports/latex.d.ts.map +1 -1
- package/dist/ports/submission-portal.d.ts +39 -2
- package/dist/ports/submission-portal.d.ts.map +1 -1
- package/dist/ports/venue-deadlines.d.ts +1 -1
- package/dist/submission.d.ts +49 -11
- package/dist/submission.d.ts.map +1 -1
- package/dist/submission.js +332 -44
- package/dist/submission.js.map +1 -1
- package/docs/configuration.md +2 -1
- package/docs/rules/cycle/record.md +9 -6
- package/docs/submission.md +47 -48
- package/eslint.config.mjs +3 -0
- package/lib/paper-config.ts +7 -4
- package/package.json +6 -1
- package/presets/msr.jsonc +9 -7
- package/presets/msr.md +14 -10
- package/scripts/check.ts +1 -1
- package/scripts/refresh-deadlines/compare.test.ts +75 -0
- package/scripts/refresh-deadlines/compare.ts +101 -0
- package/scripts/refresh-deadlines/main.test.ts +140 -0
- package/scripts/refresh-deadlines/main.ts +56 -0
- package/scripts/refresh-deadlines/refresh.test.ts +287 -0
- package/scripts/refresh-deadlines/refresh.ts +183 -0
- package/skills/submission-portal/SKILL.md +60 -44
- package/skills/submission-portal/SKILL.md.spec.ts +62 -43
- package/skills/submission-portal/submission-portal.test.ts +4 -73
- package/src/adapters/hotcrp/cassette/README.md +4 -4
- package/src/adapters/hotcrp/cassette/deadlines-msr2027.html +9 -9
- package/src/adapters/hotcrp/deadlines.io.test.ts +2 -2
- package/src/adapters/hotcrp/deadlines.test.ts +3 -3
- package/src/adapters/hotcrp/index.ts +15 -3
- package/src/adapters/hotcrp/portal.io.ts +61 -17
- package/src/adapters/hotcrp/portal.test.ts +105 -1
- package/src/adapters/hotcrp/response.ts +21 -0
- package/src/adapters/hotcrp/site.ts +34 -0
- package/src/adapters/hotcrp/token.test.ts +69 -0
- package/src/adapters/hotcrp/token.ts +51 -0
- package/src/adapters/latex/abstract.test.ts +280 -0
- package/src/adapters/latex/abstract.ts +272 -0
- package/src/adapters/latex/headings.ts +5 -4
- package/src/adapters/latex/index.test.ts +31 -1
- package/src/adapters/latex/index.ts +2 -0
- package/src/adapters/latex/parse.ts +64 -3
- package/src/cli.test.ts +20 -8
- package/src/cli.ts +22 -13
- package/src/cycle-rules.test.ts +6 -6
- package/src/cycle-rules.ts +7 -4
- package/src/domain/deadline.test.ts +31 -2
- package/src/domain/deadline.ts +42 -16
- package/src/domain/portal-token.test.ts +55 -0
- package/src/domain/portal-token.ts +63 -0
- package/src/domain/submission.test.ts +88 -0
- package/src/domain/submission.ts +99 -0
- package/src/new-paper.test.ts +1 -0
- package/src/paper-settings.test.ts +20 -0
- package/src/paper-settings.ts +17 -5
- package/src/ports/latex.ts +25 -0
- package/src/ports/submission-portal.ts +43 -1
- package/src/ports/venue-deadlines.ts +1 -1
- package/src/presets.test.ts +8 -5
- package/src/submission-new.test.ts +569 -0
- package/src/submission-show.test.ts +210 -0
- package/src/submission-update.test.ts +160 -0
- package/src/submission.test.ts +140 -518
- package/src/submission.ts +494 -61
- package/scripts/refresh-deadlines.test.ts +0 -431
- package/scripts/refresh-deadlines.ts +0 -387
package/docs/submission.md
CHANGED
|
@@ -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
|
|
4
|
-
|
|
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
|
|
9
|
-
npx paperlint submission update papers/my-paper --save
|
|
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
|
-
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
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
|
-
|
|
27
|
-
// papers/my-paper/paperlint.json
|
|
28
|
-
{ "extends": "paperlint:aidc", "kind": "regular", "submission": { "id": 7 } }
|
|
29
|
-
```
|
|
34
|
+
## `new`
|
|
30
35
|
|
|
31
|
-
|
|
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
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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: `
|
|
67
|
-
|
|
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
|
|
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",
|
package/lib/paper-config.ts
CHANGED
|
@@ -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.
|
|
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\".
|
|
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.
|
|
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:
|
|
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": "
|
|
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
|
|
42
|
-
//
|
|
43
|
-
//
|
|
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-
|
|
49
|
+
"at": "2026-10-21T11:59:59Z",
|
|
48
50
|
"source": "portal",
|
|
49
51
|
"url": "https://msr2027.hotcrp.com/deadlines",
|
|
50
|
-
"read": "2026-10-
|
|
52
|
+
"read": "2026-10-19",
|
|
51
53
|
},
|
|
52
54
|
{
|
|
53
55
|
"what": "resubmission",
|
|
54
|
-
"at": "2026-10-
|
|
56
|
+
"at": "2026-10-24T11:59:59Z",
|
|
55
57
|
"source": "portal",
|
|
56
58
|
"url": "https://msr2027.hotcrp.com/deadlines",
|
|
57
|
-
"read": "2026-10-
|
|
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
|
|
27
|
-
`https://msr2027.hotcrp.com/deadlines`: «Submission deadline:
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
day
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
`
|
|
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
|
+
});
|