paperlint 7.0.0 → 7.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +3 -0
- package/.github/workflows/deadlines.yml +100 -0
- package/README.md +24 -11
- package/dist/adapters/hotcrp/deadlines.d.ts +8 -0
- package/dist/adapters/hotcrp/deadlines.d.ts.map +1 -0
- package/dist/adapters/hotcrp/deadlines.io.d.ts +6 -0
- package/dist/adapters/hotcrp/deadlines.io.d.ts.map +1 -0
- package/dist/adapters/hotcrp/deadlines.io.js +26 -0
- package/dist/adapters/hotcrp/deadlines.io.js.map +1 -0
- package/dist/adapters/hotcrp/deadlines.js +134 -0
- package/dist/adapters/hotcrp/deadlines.js.map +1 -0
- package/dist/adapters/hotcrp/index.d.ts +2 -0
- package/dist/adapters/hotcrp/index.d.ts.map +1 -1
- package/dist/adapters/hotcrp/index.js +2 -0
- package/dist/adapters/hotcrp/index.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +42 -7
- package/dist/cli.js.map +1 -1
- package/dist/cycle-rules.d.ts +24 -0
- package/dist/cycle-rules.d.ts.map +1 -0
- package/dist/cycle-rules.js +212 -0
- package/dist/cycle-rules.js.map +1 -0
- package/dist/domain/body-pages.d.ts +29 -0
- package/dist/domain/body-pages.d.ts.map +1 -1
- package/dist/domain/body-pages.js +25 -0
- package/dist/domain/body-pages.js.map +1 -1
- package/dist/domain/cycle.d.ts +148 -0
- package/dist/domain/cycle.d.ts.map +1 -0
- package/dist/domain/cycle.js +281 -0
- package/dist/domain/cycle.js.map +1 -0
- package/dist/domain/deadline.d.ts +128 -0
- package/dist/domain/deadline.d.ts.map +1 -0
- package/dist/domain/deadline.js +281 -0
- package/dist/domain/deadline.js.map +1 -0
- package/dist/eslint-rules/cycle-stage.d.ts +39 -0
- package/dist/eslint-rules/cycle-stage.d.ts.map +1 -0
- package/dist/eslint-rules/cycle-stage.js +119 -0
- package/dist/eslint-rules/cycle-stage.js.map +1 -0
- package/dist/lib/paper-config.d.ts.map +1 -1
- package/dist/lib/paper-config.js +2 -0
- package/dist/lib/paper-config.js.map +1 -1
- package/dist/paper-includes.d.ts +3 -0
- package/dist/paper-includes.d.ts.map +1 -1
- package/dist/paper-includes.js +17 -2
- package/dist/paper-includes.js.map +1 -1
- package/dist/paper-settings.d.ts +7 -0
- package/dist/paper-settings.d.ts.map +1 -1
- package/dist/paper-settings.js +61 -8
- package/dist/paper-settings.js.map +1 -1
- package/dist/ports/venue-deadlines.d.ts +14 -0
- package/dist/ports/venue-deadlines.d.ts.map +1 -0
- package/dist/ports/venue-deadlines.js +2 -0
- package/dist/ports/venue-deadlines.js.map +1 -0
- package/dist/presets.d.ts +22 -3
- package/dist/presets.d.ts.map +1 -1
- package/dist/presets.js +26 -17
- package/dist/presets.js.map +1 -1
- package/dist/submission.d.ts +16 -0
- package/dist/submission.d.ts.map +1 -1
- package/dist/submission.js +59 -16
- package/dist/submission.js.map +1 -1
- package/dist/tex-requirements.d.ts +12 -0
- package/dist/tex-requirements.d.ts.map +1 -1
- package/dist/tex-requirements.js +26 -3
- package/dist/tex-requirements.js.map +1 -1
- package/dist/tex-venue-rules.d.ts +24 -2
- package/dist/tex-venue-rules.d.ts.map +1 -1
- package/dist/tex-venue-rules.js +98 -10
- package/dist/tex-venue-rules.js.map +1 -1
- package/dist/venue-rules.d.ts +12 -1
- package/dist/venue-rules.d.ts.map +1 -1
- package/dist/venue-rules.js +81 -17
- package/dist/venue-rules.js.map +1 -1
- package/docs/configuration.md +140 -12
- package/docs/e2e.md +14 -12
- package/docs/rules/cycle/evidence.md +87 -0
- package/docs/rules/cycle/port-done.md +75 -0
- package/docs/rules/cycle/record.md +152 -0
- package/docs/rules/cycle/stage.md +85 -0
- package/docs/rules/format/page-limit.md +18 -1
- package/docs/rules/tex/template.md +25 -5
- package/docs/rules/tex/venue-leftover.md +13 -0
- package/docs/rules.md +68 -55
- package/docs/submission.md +10 -1
- package/eslint-rules/cycle-stage.test.mjs +131 -0
- package/eslint-rules/cycle-stage.ts +141 -0
- package/eslint.config.mjs +41 -1
- package/fixtures/build-e2e/msr/PIPELINE-STATUS.md +3 -0
- package/fixtures/build-e2e/msr/expect.json +18 -0
- package/fixtures/build-e2e/msr/paper.tex +49 -0
- package/fixtures/build-e2e/msr/paperlint.json +1 -0
- package/fixtures/build-e2e/msr-appendix-after/PIPELINE-STATUS.md +3 -0
- package/fixtures/build-e2e/msr-appendix-after/expect.json +20 -0
- package/fixtures/build-e2e/msr-appendix-after/paper.tex +48 -0
- package/fixtures/build-e2e/msr-appendix-after/paperlint.json +1 -0
- package/lib/paper-config.ts +3 -0
- package/package.json +1 -1
- package/presets/aidc.jsonc +24 -3
- package/presets/aidc.md +6 -3
- package/presets/ieee-conference.md +1 -1
- package/presets/msr.jsonc +95 -0
- package/presets/msr.md +98 -0
- package/presets/venue-profile.schema.json +64 -0
- package/scripts/check.ts +4 -0
- package/scripts/refresh-deadlines.test.ts +431 -0
- package/scripts/refresh-deadlines.ts +387 -0
- package/skills/camera-ready/SKILL.md +8 -1
- package/skills/camera-ready/SKILL.md.spec.ts +7 -0
- package/skills/submission-portal/SKILL.md +2 -2
- package/skills/submission-portal/SKILL.md.spec.ts +1 -1
- package/skills/submit-paper/SKILL.md +9 -1
- package/skills/submit-paper/SKILL.md.spec.ts +8 -0
- package/src/adapters/hotcrp/cassette/README.md +22 -0
- package/src/adapters/hotcrp/cassette/deadlines-aidc26.html +17 -0
- package/src/adapters/hotcrp/cassette/deadlines-fse2027.html +24 -0
- package/src/adapters/hotcrp/cassette/deadlines-icse2027.html +25 -0
- package/src/adapters/hotcrp/cassette/deadlines-msr2027.html +25 -0
- package/src/adapters/hotcrp/cassette/deadlines-nosuch.html +18 -0
- package/src/adapters/hotcrp/cassette/deadlines-sosp26.html +26 -0
- package/src/adapters/hotcrp/deadlines.io.test.ts +86 -0
- package/src/adapters/hotcrp/deadlines.io.ts +27 -0
- package/src/adapters/hotcrp/deadlines.test.ts +170 -0
- package/src/adapters/hotcrp/deadlines.ts +161 -0
- package/src/adapters/hotcrp/index.ts +2 -0
- package/src/cli.test.ts +9 -0
- package/src/cli.ts +50 -6
- package/src/cycle-rules.test.ts +278 -0
- package/src/cycle-rules.ts +273 -0
- package/src/domain/body-pages.test.ts +144 -0
- package/src/domain/body-pages.ts +61 -1
- package/src/domain/cycle.test.ts +456 -0
- package/src/domain/cycle.ts +432 -0
- package/src/domain/deadline.test.ts +386 -0
- package/src/domain/deadline.ts +472 -0
- package/src/new-paper.test.ts +28 -0
- package/src/paper-includes.test.ts +26 -0
- package/src/paper-includes.ts +20 -2
- package/src/paper-settings-commands.test.ts +1 -1
- package/src/paper-settings.test.ts +167 -0
- package/src/paper-settings.ts +85 -11
- package/src/ports/venue-deadlines.ts +14 -0
- package/src/preset-card-rule.test.ts +1 -0
- package/src/presets.test.ts +171 -1
- package/src/presets.ts +65 -15
- package/src/rule-cases.test.ts +144 -0
- package/src/submission.test.ts +147 -4
- package/src/submission.ts +100 -18
- package/src/tex-requirements.ts +52 -4
- package/src/tex-venue-rules.test.ts +237 -0
- package/src/tex-venue-rules.ts +130 -15
- package/src/venue-rules.test.ts +255 -2
- package/src/venue-rules.ts +115 -22
|
@@ -34,6 +34,16 @@
|
|
|
34
34
|
"type": "string",
|
|
35
35
|
"minLength": 1
|
|
36
36
|
},
|
|
37
|
+
"template_forbids": {
|
|
38
|
+
"description": "Class options the venue's call forbids in the \\documentclass line (`[\"compsoc\", \"compsocconf\"]`: MSR's «without including the compsoc or compsocconf options»). `tex/template` reports each one a paper's class line carries. Read with the `template` of the same file, and only with it: a child's `template` replaces both.",
|
|
39
|
+
"type": "array",
|
|
40
|
+
"minItems": 1,
|
|
41
|
+
"uniqueItems": true,
|
|
42
|
+
"items": {
|
|
43
|
+
"type": "string",
|
|
44
|
+
"minLength": 1
|
|
45
|
+
}
|
|
46
|
+
},
|
|
37
47
|
"blind": {
|
|
38
48
|
"description": "The venue reviews double-blind: its call says submissions must be anonymized. Read by `anonymity/identity`, which then requires the paper's `identity` and finds none of it in the PDF. Set only where the venue says so, with the quote. A child's value replaces its parent's.",
|
|
39
49
|
"type": "boolean"
|
|
@@ -47,6 +57,11 @@
|
|
|
47
57
|
"minLength": 1
|
|
48
58
|
}
|
|
49
59
|
},
|
|
60
|
+
"mentions": {
|
|
61
|
+
"description": "When the venue's `name` and `aliases` count as naming it in a paper's text (`tex/venue-leftover`). Absent: any whole-word use. `with-year`: only with a year beside the name — `MSR 2027`, `MSR'27`, `MSR~2027` — for a name that is also a common acronym (MSR: Microsoft Research, or an acronym a paper defines), where a bare use names something else far more often than the venue. A child's value replaces its parent's.",
|
|
62
|
+
"type": "string",
|
|
63
|
+
"enum": ["with-year"]
|
|
64
|
+
},
|
|
50
65
|
"portal": {
|
|
51
66
|
"description": "Where the venue takes submissions, read by `paperlint submission`: the portal's kind (an adapter exists for `hotcrp`) and the site's base URL, the one its pages and `/api/` hang off. A paper says which submission is its own with `submission: { id }` in its paperlint.json. A child's value replaces its parent's.",
|
|
52
67
|
"type": "object",
|
|
@@ -63,6 +78,45 @@
|
|
|
63
78
|
}
|
|
64
79
|
}
|
|
65
80
|
},
|
|
81
|
+
"deadlines": {
|
|
82
|
+
"description": "The edition's deadlines as they were READ — the portal's readings, rewritten by the scheduled refresh in paperlint's repository when the portal changes them (a patch release), each with the page and the day it was read. A paper whose current cycle names this preset derives them; its own record adds the call's readings and overrides on top, and the earlier reading binds. `what`: registration (abstract registration — a call's \"abstract\" date), submission (the full paper), resubmission (updates to a completed submission), notification, camera-ready. At most one reading per kind and source. A child's list replaces its parent's.",
|
|
83
|
+
"type": "array",
|
|
84
|
+
"items": {
|
|
85
|
+
"type": "object",
|
|
86
|
+
"additionalProperties": false,
|
|
87
|
+
"required": ["what", "at", "source", "url", "read"],
|
|
88
|
+
"properties": {
|
|
89
|
+
"what": {
|
|
90
|
+
"type": "string",
|
|
91
|
+
"enum": [
|
|
92
|
+
"registration",
|
|
93
|
+
"submission",
|
|
94
|
+
"resubmission",
|
|
95
|
+
"notification",
|
|
96
|
+
"camera-ready"
|
|
97
|
+
]
|
|
98
|
+
},
|
|
99
|
+
"at": {
|
|
100
|
+
"description": "An instant in UTC, as the portal publishes it: 2026-10-20T04:00:00Z.",
|
|
101
|
+
"type": "string",
|
|
102
|
+
"pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z$"
|
|
103
|
+
},
|
|
104
|
+
"source": {
|
|
105
|
+
"type": "string",
|
|
106
|
+
"enum": ["portal", "call"]
|
|
107
|
+
},
|
|
108
|
+
"url": {
|
|
109
|
+
"type": "string",
|
|
110
|
+
"pattern": "^https?://[^\\s/]+[^\\s]*$"
|
|
111
|
+
},
|
|
112
|
+
"read": {
|
|
113
|
+
"description": "The day the page was read, YYYY-MM-DD.",
|
|
114
|
+
"type": "string",
|
|
115
|
+
"pattern": "^\\d{4}-\\d{2}-\\d{2}$"
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
},
|
|
66
120
|
"required_sections": {
|
|
67
121
|
"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.",
|
|
68
122
|
"type": "array",
|
|
@@ -86,6 +140,9 @@
|
|
|
86
140
|
"description": "What the venue's call for papers sets. Every key is optional; a child preset's key replaces its parent's, and a child's kind replaces that kind.",
|
|
87
141
|
"type": "object",
|
|
88
142
|
"additionalProperties": false,
|
|
143
|
+
"dependencies": {
|
|
144
|
+
"appendix_in_body": ["body_ends_at"]
|
|
145
|
+
},
|
|
89
146
|
"properties": {
|
|
90
147
|
"page_size": {
|
|
91
148
|
"type": "string",
|
|
@@ -140,6 +197,10 @@
|
|
|
140
197
|
"type": "string",
|
|
141
198
|
"enum": ["references"]
|
|
142
199
|
},
|
|
200
|
+
"appendix_in_body": {
|
|
201
|
+
"description": "With `body_ends_at: references`: the venue counts its appendices in the body limit and allows reference pages that hold only references (MSR: «10 pages for the main text, inclusive of all figures, tables, appendices, etc. Two more pages containing only references are permitted.»). `format/page-limit` then counts as body the pages up to the references and, when the appendix starts after them, every page from the appendix (found by hyperref's anchor) to the last; the pages between are the reference pages, judged against `ref_pages_max`. An appendix before the references is body and ends nothing.",
|
|
202
|
+
"type": "boolean"
|
|
203
|
+
},
|
|
143
204
|
"page_limits_not_gated": {
|
|
144
205
|
"type": "object"
|
|
145
206
|
},
|
|
@@ -347,6 +408,9 @@
|
|
|
347
408
|
}
|
|
348
409
|
},
|
|
349
410
|
"required": ["type"],
|
|
411
|
+
"dependencies": {
|
|
412
|
+
"template_forbids": ["template"]
|
|
413
|
+
},
|
|
350
414
|
"allOf": [
|
|
351
415
|
{
|
|
352
416
|
"if": {
|
package/scripts/check.ts
CHANGED
|
@@ -202,6 +202,10 @@ export const NOT_COVERED: Readonly<Record<string, string>> = {
|
|
|
202
202
|
validate:
|
|
203
203
|
"pr-title.yml checks the pull request's TITLE (the squash commit semantic-release reads), " +
|
|
204
204
|
"which exists only on GitHub, not in the tree.",
|
|
205
|
+
refresh:
|
|
206
|
+
"deadlines.yml reads the venue presets' portals over the network once a day and opens a pull " +
|
|
207
|
+
"request when a deadline moved; npm test replays the recorded portal pages through " +
|
|
208
|
+
"scripts/refresh-deadlines.test.ts, and nothing local should read the live portals on every check.",
|
|
205
209
|
};
|
|
206
210
|
|
|
207
211
|
/**
|
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The scheduled refresh of the shipped presets' deadlines, on the HotCRP pages recorded in
|
|
3
|
+
* `src/adapters/hotcrp/cassette/` — never the network. Both halves: an unchanged portal leaves the
|
|
4
|
+
* preset's bytes alone (no pull request every day), a moved deadline rewrites that reading and its
|
|
5
|
+
* `read` day only; a portal that cannot be read is REPORTED and does not fail the run.
|
|
6
|
+
*/
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import { execFile } from "node:child_process";
|
|
9
|
+
import { promisify } from "node:util";
|
|
10
|
+
import {
|
|
11
|
+
mkdtempSync,
|
|
12
|
+
readFileSync,
|
|
13
|
+
realpathSync,
|
|
14
|
+
writeFileSync,
|
|
15
|
+
} from "node:fs";
|
|
16
|
+
import {
|
|
17
|
+
createServer,
|
|
18
|
+
type IncomingMessage,
|
|
19
|
+
type Server,
|
|
20
|
+
type ServerResponse,
|
|
21
|
+
} from "node:http";
|
|
22
|
+
import { tmpdir } from "node:os";
|
|
23
|
+
import { dirname, join } from "node:path";
|
|
24
|
+
import { fileURLToPath } from "node:url";
|
|
25
|
+
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
|
26
|
+
import { parseDeadlinesPage } from "../src/adapters/hotcrp/index.ts";
|
|
27
|
+
import { err } from "../src/domain/result.ts";
|
|
28
|
+
import { parsePreset } from "../src/tex-requirements.ts";
|
|
29
|
+
import {
|
|
30
|
+
argsOf,
|
|
31
|
+
main,
|
|
32
|
+
refreshPreset,
|
|
33
|
+
refreshReadings,
|
|
34
|
+
withDeadlines,
|
|
35
|
+
type Outcome,
|
|
36
|
+
} from "./refresh-deadlines.ts";
|
|
37
|
+
|
|
38
|
+
const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
39
|
+
const PRESETS = join(ROOT, "presets");
|
|
40
|
+
const CASSETTE = join(ROOT, "src", "adapters", "hotcrp", "cassette");
|
|
41
|
+
const MSR = join(PRESETS, "msr.jsonc");
|
|
42
|
+
const msrText = readFileSync(MSR, "utf8");
|
|
43
|
+
|
|
44
|
+
const recorded =
|
|
45
|
+
(site: string, status = 200) =>
|
|
46
|
+
async () =>
|
|
47
|
+
Promise.resolve(
|
|
48
|
+
parseDeadlinesPage(
|
|
49
|
+
status,
|
|
50
|
+
readFileSync(join(CASSETTE, `deadlines-${site}.html`), "utf8"),
|
|
51
|
+
),
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
const readings = (text: string, file = MSR) =>
|
|
55
|
+
parsePreset(text, file).deadlines ?? [];
|
|
56
|
+
|
|
57
|
+
/** The outcome, known to be of `kind` — or the test fails naming what it was. */
|
|
58
|
+
function as<K extends Outcome["kind"]>(
|
|
59
|
+
o: Outcome,
|
|
60
|
+
kind: K,
|
|
61
|
+
): Extract<Outcome, { kind: K }> {
|
|
62
|
+
const isKind = (x: Outcome): x is Extract<Outcome, { kind: K }> =>
|
|
63
|
+
x.kind === kind;
|
|
64
|
+
if (!isKind(o)) throw new Error(`expected ${kind}, got ${JSON.stringify(o)}`);
|
|
65
|
+
return o;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Each site's recorded page, by the URL the preset names. */
|
|
69
|
+
const bySite = (url: string) =>
|
|
70
|
+
url.includes("acsac") ? recorded("aidc26")() : recorded("msr2027")();
|
|
71
|
+
|
|
72
|
+
describe("refreshPreset on the recorded MSR 2027 page", () => {
|
|
73
|
+
test("the portal says what the preset says: unchanged — not one byte rewritten, read days kept", async () => {
|
|
74
|
+
const o = await refreshPreset({
|
|
75
|
+
file: MSR,
|
|
76
|
+
text: msrText,
|
|
77
|
+
read: recorded("msr2027"),
|
|
78
|
+
today: "2026-10-06",
|
|
79
|
+
});
|
|
80
|
+
expect(o).toEqual({
|
|
81
|
+
kind: "unchanged",
|
|
82
|
+
url: "https://msr2027.hotcrp.com/deadlines",
|
|
83
|
+
notes: [],
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("the portal moved the submission: that reading changes, with today's read day; the resubmission keeps its own", async () => {
|
|
88
|
+
const earlier = msrText.replace(
|
|
89
|
+
'"at": "2026-10-20T04:00:00Z"',
|
|
90
|
+
'"at": "2026-10-19T04:00:00Z"',
|
|
91
|
+
);
|
|
92
|
+
const o = as(
|
|
93
|
+
await refreshPreset({
|
|
94
|
+
file: MSR,
|
|
95
|
+
text: earlier,
|
|
96
|
+
read: recorded("msr2027"),
|
|
97
|
+
today: "2026-10-06",
|
|
98
|
+
}),
|
|
99
|
+
"changed",
|
|
100
|
+
);
|
|
101
|
+
expect(o.changes).toEqual([
|
|
102
|
+
"submission: 2026-10-19T04:00:00Z → 2026-10-20T04:00:00Z",
|
|
103
|
+
]);
|
|
104
|
+
expect(readings(o.text).map((r) => [r.what, r.at, r.read])).toEqual([
|
|
105
|
+
["submission", "2026-10-20T04:00:00Z", "2026-10-06"],
|
|
106
|
+
["resubmission", "2026-10-23T04:00:00Z", "2026-10-05"],
|
|
107
|
+
]);
|
|
108
|
+
// Only the deadlines block differs: the comments and every other key are the file's own.
|
|
109
|
+
expect(o.text).toBe(
|
|
110
|
+
msrText.replace(
|
|
111
|
+
'"read": "2026-10-05",\n },\n {\n "what": "resubmission"',
|
|
112
|
+
'"read": "2026-10-06",\n },\n {\n "what": "resubmission"',
|
|
113
|
+
),
|
|
114
|
+
);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe("refreshPreset — a preset with no readings yet, and the ones it skips", () => {
|
|
119
|
+
test("a preset with a portal and no `deadlines`: the block is inserted after `portal`, and parses", async () => {
|
|
120
|
+
const start = msrText.indexOf(" // Read from the portal");
|
|
121
|
+
const end = msrText.indexOf(" ],\n", start) + " ],\n".length;
|
|
122
|
+
const bare = msrText.slice(0, start) + msrText.slice(end);
|
|
123
|
+
const o = as(
|
|
124
|
+
await refreshPreset({
|
|
125
|
+
file: MSR,
|
|
126
|
+
text: bare,
|
|
127
|
+
read: recorded("msr2027"),
|
|
128
|
+
today: "2026-10-05",
|
|
129
|
+
}),
|
|
130
|
+
"changed",
|
|
131
|
+
);
|
|
132
|
+
expect(o.changes).toEqual([
|
|
133
|
+
"submission: added 2026-10-20T04:00:00Z",
|
|
134
|
+
"resubmission: added 2026-10-23T04:00:00Z",
|
|
135
|
+
]);
|
|
136
|
+
expect(readings(o.text)).toEqual(readings(msrText));
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("a preset without a portal, or with one paperlint has no adapter for: nothing to read", async () => {
|
|
140
|
+
const aisec = join(PRESETS, "aisec.jsonc");
|
|
141
|
+
const none = await refreshPreset({
|
|
142
|
+
file: aisec,
|
|
143
|
+
text: readFileSync(aisec, "utf8"),
|
|
144
|
+
read: recorded("msr2027"),
|
|
145
|
+
today: "2026-10-05",
|
|
146
|
+
});
|
|
147
|
+
expect(none).toEqual({ kind: "skipped", why: "no portal" });
|
|
148
|
+
const easychair = await refreshPreset({
|
|
149
|
+
file: MSR,
|
|
150
|
+
text: msrText.replace('"kind": "hotcrp"', '"kind": "easychair"'),
|
|
151
|
+
read: recorded("msr2027"),
|
|
152
|
+
today: "2026-10-05",
|
|
153
|
+
});
|
|
154
|
+
expect(easychair).toEqual({
|
|
155
|
+
kind: "skipped",
|
|
156
|
+
why: 'portal kind "easychair" is not supported; upload by hand',
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe("refreshPreset — a portal that cannot be read is reported, never guessed", () => {
|
|
162
|
+
test.each<[string, () => Promise<Outcome>, RegExp]>([
|
|
163
|
+
[
|
|
164
|
+
"a site that answers 404",
|
|
165
|
+
() =>
|
|
166
|
+
refreshPreset({
|
|
167
|
+
file: MSR,
|
|
168
|
+
text: msrText,
|
|
169
|
+
read: recorded("nosuch", 404),
|
|
170
|
+
today: "2026-10-05",
|
|
171
|
+
}),
|
|
172
|
+
/^HTTP 404: No such conference$/,
|
|
173
|
+
],
|
|
174
|
+
[
|
|
175
|
+
"a site that does not answer",
|
|
176
|
+
() =>
|
|
177
|
+
refreshPreset({
|
|
178
|
+
file: MSR,
|
|
179
|
+
text: msrText,
|
|
180
|
+
read: () =>
|
|
181
|
+
Promise.resolve(
|
|
182
|
+
err({ kind: "unreachable", detail: "connect ECONNREFUSED" }),
|
|
183
|
+
),
|
|
184
|
+
today: "2026-10-05",
|
|
185
|
+
}),
|
|
186
|
+
/^unreachable: connect ECONNREFUSED$/,
|
|
187
|
+
],
|
|
188
|
+
[
|
|
189
|
+
"a page that is not a HotCRP deadlines page",
|
|
190
|
+
() =>
|
|
191
|
+
refreshPreset({
|
|
192
|
+
file: MSR,
|
|
193
|
+
text: msrText,
|
|
194
|
+
read: () =>
|
|
195
|
+
Promise.resolve(
|
|
196
|
+
err({ kind: "malformed", httpStatus: 200, detail: "no init" }),
|
|
197
|
+
),
|
|
198
|
+
today: "2026-10-05",
|
|
199
|
+
}),
|
|
200
|
+
/^not the page expected \(HTTP 200\): no init$/,
|
|
201
|
+
],
|
|
202
|
+
])("%s", async (_what, run, why) => {
|
|
203
|
+
const o = as(await run(), "unreadable");
|
|
204
|
+
expect(o.url).toBe("https://msr2027.hotcrp.com/deadlines");
|
|
205
|
+
expect(o.why).toMatch(why);
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
const reading = (
|
|
210
|
+
what: "submission" | "resubmission" | "camera-ready",
|
|
211
|
+
at: string,
|
|
212
|
+
source: "portal" | "call" = "portal",
|
|
213
|
+
) => ({
|
|
214
|
+
what,
|
|
215
|
+
at,
|
|
216
|
+
source,
|
|
217
|
+
url: "https://x.example/deadlines",
|
|
218
|
+
read: "2026-10-01",
|
|
219
|
+
});
|
|
220
|
+
const page = (
|
|
221
|
+
deadlines: readonly {
|
|
222
|
+
what: "submission" | "camera-ready" | null;
|
|
223
|
+
at: string;
|
|
224
|
+
}[],
|
|
225
|
+
) => ({
|
|
226
|
+
now: 1,
|
|
227
|
+
open: true,
|
|
228
|
+
blind: true,
|
|
229
|
+
deadlines: deadlines.map((d) => ({ ...d, label: String(d.what) })),
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
describe("refreshReadings — what the page says, over what the preset said", () => {
|
|
233
|
+
test("a reading the portal no longer lists is kept and noted; a call's reading is never touched", () => {
|
|
234
|
+
const r = refreshReadings(
|
|
235
|
+
[
|
|
236
|
+
reading("submission", "2026-10-20T04:00:00Z"),
|
|
237
|
+
reading("submission", "2026-10-21T11:59:59Z", "call"),
|
|
238
|
+
reading("camera-ready", "2026-12-01T00:00:00Z"),
|
|
239
|
+
],
|
|
240
|
+
page([{ what: "submission", at: "2026-10-20T04:00:00Z" }]),
|
|
241
|
+
"https://x.example/deadlines",
|
|
242
|
+
"2026-10-06",
|
|
243
|
+
);
|
|
244
|
+
expect(r.changes).toEqual([]);
|
|
245
|
+
expect(r.notes).toEqual([
|
|
246
|
+
"camera-ready: no longer on the page — kept as read 2026-10-01",
|
|
247
|
+
]);
|
|
248
|
+
expect(r.readings).toEqual([
|
|
249
|
+
reading("submission", "2026-10-20T04:00:00Z"),
|
|
250
|
+
reading("submission", "2026-10-21T11:59:59Z", "call"),
|
|
251
|
+
reading("camera-ready", "2026-12-01T00:00:00Z"),
|
|
252
|
+
]);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test("a kind the page lists twice binds at the earlier instant; a label paperlint names no kind for is left out", () => {
|
|
256
|
+
const r = refreshReadings(
|
|
257
|
+
[],
|
|
258
|
+
page([
|
|
259
|
+
{ what: "submission", at: "2026-10-22T04:00:00Z" },
|
|
260
|
+
{ what: "submission", at: "2026-10-20T04:00:00Z" },
|
|
261
|
+
{ what: null, at: "2026-09-01T00:00:00Z" },
|
|
262
|
+
]),
|
|
263
|
+
"https://x.example/deadlines",
|
|
264
|
+
"2026-10-06",
|
|
265
|
+
);
|
|
266
|
+
expect(r.readings).toEqual([
|
|
267
|
+
{
|
|
268
|
+
what: "submission",
|
|
269
|
+
at: "2026-10-20T04:00:00Z",
|
|
270
|
+
source: "portal",
|
|
271
|
+
url: "https://x.example/deadlines",
|
|
272
|
+
read: "2026-10-06",
|
|
273
|
+
},
|
|
274
|
+
]);
|
|
275
|
+
expect(r.notes).toEqual([
|
|
276
|
+
"submission: the page lists 2026-10-20T04:00:00Z, 2026-10-22T04:00:00Z — the earliest is recorded",
|
|
277
|
+
]);
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
describe("withDeadlines — located on the JSONC's syntax tree, not by text", () => {
|
|
282
|
+
test.each([
|
|
283
|
+
["an object with no portal", "{}"],
|
|
284
|
+
["a list", "[]"],
|
|
285
|
+
["nothing", ""],
|
|
286
|
+
])(
|
|
287
|
+
"%s has no `portal` to put `deadlines` after: refused by name",
|
|
288
|
+
async (_what, text) => {
|
|
289
|
+
await expect(withDeadlines(text, "x.jsonc", [])).rejects.toThrow(
|
|
290
|
+
'x.jsonc: no "portal" to put "deadlines" after',
|
|
291
|
+
);
|
|
292
|
+
},
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
test("writing back the readings the file holds gives the file's own bytes", async () => {
|
|
296
|
+
expect(await withDeadlines(msrText, MSR, readings(msrText))).toBe(msrText);
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
// ── main, and the script as the workflow runs it ─────────────────────────────────────
|
|
301
|
+
|
|
302
|
+
const MSR_PAGE = readFileSync(join(CASSETTE, "deadlines-msr2027.html"), "utf8");
|
|
303
|
+
const server = createServer((req: IncomingMessage, res: ServerResponse) => {
|
|
304
|
+
const ok = req.url === "/msr/deadlines";
|
|
305
|
+
res.writeHead(ok ? 200 : 404, { "content-type": "text/html" });
|
|
306
|
+
res.end(ok ? MSR_PAGE : '<h1 id="h-title">No such conference</h1>');
|
|
307
|
+
});
|
|
308
|
+
const portOf = (s: Server): number => {
|
|
309
|
+
const a = s.address();
|
|
310
|
+
assert.ok(a !== null && typeof a === "object", "the server is not listening");
|
|
311
|
+
return a.port;
|
|
312
|
+
};
|
|
313
|
+
let base = "";
|
|
314
|
+
beforeAll(async () => {
|
|
315
|
+
await new Promise<void>((r) => server.listen(0, "127.0.0.1", r));
|
|
316
|
+
base = `http://127.0.0.1:${String(portOf(server))}`;
|
|
317
|
+
});
|
|
318
|
+
afterAll(
|
|
319
|
+
() =>
|
|
320
|
+
new Promise<void>((r) => {
|
|
321
|
+
server.close(() => {
|
|
322
|
+
r();
|
|
323
|
+
});
|
|
324
|
+
}),
|
|
325
|
+
);
|
|
326
|
+
|
|
327
|
+
/** A presets directory of the test's own: MSR pointing at the local server one day early, and a site that is gone. */
|
|
328
|
+
function presetsDirWith(): string {
|
|
329
|
+
const dir = realpathSync(mkdtempSync(join(tmpdir(), "deadlines-")));
|
|
330
|
+
const atServer = (path: string) =>
|
|
331
|
+
msrText.split("https://msr2027.hotcrp.com").join(`${base}/${path}`);
|
|
332
|
+
writeFileSync(
|
|
333
|
+
join(dir, "msr.jsonc"),
|
|
334
|
+
atServer("msr").replace(
|
|
335
|
+
'"at": "2026-10-20T04:00:00Z"',
|
|
336
|
+
'"at": "2026-10-19T04:00:00Z"',
|
|
337
|
+
),
|
|
338
|
+
);
|
|
339
|
+
writeFileSync(join(dir, "gone.jsonc"), atServer("gone"));
|
|
340
|
+
writeFileSync(
|
|
341
|
+
join(dir, "aisec.jsonc"),
|
|
342
|
+
readFileSync(join(PRESETS, "aisec.jsonc")),
|
|
343
|
+
);
|
|
344
|
+
writeFileSync(join(dir, "notes.md"), "not a preset\n");
|
|
345
|
+
return dir;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
describe("the script, as .github/workflows/deadlines.yml runs it", () => {
|
|
349
|
+
// A real `node` process: Node strips the types of the script and everything it imports (TypeScript's
|
|
350
|
+
// JSON parser, Prettier) cold — about a second alone, several on a loaded machine, past vitest's 5 s.
|
|
351
|
+
test(
|
|
352
|
+
"rewrites the moved deadline, reports the site it could not read, writes the summary — and exits 0",
|
|
353
|
+
{
|
|
354
|
+
timeout: 60_000,
|
|
355
|
+
},
|
|
356
|
+
async () => {
|
|
357
|
+
const dir = presetsDirWith();
|
|
358
|
+
const summary = join(dir, "summary.md");
|
|
359
|
+
// Asynchronously: the local server answers from THIS process, which a synchronous spawn blocks.
|
|
360
|
+
const r = await promisify(execFile)(process.execPath, [
|
|
361
|
+
join(ROOT, "scripts", "refresh-deadlines.ts"),
|
|
362
|
+
"--presets",
|
|
363
|
+
dir,
|
|
364
|
+
"--summary",
|
|
365
|
+
summary,
|
|
366
|
+
]); // rejects on a non-zero exit, with the output
|
|
367
|
+
expect(
|
|
368
|
+
readings(readFileSync(join(dir, "msr.jsonc"), "utf8"))[0]?.at,
|
|
369
|
+
).toBe("2026-10-20T04:00:00Z");
|
|
370
|
+
expect(r.stdout).toMatch(
|
|
371
|
+
/^::warning title=deadlines::gone\.jsonc: could not read http:\/\/127\.0\.0\.1:\d+\/gone\/deadlines — HTTP 404: No such conference$/m,
|
|
372
|
+
);
|
|
373
|
+
const md = readFileSync(summary, "utf8");
|
|
374
|
+
expect(md).toMatch(
|
|
375
|
+
/^- `msr\.jsonc` \(http:\/\/127\.0\.0\.1:\d+\/msr\/deadlines\)\n {2}- submission: 2026-10-19T04:00:00Z → 2026-10-20T04:00:00Z$/m,
|
|
376
|
+
);
|
|
377
|
+
expect(md).toMatch(/could not read/);
|
|
378
|
+
},
|
|
379
|
+
);
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
describe("main — what it prints and returns", () => {
|
|
383
|
+
test("nothing changed: one line, nothing written, exit 0", async () => {
|
|
384
|
+
const lines: string[] = [];
|
|
385
|
+
const written: string[] = [];
|
|
386
|
+
const code = await main({
|
|
387
|
+
presetsDir: PRESETS,
|
|
388
|
+
read: bySite,
|
|
389
|
+
today: "2026-10-06",
|
|
390
|
+
log: (l) => lines.push(l),
|
|
391
|
+
write: (f) => written.push(f),
|
|
392
|
+
summary: null,
|
|
393
|
+
});
|
|
394
|
+
expect(code).toBe(0);
|
|
395
|
+
expect(written).toEqual([]);
|
|
396
|
+
expect(lines.at(-1)).toMatch(
|
|
397
|
+
/^✓ no deadline changed: \d+ preset\(s\) with a portal read, \d+ skipped$/,
|
|
398
|
+
);
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
test("a preset that does not parse is a defect of the repository: exit 1, naming it", async () => {
|
|
402
|
+
const dir = realpathSync(mkdtempSync(join(tmpdir(), "deadlines-")));
|
|
403
|
+
writeFileSync(join(dir, "bad.jsonc"), "{ nope");
|
|
404
|
+
const lines: string[] = [];
|
|
405
|
+
const code = await main({
|
|
406
|
+
presetsDir: dir,
|
|
407
|
+
read: bySite,
|
|
408
|
+
today: "2026-10-06",
|
|
409
|
+
log: (l) => lines.push(l),
|
|
410
|
+
write: () => undefined,
|
|
411
|
+
summary: null,
|
|
412
|
+
});
|
|
413
|
+
expect(code).toBe(1);
|
|
414
|
+
expect(lines.join("\n")).toMatch(
|
|
415
|
+
/^::error title=deadlines::bad\.jsonc does not parse: /m,
|
|
416
|
+
);
|
|
417
|
+
});
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
describe("argsOf", () => {
|
|
421
|
+
test("defaults to the repository's presets and no summary; a flag without a value is no value", () => {
|
|
422
|
+
expect(argsOf([])).toEqual({
|
|
423
|
+
presets: join(ROOT, "presets"),
|
|
424
|
+
summary: null,
|
|
425
|
+
});
|
|
426
|
+
expect(argsOf(["--presets", "/p", "--summary"])).toEqual({
|
|
427
|
+
presets: "/p",
|
|
428
|
+
summary: null,
|
|
429
|
+
});
|
|
430
|
+
});
|
|
431
|
+
});
|