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.
Files changed (131) hide show
  1. package/.github/workflows/deadlines.yml +27 -7
  2. package/README.md +16 -15
  3. package/dist/adapters/hotcrp/index.d.ts +6 -4
  4. package/dist/adapters/hotcrp/index.d.ts.map +1 -1
  5. package/dist/adapters/hotcrp/index.js +6 -4
  6. package/dist/adapters/hotcrp/index.js.map +1 -1
  7. package/dist/adapters/hotcrp/portal.io.d.ts +3 -1
  8. package/dist/adapters/hotcrp/portal.io.d.ts.map +1 -1
  9. package/dist/adapters/hotcrp/portal.io.js +36 -11
  10. package/dist/adapters/hotcrp/portal.io.js.map +1 -1
  11. package/dist/adapters/hotcrp/response.d.ts +6 -1
  12. package/dist/adapters/hotcrp/response.d.ts.map +1 -1
  13. package/dist/adapters/hotcrp/response.js +17 -0
  14. package/dist/adapters/hotcrp/response.js.map +1 -1
  15. package/dist/adapters/hotcrp/site.d.ts +9 -0
  16. package/dist/adapters/hotcrp/site.d.ts.map +1 -0
  17. package/dist/adapters/hotcrp/site.js +22 -0
  18. package/dist/adapters/hotcrp/site.js.map +1 -0
  19. package/dist/adapters/hotcrp/token.d.ts +13 -0
  20. package/dist/adapters/hotcrp/token.d.ts.map +1 -0
  21. package/dist/adapters/hotcrp/token.js +48 -0
  22. package/dist/adapters/hotcrp/token.js.map +1 -0
  23. package/dist/adapters/latex/abstract.d.ts +22 -0
  24. package/dist/adapters/latex/abstract.d.ts.map +1 -0
  25. package/dist/adapters/latex/abstract.js +187 -0
  26. package/dist/adapters/latex/abstract.js.map +1 -0
  27. package/dist/adapters/latex/headings.d.ts +6 -0
  28. package/dist/adapters/latex/headings.d.ts.map +1 -1
  29. package/dist/adapters/latex/headings.js +5 -4
  30. package/dist/adapters/latex/headings.js.map +1 -1
  31. package/dist/adapters/latex/index.d.ts.map +1 -1
  32. package/dist/adapters/latex/index.js +2 -0
  33. package/dist/adapters/latex/index.js.map +1 -1
  34. package/dist/adapters/latex/parse.d.ts +15 -0
  35. package/dist/adapters/latex/parse.d.ts.map +1 -1
  36. package/dist/adapters/latex/parse.js +58 -1
  37. package/dist/adapters/latex/parse.js.map +1 -1
  38. package/dist/cli.d.ts.map +1 -1
  39. package/dist/cli.js +20 -8
  40. package/dist/cli.js.map +1 -1
  41. package/dist/cycle-rules.d.ts.map +1 -1
  42. package/dist/cycle-rules.js +3 -4
  43. package/dist/cycle-rules.js.map +1 -1
  44. package/dist/domain/deadline.d.ts +8 -1
  45. package/dist/domain/deadline.d.ts.map +1 -1
  46. package/dist/domain/deadline.js +26 -12
  47. package/dist/domain/deadline.js.map +1 -1
  48. package/dist/domain/portal-token.d.ts +44 -0
  49. package/dist/domain/portal-token.d.ts.map +1 -0
  50. package/dist/domain/portal-token.js +32 -0
  51. package/dist/domain/portal-token.js.map +1 -0
  52. package/dist/domain/submission.d.ts +30 -0
  53. package/dist/domain/submission.d.ts.map +1 -1
  54. package/dist/domain/submission.js +68 -0
  55. package/dist/domain/submission.js.map +1 -1
  56. package/dist/lib/paper-config.d.ts +1 -1
  57. package/dist/lib/paper-config.d.ts.map +1 -1
  58. package/dist/lib/paper-config.js +6 -4
  59. package/dist/lib/paper-config.js.map +1 -1
  60. package/dist/paper-settings.d.ts +3 -1
  61. package/dist/paper-settings.d.ts.map +1 -1
  62. package/dist/paper-settings.js +11 -5
  63. package/dist/paper-settings.js.map +1 -1
  64. package/dist/ports/latex.d.ts +23 -0
  65. package/dist/ports/latex.d.ts.map +1 -1
  66. package/dist/ports/submission-portal.d.ts +39 -2
  67. package/dist/ports/submission-portal.d.ts.map +1 -1
  68. package/dist/ports/venue-deadlines.d.ts +1 -1
  69. package/dist/submission.d.ts +49 -11
  70. package/dist/submission.d.ts.map +1 -1
  71. package/dist/submission.js +332 -44
  72. package/dist/submission.js.map +1 -1
  73. package/docs/configuration.md +2 -1
  74. package/docs/rules/cycle/record.md +9 -6
  75. package/docs/submission.md +47 -48
  76. package/eslint.config.mjs +3 -0
  77. package/lib/paper-config.ts +7 -4
  78. package/package.json +6 -1
  79. package/presets/msr.jsonc +9 -7
  80. package/presets/msr.md +14 -10
  81. package/scripts/check.ts +1 -1
  82. package/scripts/refresh-deadlines/compare.test.ts +75 -0
  83. package/scripts/refresh-deadlines/compare.ts +101 -0
  84. package/scripts/refresh-deadlines/main.test.ts +140 -0
  85. package/scripts/refresh-deadlines/main.ts +56 -0
  86. package/scripts/refresh-deadlines/refresh.test.ts +287 -0
  87. package/scripts/refresh-deadlines/refresh.ts +183 -0
  88. package/skills/submission-portal/SKILL.md +60 -44
  89. package/skills/submission-portal/SKILL.md.spec.ts +62 -43
  90. package/skills/submission-portal/submission-portal.test.ts +4 -73
  91. package/src/adapters/hotcrp/cassette/README.md +4 -4
  92. package/src/adapters/hotcrp/cassette/deadlines-msr2027.html +9 -9
  93. package/src/adapters/hotcrp/deadlines.io.test.ts +2 -2
  94. package/src/adapters/hotcrp/deadlines.test.ts +3 -3
  95. package/src/adapters/hotcrp/index.ts +15 -3
  96. package/src/adapters/hotcrp/portal.io.ts +61 -17
  97. package/src/adapters/hotcrp/portal.test.ts +105 -1
  98. package/src/adapters/hotcrp/response.ts +21 -0
  99. package/src/adapters/hotcrp/site.ts +34 -0
  100. package/src/adapters/hotcrp/token.test.ts +69 -0
  101. package/src/adapters/hotcrp/token.ts +51 -0
  102. package/src/adapters/latex/abstract.test.ts +280 -0
  103. package/src/adapters/latex/abstract.ts +272 -0
  104. package/src/adapters/latex/headings.ts +5 -4
  105. package/src/adapters/latex/index.test.ts +31 -1
  106. package/src/adapters/latex/index.ts +2 -0
  107. package/src/adapters/latex/parse.ts +64 -3
  108. package/src/cli.test.ts +20 -8
  109. package/src/cli.ts +22 -13
  110. package/src/cycle-rules.test.ts +6 -6
  111. package/src/cycle-rules.ts +7 -4
  112. package/src/domain/deadline.test.ts +31 -2
  113. package/src/domain/deadline.ts +42 -16
  114. package/src/domain/portal-token.test.ts +55 -0
  115. package/src/domain/portal-token.ts +63 -0
  116. package/src/domain/submission.test.ts +88 -0
  117. package/src/domain/submission.ts +99 -0
  118. package/src/new-paper.test.ts +1 -0
  119. package/src/paper-settings.test.ts +20 -0
  120. package/src/paper-settings.ts +17 -5
  121. package/src/ports/latex.ts +25 -0
  122. package/src/ports/submission-portal.ts +43 -1
  123. package/src/ports/venue-deadlines.ts +1 -1
  124. package/src/presets.test.ts +8 -5
  125. package/src/submission-new.test.ts +569 -0
  126. package/src/submission-show.test.ts +210 -0
  127. package/src/submission-update.test.ts +160 -0
  128. package/src/submission.test.ts +140 -518
  129. package/src/submission.ts +494 -61
  130. package/scripts/refresh-deadlines.test.ts +0 -431
  131. 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: "Read or change a paper's submission on the venue's HotCRP portal via `paperlint submission show|update`: replace the PDF or abstract, mark it submitted, check it is really \"Submitted\", and verify by sha256 that the portal holds the latest build. Use when the PDF or abstract on HotCRP needs replacing, when asked whether the paper is truly submitted or which PDF reviewers see, or before a deadline. Composes with submit-paper (what to submit); this gets it onto the portal and proves it landed."
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:4b3a8c0b51ebe206 compiled from skills/submission-portal/SKILL.md.spec.ts -->
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` talks to the venue's submission portal through its API. Only HotCRP is
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). For your own venue preset, add it there.
18
- - **The paper's `paperlint.json` names its submission**: `"submission": { "id": <number> }` — the
19
- number HotCRP shows for the paper.
20
- - **A token in `HOTCRP_TOKEN`**, created by the author on the HotCRP site under Account settings →
21
- Developer (scopes `submeta:read` and `document:read` to read; `submeta:write` and
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 the file and key, or the
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
- 1. **Read it.** `npx paperlint submission show papers/<paper>` — status, title, type, topics,
31
- abstract length, the PDF the portal holds, and `match`: whether that PDF's sha256 equals the
32
- local `paper.pdf`. Rebuild first (`npx paperlint build papers/<paper>`) if the local PDF is stale.
33
- 2. **Dry run.** `npx paperlint submission update papers/<paper>` — sends the PDF (add
34
- `--abstract <file>` for a plain-text abstract, `--submit` to mark it submitted) with HotCRP's
35
- `dry_run=1`: the portal checks the change and **keeps nothing**. Read `valid`, `changes` and every
36
- `portal says:` line. Exit 1 means the portal would refuse it — report why, do not go on.
37
- 3. **Ask the author.** Show the dry run's output and ask for an explicit yes to change the real
38
- submission.
39
- 4. **Save — only after that yes.** `npx paperlint submission update papers/<paper> --save` (same
40
- flags as the dry run). 🔴 `--save` CHANGES A REAL SUBMISSION that reviewers will see. Never run it
41
- without the author's explicit yes in this conversation, never "to test", and never because a
42
- dry run was valid.
43
- 5. **Prove it landed.** `npx paperlint submission show papers/<paper>` again: `match` must read
44
- `yes — the portal holds this build`, and the status must be what was intended (`submitted` if
45
- `--submit` was sent). Anything else is not done — say so.
46
-
47
- The same five steps as commands, in order (`<paper>` is the paper's folder):
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 submission show papers/<paper>
51
- npx paperlint submission update papers/<paper>
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/<paper> --save
54
- npx paperlint submission show papers/<paper>
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; a status of `submitted` alone
60
- says nothing about WHICH PDF.
61
- - `the portal refused (HTTP 401): …` — the token is missing, wrong or expired; `403` — the token
62
- lacks the scope, or the deadline has passed for this change. The text after the colon is
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 (that is `harden-paper`), anonymize it, or fill the
69
- submission form's other fields (authors, conflicts) — see `submit-paper`.
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`.