paperlint 7.1.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 +6 -0
- 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 +40 -5
- 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/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-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 +12 -0
- package/dist/presets.d.ts.map +1 -1
- package/dist/presets.js +19 -11
- package/dist/presets.js.map +1 -1
- package/dist/submission.d.ts +8 -0
- package/dist/submission.d.ts.map +1 -1
- package/dist/submission.js +41 -10
- 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 +19 -0
- package/dist/tex-requirements.js.map +1 -1
- package/dist/tex-venue-rules.d.ts +19 -0
- package/dist/tex-venue-rules.d.ts.map +1 -1
- package/dist/tex-venue-rules.js +69 -0
- package/dist/tex-venue-rules.js.map +1 -1
- package/dist/venue-rules.d.ts +10 -0
- package/dist/venue-rules.d.ts.map +1 -1
- package/dist/venue-rules.js +26 -10
- package/dist/venue-rules.js.map +1 -1
- package/docs/configuration.md +139 -11
- 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.md +48 -43
- package/docs/submission.md +5 -0
- package/eslint-rules/cycle-stage.test.mjs +131 -0
- package/eslint-rules/cycle-stage.ts +141 -0
- package/eslint.config.mjs +41 -1
- 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/msr.jsonc +25 -1
- package/presets/msr.md +5 -1
- package/presets/venue-profile.schema.json +39 -0
- package/scripts/check.ts +4 -0
- package/scripts/refresh-deadlines.test.ts +431 -0
- package/scripts/refresh-deadlines.ts +387 -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 +43 -4
- package/src/cycle-rules.test.ts +278 -0
- package/src/cycle-rules.ts +273 -0
- 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 +1 -0
- 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/presets.test.ts +105 -0
- package/src/presets.ts +43 -10
- package/src/rule-cases.test.ts +144 -0
- package/src/submission.test.ts +105 -1
- package/src/submission.ts +73 -12
- package/src/tex-requirements.ts +31 -0
- package/src/tex-venue-rules.test.ts +76 -0
- package/src/tex-venue-rules.ts +85 -0
- package/src/venue-rules.test.ts +82 -0
- package/src/venue-rules.ts +44 -12
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `hotcrpDeadlines` over a local server that answers `GET /deadlines` with a recorded page: the
|
|
3
|
+
* request paperlint sends (the path, no credential) and what comes back through the parser; and a
|
|
4
|
+
* site that does not answer at all.
|
|
5
|
+
*/
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import { readFileSync } from "node:fs";
|
|
8
|
+
import {
|
|
9
|
+
createServer,
|
|
10
|
+
type IncomingMessage,
|
|
11
|
+
type Server,
|
|
12
|
+
type ServerResponse,
|
|
13
|
+
} from "node:http";
|
|
14
|
+
import { afterAll, beforeAll, test } from "vitest";
|
|
15
|
+
import { hotcrpDeadlines } from "./deadlines.io.ts";
|
|
16
|
+
|
|
17
|
+
const page = readFileSync(
|
|
18
|
+
new URL("./cassette/deadlines-msr2027.html", import.meta.url),
|
|
19
|
+
"utf8",
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
const seen: { url: string; authorization: string | undefined }[] = [];
|
|
23
|
+
const server = createServer((req: IncomingMessage, res: ServerResponse) => {
|
|
24
|
+
seen.push({ url: req.url ?? "", authorization: req.headers.authorization });
|
|
25
|
+
if (req.url === "/deadlines") {
|
|
26
|
+
res.writeHead(200, { "content-type": "text/html" });
|
|
27
|
+
res.end(page);
|
|
28
|
+
} else {
|
|
29
|
+
res.writeHead(404, { "content-type": "text/html" });
|
|
30
|
+
res.end('<h1 id="h-title">No such conference</h1>');
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const portOf = (s: Server): number => {
|
|
35
|
+
const a = s.address();
|
|
36
|
+
assert.ok(a !== null && typeof a === "object", "the server is not listening");
|
|
37
|
+
return a.port;
|
|
38
|
+
};
|
|
39
|
+
const closeServer = (s: Server): Promise<void> =>
|
|
40
|
+
new Promise<void>((r) => {
|
|
41
|
+
s.close(() => {
|
|
42
|
+
r();
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
let base = "";
|
|
47
|
+
beforeAll(async () => {
|
|
48
|
+
await new Promise<void>((r) => server.listen(0, "127.0.0.1", r));
|
|
49
|
+
base = `http://127.0.0.1:${String(portOf(server))}`;
|
|
50
|
+
});
|
|
51
|
+
afterAll(() => closeServer(server));
|
|
52
|
+
|
|
53
|
+
test("GET <site>/deadlines, no credential; the page parses to the venue's deadlines", async () => {
|
|
54
|
+
const r = await hotcrpDeadlines({ url: base }).read();
|
|
55
|
+
assert.deepEqual(seen.at(-1), {
|
|
56
|
+
url: "/deadlines",
|
|
57
|
+
authorization: undefined,
|
|
58
|
+
});
|
|
59
|
+
assert.ok(r.ok, JSON.stringify(r));
|
|
60
|
+
assert.equal(r.value.open, true);
|
|
61
|
+
assert.deepEqual(
|
|
62
|
+
r.value.deadlines.map((d) => [d.what, d.at]),
|
|
63
|
+
[
|
|
64
|
+
["submission", "2026-10-20T04:00:00Z"],
|
|
65
|
+
["resubmission", "2026-10-23T04:00:00Z"],
|
|
66
|
+
],
|
|
67
|
+
);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("a site that is not a conference: refused with the page's title", async () => {
|
|
71
|
+
const r = await hotcrpDeadlines({ url: `${base}/nosuch` }).read();
|
|
72
|
+
assert.ok(!r.ok);
|
|
73
|
+
assert.equal(r.error.kind, "refused");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("a site that does not answer is unreachable, with the network's words", async () => {
|
|
77
|
+
const closed = createServer();
|
|
78
|
+
await new Promise<void>((r) => closed.listen(0, "127.0.0.1", r));
|
|
79
|
+
const port = portOf(closed);
|
|
80
|
+
await closeServer(closed);
|
|
81
|
+
const r = await hotcrpDeadlines({
|
|
82
|
+
url: `http://127.0.0.1:${String(port)}`,
|
|
83
|
+
}).read();
|
|
84
|
+
assert.ok(!r.ok);
|
|
85
|
+
assert.equal(r.error.kind, "unreachable");
|
|
86
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HotCRP's deadlines page over Node's `fetch`: `GET <site>/deadlines`, no token — the page is
|
|
3
|
+
* public, and a deadline is nobody's secret.
|
|
4
|
+
*/
|
|
5
|
+
import { messageOf } from "../../domain/text.ts";
|
|
6
|
+
import { err } from "../../domain/result.ts";
|
|
7
|
+
import type { VenueDeadlines } from "../../ports/venue-deadlines.ts";
|
|
8
|
+
import { parseDeadlinesPage } from "./deadlines.ts";
|
|
9
|
+
|
|
10
|
+
const READ_TIMEOUT_MS = 60_000;
|
|
11
|
+
|
|
12
|
+
/** The deadlines of the HotCRP site at `url` (no trailing slash). */
|
|
13
|
+
export function hotcrpDeadlines(o: { readonly url: string }): VenueDeadlines {
|
|
14
|
+
return {
|
|
15
|
+
async read() {
|
|
16
|
+
try {
|
|
17
|
+
const r = await fetch(`${o.url}/deadlines`, {
|
|
18
|
+
headers: { Accept: "text/html" },
|
|
19
|
+
signal: AbortSignal.timeout(READ_TIMEOUT_MS),
|
|
20
|
+
});
|
|
21
|
+
return parseDeadlinesPage(r.status, await r.text());
|
|
22
|
+
} catch (e) {
|
|
23
|
+
return err({ kind: "unreachable", detail: messageOf(e) });
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The deadlines page parser on the pages five live HotCRP sites answered on 2026-10-05
|
|
3
|
+
* (`cassette/deadlines-*.html`, recorded whole), and on a site that does not exist. Each expected
|
|
4
|
+
* value was read off the page's text by hand before the parser existed.
|
|
5
|
+
*/
|
|
6
|
+
import { readFileSync } from "node:fs";
|
|
7
|
+
import { describe, expect, it } from "vitest";
|
|
8
|
+
import { parseDeadlinesPage, whatOf } from "./index.ts";
|
|
9
|
+
|
|
10
|
+
type Site = "msr2027" | "aidc26" | "sosp26" | "icse2027" | "fse2027" | "nosuch";
|
|
11
|
+
const page = (site: Site): string =>
|
|
12
|
+
readFileSync(
|
|
13
|
+
new URL(`./cassette/deadlines-${site}.html`, import.meta.url),
|
|
14
|
+
"utf8",
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
const read = (site: Site, status = 200) => {
|
|
18
|
+
const r = parseDeadlinesPage(status, page(site));
|
|
19
|
+
if (!r.ok) throw new Error(JSON.stringify(r.error));
|
|
20
|
+
return r.value;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
describe("parseDeadlinesPage", () => {
|
|
24
|
+
it("a conference with a resubmission deadline: the list carries it, the status object does not", () => {
|
|
25
|
+
expect(read("msr2027")).toEqual({
|
|
26
|
+
now: 1791194488.329003,
|
|
27
|
+
open: true,
|
|
28
|
+
blind: true,
|
|
29
|
+
deadlines: [
|
|
30
|
+
{
|
|
31
|
+
what: "submission",
|
|
32
|
+
label: "Submission deadline",
|
|
33
|
+
at: "2026-10-20T04:00:00Z",
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
what: "resubmission",
|
|
37
|
+
label: "Resubmission deadline",
|
|
38
|
+
at: "2026-10-23T04:00:00Z",
|
|
39
|
+
},
|
|
40
|
+
],
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("a workshop past its submission deadline, with a final-version deadline only the status object names", () => {
|
|
45
|
+
expect(read("aidc26")).toEqual({
|
|
46
|
+
now: 1791194489.473936,
|
|
47
|
+
open: true,
|
|
48
|
+
blind: true,
|
|
49
|
+
deadlines: [
|
|
50
|
+
{
|
|
51
|
+
what: "submission",
|
|
52
|
+
label: "Submission deadline",
|
|
53
|
+
at: "2026-10-03T11:59:59Z",
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
what: "camera-ready",
|
|
57
|
+
label: "camera-ready",
|
|
58
|
+
at: "2026-10-31T11:59:59Z",
|
|
59
|
+
},
|
|
60
|
+
],
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe("parseDeadlinesPage — every kind at once", () => {
|
|
66
|
+
it("registration, submission, a late resubmission and a final version: sorted by time", () => {
|
|
67
|
+
expect(read("sosp26").deadlines).toEqual([
|
|
68
|
+
{
|
|
69
|
+
what: "registration",
|
|
70
|
+
label: "Registration deadline",
|
|
71
|
+
at: "2026-03-27T11:59:59Z",
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
what: "submission",
|
|
75
|
+
label: "Submission deadline",
|
|
76
|
+
at: "2026-04-02T11:59:59Z",
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
what: "resubmission",
|
|
80
|
+
label: "Resubmission deadline",
|
|
81
|
+
at: "2026-08-14T23:59:59Z",
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
what: "camera-ready",
|
|
85
|
+
label: "camera-ready",
|
|
86
|
+
at: "2026-08-29T11:59:59Z",
|
|
87
|
+
},
|
|
88
|
+
]);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe("parseDeadlinesPage — a closed round, AoE, and pages that are not deadlines", () => {
|
|
93
|
+
it("a round that is not open: `open` is false and the dates still read", () => {
|
|
94
|
+
const r = read("icse2027");
|
|
95
|
+
expect(r.open).toBe(false);
|
|
96
|
+
expect(r.deadlines.map((d) => [d.what, d.at])).toEqual([
|
|
97
|
+
["registration", "2026-06-24T11:59:59Z"],
|
|
98
|
+
["submission", "2026-07-09T11:59:00Z"],
|
|
99
|
+
]);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe("parseDeadlinesPage — AoE and non-pages", () => {
|
|
104
|
+
it("the AoE a page prints is already an instant in UTC: «Oct 3, 11:59 PM AoE» is Oct 4, 11:59 UTC", () => {
|
|
105
|
+
expect(read("fse2027").deadlines).toEqual([
|
|
106
|
+
{
|
|
107
|
+
what: "submission",
|
|
108
|
+
label: "Submission deadline",
|
|
109
|
+
at: "2026-10-04T11:59:00Z",
|
|
110
|
+
},
|
|
111
|
+
]);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("a status object with no submission round: closed, blindness unknown, no deadlines", () => {
|
|
115
|
+
expect(
|
|
116
|
+
parseDeadlinesPage(200, 'hotcrp.init_deadlines({"now": 1})'),
|
|
117
|
+
).toEqual({
|
|
118
|
+
ok: true,
|
|
119
|
+
value: { now: 1, open: false, blind: null, deadlines: [] },
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("a refusal without a page title carries no message", () => {
|
|
124
|
+
expect(parseDeadlinesPage(503, "<html>down</html>")).toEqual({
|
|
125
|
+
ok: false,
|
|
126
|
+
error: { kind: "refused", httpStatus: 503, messages: [] },
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("a site that does not exist: refused with the page's title", () => {
|
|
131
|
+
expect(parseDeadlinesPage(404, page("nosuch"))).toEqual({
|
|
132
|
+
ok: false,
|
|
133
|
+
error: {
|
|
134
|
+
kind: "refused",
|
|
135
|
+
httpStatus: 404,
|
|
136
|
+
messages: [
|
|
137
|
+
{ message: "No such conference", field: null, status: null },
|
|
138
|
+
],
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("a 200 page that is not a deadlines page, and a status object of another shape: malformed", () => {
|
|
144
|
+
const detail: unknown = expect.stringContaining(
|
|
145
|
+
"no `hotcrp.init_deadlines(…)` call",
|
|
146
|
+
);
|
|
147
|
+
expect(parseDeadlinesPage(200, "<html><body>hello</body></html>")).toEqual({
|
|
148
|
+
ok: false,
|
|
149
|
+
error: {
|
|
150
|
+
kind: "malformed",
|
|
151
|
+
httpStatus: 200,
|
|
152
|
+
detail,
|
|
153
|
+
},
|
|
154
|
+
});
|
|
155
|
+
const r = parseDeadlinesPage(200, 'hotcrp.init_deadlines({"sub":1})');
|
|
156
|
+
expect(r.ok ? "parsed" : r.error.kind).toBe("malformed");
|
|
157
|
+
const notJson = parseDeadlinesPage(200, "hotcrp.init_deadlines({now: 1})");
|
|
158
|
+
expect(notJson.ok ? "parsed" : notJson.error.kind).toBe("malformed");
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe("whatOf", () => {
|
|
163
|
+
it("names the deadlines paperlint knows by their HotCRP labels, with or without a round name", () => {
|
|
164
|
+
expect(whatOf("Submission deadline")).toBe("submission");
|
|
165
|
+
expect(whatOf("Resubmission deadline")).toBe("resubmission");
|
|
166
|
+
expect(whatOf("Short papers registration deadline")).toBe("registration");
|
|
167
|
+
expect(whatOf("Final version deadline")).toBe("camera-ready");
|
|
168
|
+
expect(whatOf("Response deadline")).toBeNull();
|
|
169
|
+
});
|
|
170
|
+
});
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HotCRP's deadlines page (`<site>/deadlines`), parsed at the boundary into paperlint's
|
|
3
|
+
* `PortalDeadlines`. Pure.
|
|
4
|
+
*
|
|
5
|
+
* ── WHAT THE PAGE IS, measured 2026-10-05 on five live sites (`cassette/deadlines-*.html`) ───────
|
|
6
|
+
* The page is HTML, public, no account needed; the API endpoint of the same name (`/api/deadlines`)
|
|
7
|
+
* answers 401 «Missing credentials» without a token. The machine-readable part is the argument of
|
|
8
|
+
* `hotcrp.init_deadlines({…})` in the page's last script: the author's `status_json()`
|
|
9
|
+
* (kohler/hotcrp, src/contact.php), of which an anonymous visitor gets
|
|
10
|
+
*
|
|
11
|
+
* now the server's clock, Unix seconds with a fraction
|
|
12
|
+
* sub.open whether the submission round is open now
|
|
13
|
+
* sub.sub the submission deadline, Unix seconds (SubmissionRound::$submit)
|
|
14
|
+
* sub.reg the registration deadline, when it is earlier (SubmissionRound::$register)
|
|
15
|
+
* sub.blind true | "optional" | "until-review" (Conf::submission_blindness)
|
|
16
|
+
* final.done the final-version (camera-ready) deadline (final_deadline_for_display)
|
|
17
|
+
*
|
|
18
|
+
* The RESUBMISSION deadline (updates allowed until, `SubmissionRound::$resubmit`) is not in that
|
|
19
|
+
* object; the page lists it only in its `<dl>`, each entry a `<dt>` with the deadline's label and a
|
|
20
|
+
* `<span data-ts="…">` holding the same Unix seconds (pages/p_deadlines.php, `dl1`). So both are
|
|
21
|
+
* read: the JSON for what it names, the list for every deadline the page shows, matched by label.
|
|
22
|
+
*/
|
|
23
|
+
import { z } from "zod";
|
|
24
|
+
import {
|
|
25
|
+
instantOfUnix,
|
|
26
|
+
type DeadlineWhat,
|
|
27
|
+
type PortalDeadline,
|
|
28
|
+
type PortalDeadlines,
|
|
29
|
+
} from "../../domain/deadline.ts";
|
|
30
|
+
import { err, ok, type Result } from "../../domain/result.ts";
|
|
31
|
+
import type { PortalFailure } from "../../domain/submission.ts";
|
|
32
|
+
|
|
33
|
+
const Blind = z.union([
|
|
34
|
+
z.boolean(),
|
|
35
|
+
z.literal("optional"),
|
|
36
|
+
z.literal("until-review"),
|
|
37
|
+
]);
|
|
38
|
+
const Status = z.looseObject({
|
|
39
|
+
now: z.number(),
|
|
40
|
+
sub: z
|
|
41
|
+
.looseObject({
|
|
42
|
+
open: z.boolean().optional(),
|
|
43
|
+
sub: z.number().optional(),
|
|
44
|
+
reg: z.number().optional(),
|
|
45
|
+
blind: Blind.optional(),
|
|
46
|
+
})
|
|
47
|
+
.optional(),
|
|
48
|
+
final: z.looseObject({ done: z.number().optional() }).optional(),
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
/** The script call that carries the status object; the argument is JSON. */
|
|
52
|
+
const INIT = /hotcrp\.init_deadlines\((\{.*?\})\)/s;
|
|
53
|
+
/** One listed deadline: its label in `<strong>`, its time in `data-ts`. */
|
|
54
|
+
const ENTRY = /<dt><strong>([^<]*)<\/strong>:\s*<span[^>]*\bdata-ts="(\d+)"/g;
|
|
55
|
+
/** The page's title, for a page that is no deadlines page (a site that does not exist). */
|
|
56
|
+
const TITLE = /<h1 id="h-title">([^<]*)</;
|
|
57
|
+
|
|
58
|
+
/** What a listed deadline's label names, in paperlint's words; null for one paperlint does not name. */
|
|
59
|
+
export function whatOf(label: string): DeadlineWhat | null {
|
|
60
|
+
const l = label.toLowerCase();
|
|
61
|
+
if (/\bresubmission deadline\b/.test(l)) return "resubmission";
|
|
62
|
+
if (/\bregistration deadline\b/.test(l)) return "registration";
|
|
63
|
+
if (/\bsubmission deadline\b/.test(l)) return "submission";
|
|
64
|
+
if (/\bfinal\b|\bcamera[- ]ready\b/.test(l)) return "camera-ready";
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The deadlines the status object names, each with the label the page would give it. */
|
|
69
|
+
type StatusJson = Readonly<z.infer<typeof Status>>;
|
|
70
|
+
|
|
71
|
+
function fromStatus(s: StatusJson): readonly PortalDeadline[] {
|
|
72
|
+
const named: readonly (readonly [DeadlineWhat, number | undefined])[] = [
|
|
73
|
+
["registration", s.sub?.reg],
|
|
74
|
+
["submission", s.sub?.sub],
|
|
75
|
+
["camera-ready", s.final?.done],
|
|
76
|
+
];
|
|
77
|
+
return named.flatMap(([what, at]) =>
|
|
78
|
+
at === undefined ? [] : [{ what, label: what, at: instantOfUnix(at) }],
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Every deadline the page lists, with its label as the page spells it. */
|
|
83
|
+
function fromList(html: string): readonly PortalDeadline[] {
|
|
84
|
+
return [...html.matchAll(ENTRY)].map((m) => {
|
|
85
|
+
// Both groups always participate in a match of ENTRY: there is no fallback to take.
|
|
86
|
+
const label = String(m[1]).trim();
|
|
87
|
+
return { what: whatOf(label), label, at: instantOfUnix(Number(m[2])) };
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Listed entries first (they carry the page's label); a status entry only when the list lacks its kind. */
|
|
92
|
+
function merged(
|
|
93
|
+
listed: readonly PortalDeadline[],
|
|
94
|
+
named: readonly PortalDeadline[],
|
|
95
|
+
): readonly PortalDeadline[] {
|
|
96
|
+
const extra = named.filter(
|
|
97
|
+
(n) => !listed.some((l) => l.what === n.what && l.at === n.at),
|
|
98
|
+
);
|
|
99
|
+
return [...listed, ...extra].toSorted((a, b) => a.at.localeCompare(b.at));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const refused = (httpStatus: number, html: string): PortalFailure => {
|
|
103
|
+
const title = TITLE.exec(html)?.[1]?.trim();
|
|
104
|
+
return {
|
|
105
|
+
kind: "refused",
|
|
106
|
+
httpStatus,
|
|
107
|
+
messages:
|
|
108
|
+
title === undefined
|
|
109
|
+
? []
|
|
110
|
+
: [{ message: title, field: null, status: null }],
|
|
111
|
+
};
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
/** The status object in the page's `init_deadlines` call, or why there is none. */
|
|
115
|
+
function statusOf(
|
|
116
|
+
httpStatus: number,
|
|
117
|
+
html: string,
|
|
118
|
+
): Result<StatusJson, PortalFailure> {
|
|
119
|
+
const m = INIT.exec(html);
|
|
120
|
+
if (m === null)
|
|
121
|
+
return err({
|
|
122
|
+
kind: "malformed",
|
|
123
|
+
httpStatus,
|
|
124
|
+
detail:
|
|
125
|
+
"the page has no `hotcrp.init_deadlines(…)` call — not a HotCRP deadlines page",
|
|
126
|
+
});
|
|
127
|
+
const json = ((): unknown => {
|
|
128
|
+
try {
|
|
129
|
+
return JSON.parse(String(m[1])); // the group always participates in a match of INIT
|
|
130
|
+
} catch {
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
})();
|
|
134
|
+
const parsed = Status.safeParse(json);
|
|
135
|
+
return parsed.success
|
|
136
|
+
? ok(parsed.data)
|
|
137
|
+
: err({
|
|
138
|
+
kind: "malformed",
|
|
139
|
+
httpStatus,
|
|
140
|
+
detail:
|
|
141
|
+
"the `init_deadlines` argument is not the status object HotCRP documents",
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** `GET /deadlines` → the portal's deadlines, or why the page is not them. */
|
|
146
|
+
export function parseDeadlinesPage(
|
|
147
|
+
httpStatus: number,
|
|
148
|
+
html: string,
|
|
149
|
+
): Result<PortalDeadlines, PortalFailure> {
|
|
150
|
+
if (httpStatus < 200 || httpStatus >= 300)
|
|
151
|
+
return err(refused(httpStatus, html));
|
|
152
|
+
const status = statusOf(httpStatus, html);
|
|
153
|
+
if (!status.ok) return status;
|
|
154
|
+
const s = status.value;
|
|
155
|
+
return ok({
|
|
156
|
+
now: s.now,
|
|
157
|
+
open: s.sub?.open ?? false,
|
|
158
|
+
blind: s.sub?.blind ?? null,
|
|
159
|
+
deadlines: merged(fromList(html), fromStatus(s)),
|
|
160
|
+
});
|
|
161
|
+
}
|
package/src/cli.test.ts
CHANGED
|
@@ -344,6 +344,15 @@ test("no command prints the usage and exits 2; --kind with no value is refused",
|
|
|
344
344
|
assert.equal(parseArgs(["new", "x", "--kind"]).missingValue, "--kind");
|
|
345
345
|
});
|
|
346
346
|
|
|
347
|
+
test("--help names the cycles and where a deadline comes from — the only place a newcomer looks first", async () => {
|
|
348
|
+
const r = await cli(["--help"], root);
|
|
349
|
+
assert.equal(r.code, 0);
|
|
350
|
+
assert.match(r.out, /"cycles": \[ \{ "id"/);
|
|
351
|
+
assert.match(r.out, /A venue preset carries the portal's deadlines/);
|
|
352
|
+
assert.match(r.out, /"override": \{ "at", "reason", "evidence" \}/);
|
|
353
|
+
assert.match(r.out, /the earlier reading binds/);
|
|
354
|
+
});
|
|
355
|
+
|
|
347
356
|
test("src/cli.ts run as the program itself answers like the bin", () => {
|
|
348
357
|
const r = runNode(join(import.meta.dirname, "cli.ts"), ["--help"]);
|
|
349
358
|
assert.equal(r.status, 0);
|
package/src/cli.ts
CHANGED
|
@@ -74,6 +74,8 @@ import {
|
|
|
74
74
|
import {
|
|
75
75
|
FORMAT_RULE_LEVELS,
|
|
76
76
|
formatRules,
|
|
77
|
+
CYCLE_PORT_RULE_LEVELS,
|
|
78
|
+
cyclePortRules,
|
|
77
79
|
TEX_VENUE_RULE_LEVELS,
|
|
78
80
|
texVenueRules,
|
|
79
81
|
} from "./tex-venue-rules.ts";
|
|
@@ -164,6 +166,8 @@ export { init };
|
|
|
164
166
|
export { nextSteps } from "./init.ts";
|
|
165
167
|
|
|
166
168
|
import paperStages from "#eslint-rules/paper-stages";
|
|
169
|
+
import cycleStage from "#eslint-rules/cycle-stage";
|
|
170
|
+
import { CYCLE_RULE_LEVELS, cycleRules } from "./cycle-rules.ts";
|
|
167
171
|
import {
|
|
168
172
|
FOLDER_VENUE_RULE_LEVELS,
|
|
169
173
|
folderVenueRules,
|
|
@@ -280,6 +284,24 @@ settings — paperlint.json, at two levels, one schema. Both are optional.
|
|
|
280
284
|
"extends" names a venue preset: paperlint:<name> (shipped: ${SHIPPED_VENUES().join(", ")})
|
|
281
285
|
or ./path.jsonc, relative to the paperlint.json. npm presets are not supported yet.
|
|
282
286
|
|
|
287
|
+
cycles and deadlines — a paper's attempts at venues, oldest first, instead of "extends",
|
|
288
|
+
"kind" and "submission" (those are then the current attempt's, and refused beside "cycles"):
|
|
289
|
+
|
|
290
|
+
{ "cycles": [ { "id": "msr-2027", "venue": { "kind": "preset", "extends": "paperlint:msr" },
|
|
291
|
+
"kind": "technical", "opened": "2026-10-05",
|
|
292
|
+
"deadlines": [ { "what": "submission",
|
|
293
|
+
"observed": [ { "at": "2026-10-23 AoE", "source": "call",
|
|
294
|
+
"url": "<the call>", "read": "2026-10-05" } ] } ] } ] }
|
|
295
|
+
|
|
296
|
+
A venue preset carries the portal's deadlines, read and dated; the package updates them and
|
|
297
|
+
your dependency update brings them. A paper adds what the call says ("source": "call") and,
|
|
298
|
+
for a deadline moved by the chairs, "override": { "at", "reason", "evidence" } — evidence a
|
|
299
|
+
file in the paper folder. The override wins; otherwise the earlier reading binds. "what":
|
|
300
|
+
registration (the call's abstract), submission (the full paper), resubmission, notification,
|
|
301
|
+
camera-ready. A venue without a preset: "venue": { "kind": "named", "name", "url" }, every
|
|
302
|
+
deadline typed from its call. Lint never reads the clock: it checks the record, not the time
|
|
303
|
+
left. docs/configuration.md#the-papers-cycles
|
|
304
|
+
|
|
283
305
|
"rules" is { "<rule>": "<severity>" } for every paper file in scope, or ESLint flat-config
|
|
284
306
|
blocks (files, ignores, rules) with globs relative to that file. Order, later wins: paperlint's
|
|
285
307
|
own, the venue preset's, the root file's, the paper's. Optional rules (off unless turned on):
|
|
@@ -303,6 +325,22 @@ const REGISTER_AND_CASE_RULES = {
|
|
|
303
325
|
"heading-case": headingCaseRule(TEX_RULE_DEPS),
|
|
304
326
|
};
|
|
305
327
|
|
|
328
|
+
/**
|
|
329
|
+
* The paper's attempts (`cycles` in paperlint.json), judged on `PIPELINE-STATUS.md`: the record itself
|
|
330
|
+
* and its evidence (src/cycle-rules.ts), and the stages that name a cycle (eslint-rules/cycle-stage.ts).
|
|
331
|
+
* The `cycle` plugin's other rule, `port-done`, reads `paper.tex` (src/tex-venue-rules.ts).
|
|
332
|
+
*/
|
|
333
|
+
/** The markdown language with YAML frontmatter: what every markdown block of the config lints as. */
|
|
334
|
+
const MARKDOWN_WITH_FRONTMATTER = {
|
|
335
|
+
language: "markdown/gfm",
|
|
336
|
+
languageOptions: { frontmatter: "yaml" },
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
const CYCLE_STATUS_RULES = {
|
|
340
|
+
...cycleRules({ files: nodeFiles, venuesDir: presetsDir() }),
|
|
341
|
+
...cycleStage.rules,
|
|
342
|
+
};
|
|
343
|
+
|
|
306
344
|
/** The rules of the files `paper.tex` includes, each read on its own as a fragment (#144). */
|
|
307
345
|
const FRAGMENT_RULES = {
|
|
308
346
|
"future-promise": texBuild["future-promise"],
|
|
@@ -333,10 +371,7 @@ export function buildConfig(
|
|
|
333
371
|
"paper/section-word": "warn",
|
|
334
372
|
"paper/leading-zero": "warn",
|
|
335
373
|
};
|
|
336
|
-
const md =
|
|
337
|
-
language: "markdown/gfm",
|
|
338
|
-
languageOptions: { frontmatter: "yaml" },
|
|
339
|
-
};
|
|
374
|
+
const md = MARKDOWN_WITH_FRONTMATTER;
|
|
340
375
|
|
|
341
376
|
const cfg: ConfigBlock[] = [
|
|
342
377
|
// 🔴 THE PROJECT'S PAPER TEMPLATE IS NOT A PAPER. `paperlint new` reads `<papers>/.template/`, and
|
|
@@ -374,12 +409,14 @@ export function buildConfig(
|
|
|
374
409
|
...folderVenueRules({ files: nodeFiles, venuesDir: presetsDir() }),
|
|
375
410
|
},
|
|
376
411
|
},
|
|
412
|
+
cycle: { rules: CYCLE_STATUS_RULES },
|
|
377
413
|
},
|
|
378
414
|
...md,
|
|
379
415
|
rules: {
|
|
380
416
|
"paper/stages": "error",
|
|
381
417
|
"paper/source": "error",
|
|
382
418
|
...FOLDER_VENUE_RULE_LEVELS,
|
|
419
|
+
...CYCLE_RULE_LEVELS,
|
|
383
420
|
},
|
|
384
421
|
},
|
|
385
422
|
{
|
|
@@ -432,6 +469,7 @@ export function buildConfig(
|
|
|
432
469
|
...pageLimitRules({ files: nodeFiles, venuesDir: presetsDir() }),
|
|
433
470
|
},
|
|
434
471
|
},
|
|
472
|
+
cycle: { rules: cyclePortRules(TEX_RULE_DEPS) },
|
|
435
473
|
},
|
|
436
474
|
language: "tex/latex",
|
|
437
475
|
rules: {
|
|
@@ -454,6 +492,7 @@ export function buildConfig(
|
|
|
454
492
|
...TEX_VENUE_RULE_LEVELS,
|
|
455
493
|
...FORMAT_RULE_LEVELS,
|
|
456
494
|
...PAGE_LIMIT_RULE_LEVELS,
|
|
495
|
+
...CYCLE_PORT_RULE_LEVELS,
|
|
457
496
|
// Silent for a paper that declares no `talk` (src/talk-rules.ts), but talk/undeclared.
|
|
458
497
|
...TALK_RULE_LEVELS,
|
|
459
498
|
},
|