paperlint 10.4.1 → 11.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/deadlines.yml +27 -7
- package/README.md +16 -15
- package/dist/adapters/hotcrp/index.d.ts +6 -4
- package/dist/adapters/hotcrp/index.d.ts.map +1 -1
- package/dist/adapters/hotcrp/index.js +6 -4
- package/dist/adapters/hotcrp/index.js.map +1 -1
- package/dist/adapters/hotcrp/portal.io.d.ts +3 -1
- package/dist/adapters/hotcrp/portal.io.d.ts.map +1 -1
- package/dist/adapters/hotcrp/portal.io.js +36 -11
- package/dist/adapters/hotcrp/portal.io.js.map +1 -1
- package/dist/adapters/hotcrp/response.d.ts +6 -1
- package/dist/adapters/hotcrp/response.d.ts.map +1 -1
- package/dist/adapters/hotcrp/response.js +17 -0
- package/dist/adapters/hotcrp/response.js.map +1 -1
- package/dist/adapters/hotcrp/site.d.ts +9 -0
- package/dist/adapters/hotcrp/site.d.ts.map +1 -0
- package/dist/adapters/hotcrp/site.js +22 -0
- package/dist/adapters/hotcrp/site.js.map +1 -0
- package/dist/adapters/hotcrp/token.d.ts +13 -0
- package/dist/adapters/hotcrp/token.d.ts.map +1 -0
- package/dist/adapters/hotcrp/token.js +48 -0
- package/dist/adapters/hotcrp/token.js.map +1 -0
- package/dist/adapters/latex/abstract.d.ts +22 -0
- package/dist/adapters/latex/abstract.d.ts.map +1 -0
- package/dist/adapters/latex/abstract.js +187 -0
- package/dist/adapters/latex/abstract.js.map +1 -0
- package/dist/adapters/latex/headings.d.ts +6 -0
- package/dist/adapters/latex/headings.d.ts.map +1 -1
- package/dist/adapters/latex/headings.js +5 -4
- package/dist/adapters/latex/headings.js.map +1 -1
- package/dist/adapters/latex/index.d.ts.map +1 -1
- package/dist/adapters/latex/index.js +2 -0
- package/dist/adapters/latex/index.js.map +1 -1
- package/dist/adapters/latex/parse.d.ts +15 -0
- package/dist/adapters/latex/parse.d.ts.map +1 -1
- package/dist/adapters/latex/parse.js +58 -1
- package/dist/adapters/latex/parse.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +20 -8
- package/dist/cli.js.map +1 -1
- package/dist/cycle-rules.d.ts.map +1 -1
- package/dist/cycle-rules.js +3 -4
- package/dist/cycle-rules.js.map +1 -1
- package/dist/domain/deadline.d.ts +8 -1
- package/dist/domain/deadline.d.ts.map +1 -1
- package/dist/domain/deadline.js +26 -12
- package/dist/domain/deadline.js.map +1 -1
- package/dist/domain/portal-token.d.ts +44 -0
- package/dist/domain/portal-token.d.ts.map +1 -0
- package/dist/domain/portal-token.js +32 -0
- package/dist/domain/portal-token.js.map +1 -0
- package/dist/domain/submission.d.ts +30 -0
- package/dist/domain/submission.d.ts.map +1 -1
- package/dist/domain/submission.js +68 -0
- package/dist/domain/submission.js.map +1 -1
- package/dist/lib/paper-config.d.ts +1 -1
- package/dist/lib/paper-config.d.ts.map +1 -1
- package/dist/lib/paper-config.js +6 -4
- package/dist/lib/paper-config.js.map +1 -1
- package/dist/paper-settings.d.ts +3 -1
- package/dist/paper-settings.d.ts.map +1 -1
- package/dist/paper-settings.js +11 -5
- package/dist/paper-settings.js.map +1 -1
- package/dist/ports/latex.d.ts +23 -0
- package/dist/ports/latex.d.ts.map +1 -1
- package/dist/ports/submission-portal.d.ts +39 -2
- package/dist/ports/submission-portal.d.ts.map +1 -1
- package/dist/ports/venue-deadlines.d.ts +1 -1
- package/dist/submission.d.ts +49 -11
- package/dist/submission.d.ts.map +1 -1
- package/dist/submission.js +332 -44
- package/dist/submission.js.map +1 -1
- package/docs/configuration.md +2 -1
- package/docs/rules/cycle/record.md +9 -6
- package/docs/submission.md +47 -48
- package/eslint.config.mjs +3 -0
- package/lib/paper-config.ts +7 -4
- package/package.json +6 -1
- package/presets/msr.jsonc +9 -7
- package/presets/msr.md +14 -10
- package/scripts/check.ts +1 -1
- package/scripts/refresh-deadlines/compare.test.ts +75 -0
- package/scripts/refresh-deadlines/compare.ts +101 -0
- package/scripts/refresh-deadlines/main.test.ts +140 -0
- package/scripts/refresh-deadlines/main.ts +56 -0
- package/scripts/refresh-deadlines/refresh.test.ts +287 -0
- package/scripts/refresh-deadlines/refresh.ts +183 -0
- package/skills/submission-portal/SKILL.md +60 -44
- package/skills/submission-portal/SKILL.md.spec.ts +62 -43
- package/skills/submission-portal/submission-portal.test.ts +4 -73
- package/src/adapters/hotcrp/cassette/README.md +4 -4
- package/src/adapters/hotcrp/cassette/deadlines-msr2027.html +9 -9
- package/src/adapters/hotcrp/deadlines.io.test.ts +2 -2
- package/src/adapters/hotcrp/deadlines.test.ts +3 -3
- package/src/adapters/hotcrp/index.ts +15 -3
- package/src/adapters/hotcrp/portal.io.ts +61 -17
- package/src/adapters/hotcrp/portal.test.ts +105 -1
- package/src/adapters/hotcrp/response.ts +21 -0
- package/src/adapters/hotcrp/site.ts +34 -0
- package/src/adapters/hotcrp/token.test.ts +69 -0
- package/src/adapters/hotcrp/token.ts +51 -0
- package/src/adapters/latex/abstract.test.ts +280 -0
- package/src/adapters/latex/abstract.ts +272 -0
- package/src/adapters/latex/headings.ts +5 -4
- package/src/adapters/latex/index.test.ts +31 -1
- package/src/adapters/latex/index.ts +2 -0
- package/src/adapters/latex/parse.ts +64 -3
- package/src/cli.test.ts +20 -8
- package/src/cli.ts +22 -13
- package/src/cycle-rules.test.ts +6 -6
- package/src/cycle-rules.ts +7 -4
- package/src/domain/deadline.test.ts +31 -2
- package/src/domain/deadline.ts +42 -16
- package/src/domain/portal-token.test.ts +55 -0
- package/src/domain/portal-token.ts +63 -0
- package/src/domain/submission.test.ts +88 -0
- package/src/domain/submission.ts +99 -0
- package/src/new-paper.test.ts +1 -0
- package/src/paper-settings.test.ts +20 -0
- package/src/paper-settings.ts +17 -5
- package/src/ports/latex.ts +25 -0
- package/src/ports/submission-portal.ts +43 -1
- package/src/ports/venue-deadlines.ts +1 -1
- package/src/presets.test.ts +8 -5
- package/src/submission-new.test.ts +569 -0
- package/src/submission-show.test.ts +210 -0
- package/src/submission-update.test.ts +160 -0
- package/src/submission.test.ts +140 -518
- package/src/submission.ts +494 -61
- package/scripts/refresh-deadlines.test.ts +0 -431
- package/scripts/refresh-deadlines.ts +0 -387
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The refresh's logic: what one preset becomes, what a run prints, writes and returns — over a
|
|
3
|
+
* fake VenueDeadlines port that answers plain domain values (how HotCRP's page is parsed is
|
|
4
|
+
* src/adapters/hotcrp/deadlines.test.ts's) and presets held in memory.
|
|
5
|
+
*/
|
|
6
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
7
|
+
import { dirname, join } from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { describe, expect, test } from "vitest";
|
|
10
|
+
import type { PortalDeadlines } from "#src/domain/deadline";
|
|
11
|
+
import { err, ok, type Result } from "#src/domain/result";
|
|
12
|
+
import type { PortalFailure } from "#src/domain/submission";
|
|
13
|
+
import type { VenueDeadlines } from "#src/ports/venue-deadlines";
|
|
14
|
+
import { parsePreset } from "#src/tex-requirements";
|
|
15
|
+
import {
|
|
16
|
+
refreshPreset,
|
|
17
|
+
runRefresh,
|
|
18
|
+
withDeadlines,
|
|
19
|
+
type Outcome,
|
|
20
|
+
type PresetFiles,
|
|
21
|
+
} from "./refresh.ts";
|
|
22
|
+
|
|
23
|
+
const ROOT = dirname(dirname(dirname(fileURLToPath(import.meta.url))));
|
|
24
|
+
const PRESETS = join(ROOT, "presets");
|
|
25
|
+
const MSR = join(PRESETS, "msr.jsonc");
|
|
26
|
+
const msrText = readFileSync(MSR, "utf8");
|
|
27
|
+
const readings = (text: string) => parsePreset(text, MSR).deadlines ?? [];
|
|
28
|
+
/** MSR with its submission deadline two days before the portal's. */
|
|
29
|
+
const earlier = msrText.replace(
|
|
30
|
+
'"at": "2026-10-21T11:59:59Z"',
|
|
31
|
+
'"at": "2026-10-19T04:00:00Z"',
|
|
32
|
+
);
|
|
33
|
+
/** Presets held in memory, by name: what a run reads, and what it writes back. */
|
|
34
|
+
function inMemory(files: Readonly<Record<string, string>>) {
|
|
35
|
+
const written: Record<string, string> = {};
|
|
36
|
+
const presets: PresetFiles = {
|
|
37
|
+
names: Object.keys(files).toSorted(),
|
|
38
|
+
read: (name) => files[name] ?? "",
|
|
39
|
+
write: (name, text) => {
|
|
40
|
+
written[name] = text;
|
|
41
|
+
},
|
|
42
|
+
path: (name) => join(PRESETS, name),
|
|
43
|
+
};
|
|
44
|
+
return { presets, written };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** What MSR 2027's portal lists — the readings presets/msr.jsonc holds. */
|
|
48
|
+
const MSR_PAGE: PortalDeadlines = {
|
|
49
|
+
now: 1792434476,
|
|
50
|
+
open: true,
|
|
51
|
+
blind: true,
|
|
52
|
+
deadlines: [
|
|
53
|
+
{
|
|
54
|
+
what: "submission",
|
|
55
|
+
label: "Submission deadline",
|
|
56
|
+
at: "2026-10-21T11:59:59Z",
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
what: "resubmission",
|
|
60
|
+
label: "Resubmission deadline",
|
|
61
|
+
at: "2026-10-24T11:59:59Z",
|
|
62
|
+
},
|
|
63
|
+
],
|
|
64
|
+
};
|
|
65
|
+
/** What AIDC 2026's portal lists — the readings presets/aidc.jsonc holds. */
|
|
66
|
+
const AIDC_PAGE: PortalDeadlines = {
|
|
67
|
+
now: 1791194489,
|
|
68
|
+
open: true,
|
|
69
|
+
blind: true,
|
|
70
|
+
deadlines: [
|
|
71
|
+
{
|
|
72
|
+
what: "submission",
|
|
73
|
+
label: "Submission deadline",
|
|
74
|
+
at: "2026-10-03T11:59:59Z",
|
|
75
|
+
},
|
|
76
|
+
{ what: "camera-ready", label: "camera-ready", at: "2026-10-31T11:59:59Z" },
|
|
77
|
+
],
|
|
78
|
+
};
|
|
79
|
+
/** A portal that answers `answer`. */
|
|
80
|
+
const answering =
|
|
81
|
+
(answer: Result<PortalDeadlines, PortalFailure>) => (): VenueDeadlines => ({
|
|
82
|
+
read: () => Promise.resolve(answer),
|
|
83
|
+
});
|
|
84
|
+
/** Each site's page, by the URL the preset names. */
|
|
85
|
+
const bySite = (url: string): VenueDeadlines =>
|
|
86
|
+
answering(ok(url.includes("acsac") ? AIDC_PAGE : MSR_PAGE))();
|
|
87
|
+
|
|
88
|
+
/** The outcome, known to be of `kind` — or the test fails naming what it was. */
|
|
89
|
+
function as<K extends Outcome["kind"]>(
|
|
90
|
+
o: Outcome,
|
|
91
|
+
kind: K,
|
|
92
|
+
): Extract<Outcome, { kind: K }> {
|
|
93
|
+
const isKind = (x: Outcome): x is Extract<Outcome, { kind: K }> =>
|
|
94
|
+
x.kind === kind;
|
|
95
|
+
if (!isKind(o)) throw new Error(`expected ${kind}, got ${JSON.stringify(o)}`);
|
|
96
|
+
return o;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
describe("withDeadlines — the `deadlines` value replaced by jsonc-parser, everything else as written", () => {
|
|
100
|
+
test("writing back the readings the file holds gives the file's own bytes", async () => {
|
|
101
|
+
expect(await withDeadlines(msrText, MSR, readings(msrText))).toBe(msrText);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("a preset with a portal and no `deadlines` yet: each reading added, the block right after `portal`", async () => {
|
|
106
|
+
const start = msrText.indexOf(" // Read from the portal");
|
|
107
|
+
const end = msrText.indexOf(" ],\n", start) + " ],\n".length;
|
|
108
|
+
const o = as(
|
|
109
|
+
await refreshPreset({
|
|
110
|
+
file: MSR,
|
|
111
|
+
text: msrText.slice(0, start) + msrText.slice(end),
|
|
112
|
+
portal: answering(ok(MSR_PAGE)),
|
|
113
|
+
today: "2026-10-19",
|
|
114
|
+
}),
|
|
115
|
+
"changed",
|
|
116
|
+
);
|
|
117
|
+
expect(o.changes).toEqual([
|
|
118
|
+
"submission: added 2026-10-21T11:59:59Z",
|
|
119
|
+
"resubmission: added 2026-10-24T11:59:59Z",
|
|
120
|
+
]);
|
|
121
|
+
expect(readings(o.text)).toEqual(readings(msrText));
|
|
122
|
+
expect(o.text).toMatch(
|
|
123
|
+
/"url": "https:\/\/msr2027\.hotcrp\.com",\n {2}\},\n {2}"deadlines": \[/,
|
|
124
|
+
);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe("refreshPreset on MSR 2027's page", () => {
|
|
128
|
+
test("the portal says what the preset says: unchanged", async () => {
|
|
129
|
+
const o = await refreshPreset({
|
|
130
|
+
file: MSR,
|
|
131
|
+
text: msrText,
|
|
132
|
+
portal: answering(ok(MSR_PAGE)),
|
|
133
|
+
today: "2026-10-06",
|
|
134
|
+
});
|
|
135
|
+
expect(o).toEqual({
|
|
136
|
+
kind: "unchanged",
|
|
137
|
+
url: "https://msr2027.hotcrp.com/deadlines",
|
|
138
|
+
notes: [],
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("the portal moved the submission: only that reading changes, with today's read day", async () => {
|
|
143
|
+
const o = as(
|
|
144
|
+
await refreshPreset({
|
|
145
|
+
file: MSR,
|
|
146
|
+
text: earlier,
|
|
147
|
+
portal: answering(ok(MSR_PAGE)),
|
|
148
|
+
today: "2026-10-20",
|
|
149
|
+
}),
|
|
150
|
+
"changed",
|
|
151
|
+
);
|
|
152
|
+
expect(o.changes).toEqual([
|
|
153
|
+
"submission: 2026-10-19T04:00:00Z → 2026-10-21T11:59:59Z",
|
|
154
|
+
]);
|
|
155
|
+
// Only the deadlines block differs: the comments and every other key are the file's own.
|
|
156
|
+
expect(o.text).toBe(
|
|
157
|
+
msrText.replace(
|
|
158
|
+
'"read": "2026-10-19",\n },\n {\n "what": "resubmission"',
|
|
159
|
+
'"read": "2026-10-20",\n },\n {\n "what": "resubmission"',
|
|
160
|
+
),
|
|
161
|
+
);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
describe("refreshPreset — what it skips, and a portal it cannot read", () => {
|
|
166
|
+
test("a preset without a portal, or with one paperlint has no adapter for, is skipped", async () => {
|
|
167
|
+
const aisec = join(PRESETS, "aisec.jsonc");
|
|
168
|
+
const skipped = { kind: "skipped" };
|
|
169
|
+
expect(
|
|
170
|
+
await refreshPreset({
|
|
171
|
+
file: aisec,
|
|
172
|
+
text: readFileSync(aisec, "utf8"),
|
|
173
|
+
portal: answering(ok(MSR_PAGE)),
|
|
174
|
+
today: "2026-10-05",
|
|
175
|
+
}),
|
|
176
|
+
).toEqual(skipped);
|
|
177
|
+
expect(
|
|
178
|
+
await refreshPreset({
|
|
179
|
+
file: MSR,
|
|
180
|
+
text: msrText.replace('"kind": "hotcrp"', '"kind": "easychair"'),
|
|
181
|
+
portal: answering(ok(MSR_PAGE)),
|
|
182
|
+
today: "2026-10-05",
|
|
183
|
+
}),
|
|
184
|
+
).toEqual(skipped);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test.each<[string, PortalFailure, string]>([
|
|
188
|
+
[
|
|
189
|
+
"a site that answers 404",
|
|
190
|
+
{
|
|
191
|
+
kind: "refused",
|
|
192
|
+
httpStatus: 404,
|
|
193
|
+
messages: [
|
|
194
|
+
{ message: "No such conference", field: null, status: null },
|
|
195
|
+
],
|
|
196
|
+
},
|
|
197
|
+
"the portal refused (HTTP 404): No such conference",
|
|
198
|
+
],
|
|
199
|
+
[
|
|
200
|
+
"a site that does not answer",
|
|
201
|
+
{ kind: "unreachable", detail: "connect ECONNREFUSED" },
|
|
202
|
+
"the portal cannot be reached: connect ECONNREFUSED",
|
|
203
|
+
],
|
|
204
|
+
[
|
|
205
|
+
"a page that is not the one expected",
|
|
206
|
+
{ kind: "malformed", httpStatus: 200, detail: "no init" },
|
|
207
|
+
"the portal answered HTTP 200, but not as its API does: no init",
|
|
208
|
+
],
|
|
209
|
+
])("%s is reported, never guessed", async (_what, failure, why) => {
|
|
210
|
+
expect(
|
|
211
|
+
await refreshPreset({
|
|
212
|
+
file: MSR,
|
|
213
|
+
text: msrText,
|
|
214
|
+
portal: answering(err(failure)),
|
|
215
|
+
today: "2026-10-05",
|
|
216
|
+
}),
|
|
217
|
+
).toEqual({
|
|
218
|
+
kind: "unreadable",
|
|
219
|
+
url: "https://msr2027.hotcrp.com/deadlines",
|
|
220
|
+
why,
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
describe("runRefresh — what it prints, writes and returns", () => {
|
|
226
|
+
test("nothing changed: one line, no preset written, exit 0", async () => {
|
|
227
|
+
const { presets, written } = inMemory(
|
|
228
|
+
Object.fromEntries(
|
|
229
|
+
readdirSync(PRESETS)
|
|
230
|
+
.filter((f) => f.endsWith(".jsonc"))
|
|
231
|
+
.map((f) => [f, readFileSync(join(PRESETS, f), "utf8")]),
|
|
232
|
+
),
|
|
233
|
+
);
|
|
234
|
+
const lines: string[] = [];
|
|
235
|
+
const code = await runRefresh({
|
|
236
|
+
presets,
|
|
237
|
+
portal: bySite,
|
|
238
|
+
today: "2026-10-06",
|
|
239
|
+
log: (l) => lines.push(l),
|
|
240
|
+
summary: null,
|
|
241
|
+
});
|
|
242
|
+
expect([code, written]).toStrictEqual([0, {}]);
|
|
243
|
+
expect(lines.at(-1)).toMatch(
|
|
244
|
+
/^✓ no deadline changed: 2 preset\(s\) with a portal read, \d+ skipped$/,
|
|
245
|
+
);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("a preset that does not parse is a defect of the repository: exit 1, naming it", async () => {
|
|
249
|
+
const lines: string[] = [];
|
|
250
|
+
const summaries: string[] = [];
|
|
251
|
+
const code = await runRefresh({
|
|
252
|
+
presets: inMemory({ "bad.jsonc": "{ nope" }).presets,
|
|
253
|
+
portal: bySite,
|
|
254
|
+
today: "2026-10-06",
|
|
255
|
+
log: (l) => lines.push(l),
|
|
256
|
+
summary: (text) => summaries.push(text),
|
|
257
|
+
});
|
|
258
|
+
expect(code).toBe(1);
|
|
259
|
+
expect(lines[0]).toMatch(
|
|
260
|
+
/^::error title=deadlines::bad\.jsonc does not parse: /,
|
|
261
|
+
);
|
|
262
|
+
expect(summaries[0]).toMatch(/^- 🔴 `bad\.jsonc` does not parse: /m);
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
describe("runRefresh — a changed preset", () => {
|
|
267
|
+
test("a changed preset is written back, and the summary names it", async () => {
|
|
268
|
+
const { presets, written } = inMemory({ "msr.jsonc": earlier });
|
|
269
|
+
const lines: string[] = [];
|
|
270
|
+
const summaries: string[] = [];
|
|
271
|
+
const code = await runRefresh({
|
|
272
|
+
presets,
|
|
273
|
+
portal: bySite,
|
|
274
|
+
today: "2026-10-20",
|
|
275
|
+
log: (l) => lines.push(l),
|
|
276
|
+
summary: (text) => summaries.push(text),
|
|
277
|
+
});
|
|
278
|
+
expect(code).toBe(0);
|
|
279
|
+
expect(readings(written["msr.jsonc"] ?? "")[0]?.at).toBe(
|
|
280
|
+
"2026-10-21T11:59:59Z",
|
|
281
|
+
);
|
|
282
|
+
expect(lines.at(-1)).toBe("1 preset(s) changed");
|
|
283
|
+
expect(summaries[0]).toMatch(
|
|
284
|
+
/^- `msr\.jsonc` \(https:\/\/msr2027\.hotcrp\.com\/deadlines\)\n {2}- submission: 2026-10-19T04:00:00Z → 2026-10-21T11:59:59Z$/m,
|
|
285
|
+
);
|
|
286
|
+
});
|
|
287
|
+
});
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE SHIPPED PRESETS' DEADLINES, RE-READ FROM THEIR PORTALS — the refresh's logic, behind the
|
|
3
|
+
* VenueDeadlines port and the preset files it is handed (`main.ts` plugs in the real ones). For
|
|
4
|
+
* every preset whose `portal` paperlint has an adapter for, the portal's deadlines are compared
|
|
5
|
+
* with the preset's `deadlines` (`compare.ts`), and a preset that changed is rewritten — the
|
|
6
|
+
* `deadlines` value only, by jsonc-parser, then Prettier as `npm run fmt` would.
|
|
7
|
+
*
|
|
8
|
+
* IT REPORTS, IT DOES NOT BLOCK. A portal that is down, gone or not the page expected is a
|
|
9
|
+
* `::warning::` line and a line in the summary, and the run exits 0 — the venue's outage is not this
|
|
10
|
+
* repository's defect, and the next day's run reads it again. A preset that does not parse IS:
|
|
11
|
+
* `::error::`, exit 1.
|
|
12
|
+
*/
|
|
13
|
+
import { applyEdits, modify } from "jsonc-parser";
|
|
14
|
+
import { format } from "prettier";
|
|
15
|
+
import type { IsoDate, Reading } from "#src/domain/deadline";
|
|
16
|
+
import { supportedPortal } from "#src/domain/submission";
|
|
17
|
+
import { messageOf } from "#src/domain/text";
|
|
18
|
+
import type { VenueDeadlines } from "#src/ports/venue-deadlines";
|
|
19
|
+
import { failureText } from "#src/submission";
|
|
20
|
+
import { parsePreset } from "#src/tex-requirements";
|
|
21
|
+
import { refreshReadings } from "./compare.ts";
|
|
22
|
+
|
|
23
|
+
/** What happened to one preset. */
|
|
24
|
+
export type Outcome =
|
|
25
|
+
| { readonly kind: "skipped" }
|
|
26
|
+
| { readonly kind: "broken"; readonly why: string }
|
|
27
|
+
| { readonly kind: "unreadable"; readonly url: string; readonly why: string }
|
|
28
|
+
| {
|
|
29
|
+
readonly kind: "unchanged";
|
|
30
|
+
readonly url: string;
|
|
31
|
+
readonly notes: readonly string[];
|
|
32
|
+
}
|
|
33
|
+
| {
|
|
34
|
+
readonly kind: "changed";
|
|
35
|
+
readonly url: string;
|
|
36
|
+
readonly text: string;
|
|
37
|
+
readonly changes: readonly string[];
|
|
38
|
+
readonly notes: readonly string[];
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/** The presets of one directory, by file name. */
|
|
42
|
+
export interface PresetFiles {
|
|
43
|
+
/** The `*.jsonc` presets' names, sorted. */
|
|
44
|
+
readonly names: readonly string[];
|
|
45
|
+
readonly read: (name: string) => string;
|
|
46
|
+
readonly write: (name: string, text: string) => void;
|
|
47
|
+
/** Where a preset lives, as a message names it and Prettier infers its format from. */
|
|
48
|
+
readonly path: (name: string) => string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The preset's text with its `deadlines` value set to `readings` (inserted after `portal` when absent). */
|
|
52
|
+
export async function withDeadlines(
|
|
53
|
+
text: string,
|
|
54
|
+
file: string,
|
|
55
|
+
readings: readonly Reading[],
|
|
56
|
+
): Promise<string> {
|
|
57
|
+
const edits = modify(text, ["deadlines"], readings, {
|
|
58
|
+
formattingOptions: { insertSpaces: true, tabSize: 2 },
|
|
59
|
+
getInsertionIndex: (keys) => keys.indexOf("portal") + 1,
|
|
60
|
+
});
|
|
61
|
+
// Prettier, for what jsonc-parser does not print: the trailing commas `npm run fmt:check` requires.
|
|
62
|
+
return format(applyEdits(text, edits), { filepath: file });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** One preset: its portal read, its readings compared, its new text when one changed. */
|
|
66
|
+
export async function refreshPreset(o: {
|
|
67
|
+
readonly file: string;
|
|
68
|
+
readonly text: string;
|
|
69
|
+
readonly portal: (site: string) => VenueDeadlines;
|
|
70
|
+
readonly today: IsoDate;
|
|
71
|
+
}): Promise<Outcome> {
|
|
72
|
+
const parsed = ((): ReturnType<typeof parsePreset> | string => {
|
|
73
|
+
try {
|
|
74
|
+
return parsePreset(o.text, o.file);
|
|
75
|
+
} catch (e) {
|
|
76
|
+
return messageOf(e);
|
|
77
|
+
}
|
|
78
|
+
})();
|
|
79
|
+
if (typeof parsed === "string") return { kind: "broken", why: parsed };
|
|
80
|
+
const portal = parsed.portal === null ? null : supportedPortal(parsed.portal);
|
|
81
|
+
if (portal === null || !portal.ok) return { kind: "skipped" };
|
|
82
|
+
const url = `${portal.value.url}/deadlines`;
|
|
83
|
+
const page = await o.portal(portal.value.url).read();
|
|
84
|
+
if (!page.ok)
|
|
85
|
+
return { kind: "unreadable", url, why: failureText(page.error) };
|
|
86
|
+
const r = refreshReadings(parsed.deadlines ?? [], page.value, url, o.today);
|
|
87
|
+
if (r.changes.length === 0) return { kind: "unchanged", url, notes: r.notes };
|
|
88
|
+
const text = await withDeadlines(o.text, o.file, r.readings);
|
|
89
|
+
return { kind: "changed", url, text, changes: r.changes, notes: r.notes };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The lines one preset's outcome contributes: to the log (annotations included) and to the summary. */
|
|
93
|
+
function report(
|
|
94
|
+
name: string,
|
|
95
|
+
o: Outcome,
|
|
96
|
+
): { readonly log: readonly string[]; readonly md: readonly string[] } {
|
|
97
|
+
switch (o.kind) {
|
|
98
|
+
case "skipped":
|
|
99
|
+
return { log: [], md: [] };
|
|
100
|
+
case "broken":
|
|
101
|
+
return {
|
|
102
|
+
log: [`::error title=deadlines::${name} does not parse: ${o.why}`],
|
|
103
|
+
md: [`- 🔴 \`${name}\` does not parse: ${o.why}`],
|
|
104
|
+
};
|
|
105
|
+
case "unreadable":
|
|
106
|
+
return {
|
|
107
|
+
log: [
|
|
108
|
+
`::warning title=deadlines::${name}: could not read ${o.url} — ${o.why}`,
|
|
109
|
+
],
|
|
110
|
+
md: [`- ⚠️ \`${name}\`: could not read ${o.url} — ${o.why}`],
|
|
111
|
+
};
|
|
112
|
+
case "unchanged":
|
|
113
|
+
return { log: o.notes.map((n) => `${name}: ${n}`), md: [] };
|
|
114
|
+
case "changed": {
|
|
115
|
+
const lines = [...o.changes, ...o.notes];
|
|
116
|
+
return {
|
|
117
|
+
log: lines.map((c) => `${name}: ${c}`),
|
|
118
|
+
md: [`- \`${name}\` (${o.url})`, ...lines.map((c) => ` - ${c}`)],
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The pull request's body. */
|
|
125
|
+
const summaryOf = (today: IsoDate, lines: readonly string[]): string =>
|
|
126
|
+
[
|
|
127
|
+
`The venue presets' deadlines, re-read on ${today} from each preset's portal by \`.github/workflows/deadlines.yml\` (\`scripts/refresh-deadlines/\`). A reading whose value changed carries today's \`read\` day; the others keep theirs.`,
|
|
128
|
+
"",
|
|
129
|
+
...lines,
|
|
130
|
+
"",
|
|
131
|
+
"Merging releases a patch; papers get the new dates through their normal update of paperlint.",
|
|
132
|
+
"",
|
|
133
|
+
].join("\n");
|
|
134
|
+
|
|
135
|
+
/** What a refresh run needs: the presets, how to read a portal, the day, where to print. */
|
|
136
|
+
export interface RefreshDeps {
|
|
137
|
+
readonly presets: PresetFiles;
|
|
138
|
+
readonly portal: (site: string) => VenueDeadlines;
|
|
139
|
+
readonly today: IsoDate;
|
|
140
|
+
readonly log: (line: string) => void;
|
|
141
|
+
/** Where the pull request's body goes; null: nowhere. */
|
|
142
|
+
readonly summary: ((text: string) => void) | null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Every preset, refreshed — a changed one written back. */
|
|
146
|
+
const outcomesOf = (
|
|
147
|
+
d: RefreshDeps,
|
|
148
|
+
): Promise<readonly { readonly name: string; readonly out: Outcome }[]> =>
|
|
149
|
+
Promise.all(
|
|
150
|
+
d.presets.names.map(async (name) => {
|
|
151
|
+
const out = await refreshPreset({
|
|
152
|
+
file: d.presets.path(name),
|
|
153
|
+
text: d.presets.read(name),
|
|
154
|
+
portal: d.portal,
|
|
155
|
+
today: d.today,
|
|
156
|
+
});
|
|
157
|
+
if (out.kind === "changed") d.presets.write(name, out.text);
|
|
158
|
+
return { name, out };
|
|
159
|
+
}),
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
/** Every preset refreshed, reported to the log and the summary. 0 unless a preset does not parse. */
|
|
163
|
+
export async function runRefresh(d: RefreshDeps): Promise<number> {
|
|
164
|
+
const outcomes = await outcomesOf(d);
|
|
165
|
+
const reports = outcomes.map(({ name, out }) => report(name, out));
|
|
166
|
+
const count = (k: Outcome["kind"]) =>
|
|
167
|
+
outcomes.filter((x) => x.out.kind === k).length;
|
|
168
|
+
[
|
|
169
|
+
...reports.flatMap((r) => r.log),
|
|
170
|
+
count("changed") === 0
|
|
171
|
+
? `✓ no deadline changed: ${String(count("unchanged"))} preset(s) with a portal read, ${String(count("skipped"))} skipped`
|
|
172
|
+
: `${String(count("changed"))} preset(s) changed`,
|
|
173
|
+
].forEach((l) => {
|
|
174
|
+
d.log(l);
|
|
175
|
+
});
|
|
176
|
+
d.summary?.(
|
|
177
|
+
summaryOf(
|
|
178
|
+
d.today,
|
|
179
|
+
reports.flatMap((r) => r.md),
|
|
180
|
+
),
|
|
181
|
+
);
|
|
182
|
+
return count("broken") > 0 ? 1 : 0;
|
|
183
|
+
}
|
|
@@ -1,69 +1,85 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: submission-portal
|
|
3
|
-
description: "
|
|
3
|
+
description: "Create, read or change a paper's submission on the venue's HotCRP portal via `paperlint submission new|show|update`: register the paper, replace the PDF or abstract, mark it submitted, and verify by sha256 that the portal holds the latest build. Use when the paper is not yet on HotCRP, when its PDF or abstract there needs replacing, when asked whether it is truly submitted, or before a deadline. Composes with submit-paper (what to submit)."
|
|
4
4
|
allowed-tools: [Read, Grep, Glob, Bash]
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
-
<!-- vigiles:sha256:
|
|
7
|
+
<!-- vigiles:sha256:03bc35623dbd7911 compiled from skills/submission-portal/SKILL.md.spec.ts -->
|
|
8
8
|
|
|
9
|
-
# submission-portal — read and update the submission on the portal, then prove it landed
|
|
9
|
+
# submission-portal — create, read and update the submission on the portal, then prove it landed
|
|
10
10
|
|
|
11
|
-
`paperlint submission`
|
|
12
|
-
supported; for any other portal the command says so and the upload is done by hand.
|
|
11
|
+
`paperlint submission` creates, reads and updates the paper's submission on the venue's portal.
|
|
12
|
+
Only HotCRP is supported; for any other portal the command says so and the upload is done by hand.
|
|
13
|
+
|
|
14
|
+
## What the command does, and what stays on the site
|
|
15
|
+
|
|
16
|
+
`submission new` creates the submission with the title, the abstract, the authors and the PDF.
|
|
17
|
+
Everything else — conflicts, topics, the venue's own fields — is done on the site by the author: the
|
|
18
|
+
command prints that list, with the submission's page, after every run.
|
|
19
|
+
|
|
20
|
+
## Token
|
|
21
|
+
|
|
22
|
+
One variable per site: `HOTCRP_TOKEN_MSR2027` for `msr2027.hotcrp.com`; for a site not on
|
|
23
|
+
hotcrp.com, its whole host (with a port other than the default) and path. A plain `HOTCRP_TOKEN` is not read. When the command reports
|
|
24
|
+
a missing, misnamed or refused token, **relay its message to the author verbatim and stop**: it
|
|
25
|
+
already says where on the site to make one, which permissions to tick, and which variable to set.
|
|
26
|
+
🔴 Never ask for the token in chat, never print it, never write it to a file — the author sets it in
|
|
27
|
+
their own environment.
|
|
13
28
|
|
|
14
29
|
## What must be in place
|
|
15
30
|
|
|
16
31
|
- **The venue preset declares the portal**: `"portal": { "kind": "hotcrp", "url": "<the site>" }`
|
|
17
|
-
(`paperlint:aidc` and `paperlint:msr` have it).
|
|
18
|
-
- **
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
`document:write` to update). Never ask for it in chat, never print it, never write it to a file:
|
|
23
|
-
the command reads it from the environment and sends it only as the `Authorization` header.
|
|
32
|
+
(`paperlint:aidc` and `paperlint:msr` have it).
|
|
33
|
+
- **For `new`: `"authors"` in the paper's `paperlint.json`** — `[{ "name", "email",
|
|
34
|
+
"affiliation" }]`, every author in the paper's order. The author writes this list; never fill it
|
|
35
|
+
in from the paper, a profile or memory.
|
|
36
|
+
- **For `show` and `update`: `"submission": { "id": <number> }`** — `new --save` writes it.
|
|
24
37
|
|
|
25
|
-
A missing key or token stops the command with exit 2 and a line naming
|
|
26
|
-
variable — fix exactly that and run again.
|
|
38
|
+
A missing key or token stops the command with exit 2 and a line naming what to set — fix exactly that.
|
|
27
39
|
|
|
28
40
|
## The procedure — always in this order
|
|
29
41
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
`
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
42
|
+
`papers/my-paper` stands for the paper's folder.
|
|
43
|
+
|
|
44
|
+
0. **Create it, once — only while the paper has no `submission` id.**
|
|
45
|
+
`npx paperlint submission new papers/my-paper` is a dry run: the portal checks it and keeps
|
|
46
|
+
nothing. It prints the title, the authors and the whole abstract (read from the source's
|
|
47
|
+
`\begin{abstract}`). Read them with the author; an abstract the command cannot read, or one
|
|
48
|
+
that reads wrong, is given as a plain-text file with `--abstract <file>`.
|
|
49
|
+
1. **Ask the author** for an explicit yes, then `npx paperlint submission new papers/my-paper --save`,
|
|
50
|
+
and do the printed "on the site" list with the author.
|
|
51
|
+
2. **Read it.** `npx paperlint submission show papers/my-paper` — status, the PDF the portal holds,
|
|
52
|
+
and `match`: whether it is the local build. Rebuild first if the local PDF is stale.
|
|
53
|
+
3. **Dry run the change.** `npx paperlint submission update papers/my-paper` (`--abstract <file>`,
|
|
54
|
+
`--submit` to mark it submitted). Exit 1 means the portal would refuse it — report why, stop.
|
|
55
|
+
4. **Ask the author** for an explicit yes, then the same command with `--save`.
|
|
56
|
+
5. **Prove it landed.** `show` again: `match` must read `yes — the portal holds this build`, and
|
|
57
|
+
the status must be what was intended. Anything else is not done — say so.
|
|
58
|
+
|
|
59
|
+
🔴 `--save` creates or changes a real submission that reviewers will see: never without the
|
|
60
|
+
author's yes in this conversation, never "to test", never because a dry run passed.
|
|
48
61
|
|
|
49
62
|
```bash
|
|
50
|
-
npx paperlint
|
|
51
|
-
|
|
63
|
+
npx paperlint build papers/my-paper
|
|
64
|
+
# only while the paper has no submission id:
|
|
65
|
+
npx paperlint submission new papers/my-paper
|
|
66
|
+
# … the author reads the dry run and says yes …
|
|
67
|
+
npx paperlint submission new papers/my-paper --save
|
|
68
|
+
npx paperlint submission show papers/my-paper
|
|
69
|
+
npx paperlint submission update papers/my-paper --submit
|
|
52
70
|
# … the author reads the dry run and says yes …
|
|
53
|
-
npx paperlint submission update papers
|
|
54
|
-
npx paperlint submission show papers
|
|
71
|
+
npx paperlint submission update papers/my-paper --submit --save
|
|
72
|
+
npx paperlint submission show papers/my-paper
|
|
55
73
|
```
|
|
56
74
|
|
|
57
75
|
## Reading the answers
|
|
58
76
|
|
|
59
|
-
- `match yes` is the only proof the reviewers get the local build;
|
|
60
|
-
|
|
61
|
-
- `
|
|
62
|
-
|
|
63
|
-
HotCRP's own; quote it.
|
|
64
|
-
- `SAVE WITHHELD by the portal` — HotCRP did not save; nothing changed.
|
|
77
|
+
- `match yes` is the only proof the reviewers get the local build; `submitted` alone says nothing
|
|
78
|
+
about WHICH PDF.
|
|
79
|
+
- `portal says: …` lines are the portal's own words; quote them.
|
|
80
|
+
- `SAVE WITHHELD` or `NOTHING CREATED` — the portal saved nothing.
|
|
65
81
|
|
|
66
82
|
## What this skill does not do
|
|
67
83
|
|
|
68
|
-
It does not decide whether the paper is ready (
|
|
69
|
-
|
|
84
|
+
It does not decide whether the paper is ready (`harden-paper`), anonymize it, or set the portal's
|
|
85
|
+
other fields — see `submit-paper`.
|