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.
Files changed (151) 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 +51 -11
  10. package/dist/adapters/hotcrp/portal.io.js.map +1 -1
  11. package/dist/adapters/hotcrp/response.d.ts +12 -1
  12. package/dist/adapters/hotcrp/response.d.ts.map +1 -1
  13. package/dist/adapters/hotcrp/response.js +52 -3
  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 +21 -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 +25 -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-fields.d.ts +77 -0
  53. package/dist/domain/submission-fields.d.ts.map +1 -0
  54. package/dist/domain/submission-fields.js +127 -0
  55. package/dist/domain/submission-fields.js.map +1 -0
  56. package/dist/domain/submission.d.ts +40 -0
  57. package/dist/domain/submission.d.ts.map +1 -1
  58. package/dist/domain/submission.js +68 -0
  59. package/dist/domain/submission.js.map +1 -1
  60. package/dist/lib/paper-config.d.ts +1 -1
  61. package/dist/lib/paper-config.d.ts.map +1 -1
  62. package/dist/lib/paper-config.js +8 -4
  63. package/dist/lib/paper-config.js.map +1 -1
  64. package/dist/paper-settings.d.ts +8 -1
  65. package/dist/paper-settings.d.ts.map +1 -1
  66. package/dist/paper-settings.js +14 -5
  67. package/dist/paper-settings.js.map +1 -1
  68. package/dist/ports/latex.d.ts +23 -0
  69. package/dist/ports/latex.d.ts.map +1 -1
  70. package/dist/ports/submission-portal.d.ts +41 -2
  71. package/dist/ports/submission-portal.d.ts.map +1 -1
  72. package/dist/ports/venue-deadlines.d.ts +1 -1
  73. package/dist/presets.d.ts +3 -0
  74. package/dist/presets.d.ts.map +1 -1
  75. package/dist/presets.js +3 -1
  76. package/dist/presets.js.map +1 -1
  77. package/dist/submission.d.ts +68 -11
  78. package/dist/submission.d.ts.map +1 -1
  79. package/dist/submission.js +441 -63
  80. package/dist/submission.js.map +1 -1
  81. package/dist/tex-requirements.d.ts +3 -0
  82. package/dist/tex-requirements.d.ts.map +1 -1
  83. package/dist/tex-requirements.js +30 -2
  84. package/dist/tex-requirements.js.map +1 -1
  85. package/docs/configuration.md +2 -1
  86. package/docs/rules/cycle/record.md +9 -6
  87. package/docs/submission.md +73 -51
  88. package/eslint.config.mjs +3 -0
  89. package/lib/paper-config.ts +10 -4
  90. package/package.json +6 -1
  91. package/presets/msr.jsonc +75 -7
  92. package/presets/msr.md +14 -10
  93. package/presets/venue-profile.schema.json +41 -0
  94. package/scripts/check.ts +1 -1
  95. package/scripts/refresh-deadlines/compare.test.ts +75 -0
  96. package/scripts/refresh-deadlines/compare.ts +101 -0
  97. package/scripts/refresh-deadlines/main.test.ts +140 -0
  98. package/scripts/refresh-deadlines/main.ts +56 -0
  99. package/scripts/refresh-deadlines/refresh.test.ts +287 -0
  100. package/scripts/refresh-deadlines/refresh.ts +183 -0
  101. package/skills/submission-portal/SKILL.md +92 -45
  102. package/skills/submission-portal/SKILL.md.spec.ts +94 -44
  103. package/skills/submission-portal/submission-portal.test.ts +4 -73
  104. package/src/adapters/hotcrp/cassette/README.md +11 -10
  105. package/src/adapters/hotcrp/cassette/deadlines-msr2027.html +9 -9
  106. package/src/adapters/hotcrp/cassette/get-searchcompletion.json +100 -0
  107. package/src/adapters/hotcrp/deadlines.io.test.ts +2 -2
  108. package/src/adapters/hotcrp/deadlines.test.ts +3 -3
  109. package/src/adapters/hotcrp/index.ts +16 -3
  110. package/src/adapters/hotcrp/portal.io.ts +81 -17
  111. package/src/adapters/hotcrp/portal.test.ts +208 -3
  112. package/src/adapters/hotcrp/response.ts +66 -4
  113. package/src/adapters/hotcrp/site.ts +33 -0
  114. package/src/adapters/hotcrp/token.test.ts +69 -0
  115. package/src/adapters/hotcrp/token.ts +51 -0
  116. package/src/adapters/latex/abstract.test.ts +280 -0
  117. package/src/adapters/latex/abstract.ts +272 -0
  118. package/src/adapters/latex/headings.ts +5 -4
  119. package/src/adapters/latex/index.test.ts +31 -1
  120. package/src/adapters/latex/index.ts +2 -0
  121. package/src/adapters/latex/parse.ts +64 -3
  122. package/src/cli.test.ts +20 -8
  123. package/src/cli.ts +27 -13
  124. package/src/cycle-rules.test.ts +6 -6
  125. package/src/cycle-rules.ts +7 -4
  126. package/src/domain/deadline.test.ts +31 -2
  127. package/src/domain/deadline.ts +42 -16
  128. package/src/domain/portal-token.test.ts +55 -0
  129. package/src/domain/portal-token.ts +63 -0
  130. package/src/domain/submission-fields.test.ts +183 -0
  131. package/src/domain/submission-fields.ts +190 -0
  132. package/src/domain/submission.test.ts +88 -0
  133. package/src/domain/submission.ts +110 -0
  134. package/src/new-paper.test.ts +2 -0
  135. package/src/paper-settings.test.ts +26 -0
  136. package/src/paper-settings.ts +25 -5
  137. package/src/ports/latex.ts +25 -0
  138. package/src/ports/submission-portal.ts +46 -1
  139. package/src/ports/venue-deadlines.ts +1 -1
  140. package/src/presets.test.ts +8 -5
  141. package/src/presets.ts +12 -2
  142. package/src/submission-fields-run.test.ts +247 -0
  143. package/src/submission-new.test.ts +569 -0
  144. package/src/submission-show.test.ts +210 -0
  145. package/src/submission-update.test.ts +166 -0
  146. package/src/submission.test.ts +140 -518
  147. package/src/submission.ts +667 -81
  148. package/src/tex-requirements.test.ts +21 -0
  149. package/src/tex-requirements.ts +67 -2
  150. package/scripts/refresh-deadlines.test.ts +0 -431
  151. 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`: 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
 
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 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
@@ -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
+ }