paperlint 10.4.1 → 11.1.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 +51 -11
- package/dist/adapters/hotcrp/portal.io.js.map +1 -1
- package/dist/adapters/hotcrp/response.d.ts +12 -1
- package/dist/adapters/hotcrp/response.d.ts.map +1 -1
- package/dist/adapters/hotcrp/response.js +52 -3
- 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 +21 -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 +25 -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-fields.d.ts +77 -0
- package/dist/domain/submission-fields.d.ts.map +1 -0
- package/dist/domain/submission-fields.js +127 -0
- package/dist/domain/submission-fields.js.map +1 -0
- package/dist/domain/submission.d.ts +40 -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 +8 -4
- package/dist/lib/paper-config.js.map +1 -1
- package/dist/paper-settings.d.ts +8 -1
- package/dist/paper-settings.d.ts.map +1 -1
- package/dist/paper-settings.js +14 -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 +41 -2
- package/dist/ports/submission-portal.d.ts.map +1 -1
- package/dist/ports/venue-deadlines.d.ts +1 -1
- package/dist/presets.d.ts +3 -0
- package/dist/presets.d.ts.map +1 -1
- package/dist/presets.js +3 -1
- package/dist/presets.js.map +1 -1
- package/dist/submission.d.ts +68 -11
- package/dist/submission.d.ts.map +1 -1
- package/dist/submission.js +441 -63
- package/dist/submission.js.map +1 -1
- package/dist/tex-requirements.d.ts +3 -0
- package/dist/tex-requirements.d.ts.map +1 -1
- package/dist/tex-requirements.js +30 -2
- package/dist/tex-requirements.js.map +1 -1
- package/docs/configuration.md +2 -1
- package/docs/rules/cycle/record.md +9 -6
- package/docs/submission.md +73 -51
- package/eslint.config.mjs +3 -0
- package/lib/paper-config.ts +10 -4
- package/package.json +6 -1
- package/presets/msr.jsonc +75 -7
- package/presets/msr.md +14 -10
- package/presets/venue-profile.schema.json +41 -0
- 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 +92 -45
- package/skills/submission-portal/SKILL.md.spec.ts +94 -44
- package/skills/submission-portal/submission-portal.test.ts +4 -73
- package/src/adapters/hotcrp/cassette/README.md +11 -10
- package/src/adapters/hotcrp/cassette/deadlines-msr2027.html +9 -9
- package/src/adapters/hotcrp/cassette/get-searchcompletion.json +100 -0
- 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 +16 -3
- package/src/adapters/hotcrp/portal.io.ts +81 -17
- package/src/adapters/hotcrp/portal.test.ts +208 -3
- package/src/adapters/hotcrp/response.ts +66 -4
- package/src/adapters/hotcrp/site.ts +33 -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 +27 -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-fields.test.ts +183 -0
- package/src/domain/submission-fields.ts +190 -0
- package/src/domain/submission.test.ts +88 -0
- package/src/domain/submission.ts +110 -0
- package/src/new-paper.test.ts +2 -0
- package/src/paper-settings.test.ts +26 -0
- package/src/paper-settings.ts +25 -5
- package/src/ports/latex.ts +25 -0
- package/src/ports/submission-portal.ts +46 -1
- package/src/ports/venue-deadlines.ts +1 -1
- package/src/presets.test.ts +8 -5
- package/src/presets.ts +12 -2
- package/src/submission-fields-run.test.ts +247 -0
- package/src/submission-new.test.ts +569 -0
- package/src/submission-show.test.ts +210 -0
- package/src/submission-update.test.ts +166 -0
- package/src/submission.test.ts +140 -518
- package/src/submission.ts +667 -81
- package/src/tex-requirements.test.ts +21 -0
- package/src/tex-requirements.ts +67 -2
- package/scripts/refresh-deadlines.test.ts +0 -431
- package/scripts/refresh-deadlines.ts +0 -387
package/presets/msr.jsonc
CHANGED
|
@@ -38,26 +38,94 @@
|
|
|
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
|
|
|
63
|
+
// The fields msr2027's submission form asks beyond title, abstract, authors and the PDF, read
|
|
64
|
+
// 2026-10-20. The topics through the portal's API: `GET /api/searchcompletion` with an author's
|
|
65
|
+
// token lists 45 `topic:"…"` completions — these 41 and the 4 group names (DS/ML/AI Technique,
|
|
66
|
+
// Research Method, SE Topic, Software repository), which a submission cannot carry. Each of the 41
|
|
67
|
+
// was sent alone in a dry run of `POST /api/paper` and taken (`change_list` names `topics`); the
|
|
68
|
+
// bare subtopic «AI for software engineering» and the group «SE Topic» were dropped without an
|
|
69
|
+
// error. The form says «Select at least one topic». «ACM corresponding author» (`ACMCorAut`, listed
|
|
70
|
+
// by `/api/displayfields`) is a selector of the authors in the form; the API takes an author's
|
|
71
|
+
// email (a dry run with a name: «Format error»). `paperlint submission topics` re-reads the list.
|
|
72
|
+
"submission_fields": {
|
|
73
|
+
"source": "portal",
|
|
74
|
+
"url": "https://msr2027.hotcrp.com/api/searchcompletion",
|
|
75
|
+
"read": "2026-10-20",
|
|
76
|
+
"fields": {
|
|
77
|
+
"topics": {
|
|
78
|
+
"kind": "checkboxes",
|
|
79
|
+
"required": true,
|
|
80
|
+
"options": [
|
|
81
|
+
"DS/ML/AI Technique: Classical machine learning (e.g., regression, SVMs, nearest neighbour, decision trees, PCA, naive Bayes classifier, and k-means clustering)",
|
|
82
|
+
"DS/ML/AI Technique: Convolutional neural networks",
|
|
83
|
+
"DS/ML/AI Technique: LLM or other foundation models (e.g., large vision model)",
|
|
84
|
+
"DS/ML/AI Technique: Reinforcement learning",
|
|
85
|
+
"DS/ML/AI Technique: Transformers",
|
|
86
|
+
"Performance analysis and testing",
|
|
87
|
+
"Research Method: Controlled experiments with human subjects (e.g., A/B tests, lab studies)",
|
|
88
|
+
"Research Method: Qualitative analysis (e.g., thematic analysis, grounded theory)",
|
|
89
|
+
"Research Method: Statistical analysis and modelling",
|
|
90
|
+
"Research Method: Survey design and analysis",
|
|
91
|
+
"SE Topic: AI for software engineering",
|
|
92
|
+
"SE Topic: API design and evolution",
|
|
93
|
+
"SE Topic: App store analysis",
|
|
94
|
+
"SE Topic: Cloud-based and distributed systems",
|
|
95
|
+
"SE Topic: Debugging, fault localization, and fault prediction",
|
|
96
|
+
"SE Topic: DevOps and release engineering",
|
|
97
|
+
"SE Topic: Distributed and collaborative software engineering",
|
|
98
|
+
"SE Topic: Energy profiling",
|
|
99
|
+
"SE Topic: Ethical concerns (bias, fairness, explainability, etc.)",
|
|
100
|
+
"SE Topic: Human aspects",
|
|
101
|
+
"SE Topic: Legal aspects (licenses, copyright, etc.)",
|
|
102
|
+
"SE Topic: Privacy and security",
|
|
103
|
+
"SE Topic: Program comprehension",
|
|
104
|
+
"SE Topic: Recommender systems",
|
|
105
|
+
"SE Topic: Software development processes",
|
|
106
|
+
"SE Topic: Software ecosystems",
|
|
107
|
+
"SE Topic: Software engineering for AI",
|
|
108
|
+
"SE Topic: Software maintenance and evolution",
|
|
109
|
+
"SE Topic: Software measurement and analytics",
|
|
110
|
+
"SE Topic: Software quality assurance",
|
|
111
|
+
"SE Topic: Source / test code",
|
|
112
|
+
"SE Topic: Version control history logs",
|
|
113
|
+
"Software repository: App stores",
|
|
114
|
+
"Software repository: Archived communications (mailing lists, Gitter/Slack/Discord channels, etc.)",
|
|
115
|
+
"Software repository: Build / CI logs",
|
|
116
|
+
"Software repository: Code reviews",
|
|
117
|
+
"Software repository: Execution traces and logs",
|
|
118
|
+
"Software repository: Human interaction data",
|
|
119
|
+
"Software repository: Issue trackers / defect repositories",
|
|
120
|
+
"Software repository: Package registries / software releases",
|
|
121
|
+
"Software repository: Q&A websites",
|
|
122
|
+
],
|
|
123
|
+
},
|
|
124
|
+
"pc_conflicts": { "kind": "pc_conflicts" },
|
|
125
|
+
"ACMCorAut": { "kind": "email", "label": "ACM corresponding author" },
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
|
|
61
129
|
// What the venue is called in a paper's text (tex/venue-leftover) — and only beside a year
|
|
62
130
|
// («MSR 2027», «MSR'27»): «MSR» is also Microsoft Research, and an acronym papers define for
|
|
63
131
|
// themselves («Manual Speech Recognition (MSR)», in an accepted ACSAC paper of
|
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
|
|
@@ -117,6 +117,47 @@
|
|
|
117
117
|
}
|
|
118
118
|
}
|
|
119
119
|
},
|
|
120
|
+
"submission_fields": {
|
|
121
|
+
"description": "The fields the edition's submission form asks beyond the title, the abstract, the authors and the PDF, as READ — where (`source`: `portal`, through the portal's API; `form`, copied from the form), from which page and on which day. Read by `paperlint submission`: a paper answers them in its paperlint.json `fields`, checked against these before anything is sent, so an option the form does not offer is refused here instead of dropped by the portal without a word. `fields` maps the portal API's key to its kind: `checkboxes` (a list of `options`, HotCRP's topics spelled \"Group: Topic\"), `text`, `email`, `pc_conflicts` (PC member's email → conflict type). `required`: the form does not complete without it. A child's value replaces its parent's.",
|
|
122
|
+
"type": "object",
|
|
123
|
+
"additionalProperties": false,
|
|
124
|
+
"required": ["source", "url", "read", "fields"],
|
|
125
|
+
"properties": {
|
|
126
|
+
"source": { "type": "string", "enum": ["portal", "form"] },
|
|
127
|
+
"url": { "type": "string", "pattern": "^https?://[^\\s/]+[^\\s]*$" },
|
|
128
|
+
"read": {
|
|
129
|
+
"description": "The day the fields were read, YYYY-MM-DD.",
|
|
130
|
+
"type": "string",
|
|
131
|
+
"pattern": "^\\d{4}-\\d{2}-\\d{2}$"
|
|
132
|
+
},
|
|
133
|
+
"fields": {
|
|
134
|
+
"type": "object",
|
|
135
|
+
"minProperties": 1,
|
|
136
|
+
"additionalProperties": {
|
|
137
|
+
"type": "object",
|
|
138
|
+
"additionalProperties": false,
|
|
139
|
+
"required": ["kind"],
|
|
140
|
+
"properties": {
|
|
141
|
+
"kind": {
|
|
142
|
+
"type": "string",
|
|
143
|
+
"enum": ["checkboxes", "text", "email", "pc_conflicts"]
|
|
144
|
+
},
|
|
145
|
+
"required": { "type": "boolean" },
|
|
146
|
+
"label": { "type": "string", "minLength": 1 },
|
|
147
|
+
"options": {
|
|
148
|
+
"type": "array",
|
|
149
|
+
"minItems": 1,
|
|
150
|
+
"uniqueItems": true,
|
|
151
|
+
"items": { "type": "string", "minLength": 1 }
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
"if": { "properties": { "kind": { "const": "checkboxes" } } },
|
|
155
|
+
"then": { "required": ["options"] },
|
|
156
|
+
"else": { "not": { "required": ["options"] } }
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
},
|
|
120
161
|
"required_sections": {
|
|
121
162
|
"description": "Sections the venue requires, read by `tex/required-section`. Each is a `\\section` or `\\section*` whose title equals `title` once whitespace is collapsed. `position: \"last\"`: after every section of the body (the ones before the appendix and the bibliography); it may stand after the bibliography. A child preset's list replaces its parent's.",
|
|
122
163
|
"type": "array",
|
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
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* `npm run refresh-deadlines [-- --presets <dir>] [-- --summary <file.md>]`, which
|
|
4
|
+
* .github/workflows/deadlines.yml runs from source (no `dist/`). The composition root: the command
|
|
5
|
+
* line, the files, the clock, and HotCRP's adapter plugged into the port `refresh.ts` reads.
|
|
6
|
+
*/
|
|
7
|
+
import { readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { hotcrpDeadlines } from "#src/adapters/hotcrp/index";
|
|
10
|
+
import { presetsDir } from "#src/package-dirs";
|
|
11
|
+
import { isMain } from "../../skills/paper-pipeline/scripts/consumer.mjs";
|
|
12
|
+
import { runRefresh, type PresetFiles } from "./refresh.ts";
|
|
13
|
+
|
|
14
|
+
/** `--presets <dir>` and `--summary <file>`; a flag without a value is no value. */
|
|
15
|
+
export function argsOf(argv: readonly string[]) {
|
|
16
|
+
const value = (flag: string): string | null =>
|
|
17
|
+
argv.includes(flag) ? (argv[argv.indexOf(flag) + 1] ?? null) : null;
|
|
18
|
+
return {
|
|
19
|
+
presets: value("--presets") ?? presetsDir(),
|
|
20
|
+
summary: value("--summary"),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The `*.jsonc` presets in `dir`, on disk. */
|
|
25
|
+
export const presetsIn = (dir: string): PresetFiles => ({
|
|
26
|
+
names: readdirSync(dir)
|
|
27
|
+
.filter((f) => f.endsWith(".jsonc"))
|
|
28
|
+
.toSorted(),
|
|
29
|
+
read: (name) => readFileSync(join(dir, name), "utf8"),
|
|
30
|
+
write: (name, text) => {
|
|
31
|
+
writeFileSync(join(dir, name), text);
|
|
32
|
+
},
|
|
33
|
+
path: (name) => join(dir, name),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
/** Where the pull request's body is written: the `--summary` file, or nowhere. */
|
|
37
|
+
export const summaryTo = (path: string | null) =>
|
|
38
|
+
path === null
|
|
39
|
+
? null
|
|
40
|
+
: (text: string) => {
|
|
41
|
+
writeFileSync(path, text);
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// `isMain`, not a comparison with `file://${argv[1]}`: that is false through a symlink (consumer.mjs).
|
|
45
|
+
if (isMain(import.meta.url)) {
|
|
46
|
+
const args = argsOf(process.argv.slice(2));
|
|
47
|
+
process.exit(
|
|
48
|
+
await runRefresh({
|
|
49
|
+
presets: presetsIn(args.presets),
|
|
50
|
+
portal: (site) => hotcrpDeadlines({ url: site }),
|
|
51
|
+
today: new Date().toISOString().slice(0, 10), // the clock, read at the edge
|
|
52
|
+
log: console.log,
|
|
53
|
+
summary: summaryTo(args.summary),
|
|
54
|
+
}),
|
|
55
|
+
);
|
|
56
|
+
}
|