paperlint 7.0.0 → 7.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. package/.github/workflows/ci.yml +3 -0
  2. package/.github/workflows/deadlines.yml +100 -0
  3. package/README.md +24 -11
  4. package/dist/adapters/hotcrp/deadlines.d.ts +8 -0
  5. package/dist/adapters/hotcrp/deadlines.d.ts.map +1 -0
  6. package/dist/adapters/hotcrp/deadlines.io.d.ts +6 -0
  7. package/dist/adapters/hotcrp/deadlines.io.d.ts.map +1 -0
  8. package/dist/adapters/hotcrp/deadlines.io.js +26 -0
  9. package/dist/adapters/hotcrp/deadlines.io.js.map +1 -0
  10. package/dist/adapters/hotcrp/deadlines.js +134 -0
  11. package/dist/adapters/hotcrp/deadlines.js.map +1 -0
  12. package/dist/adapters/hotcrp/index.d.ts +2 -0
  13. package/dist/adapters/hotcrp/index.d.ts.map +1 -1
  14. package/dist/adapters/hotcrp/index.js +2 -0
  15. package/dist/adapters/hotcrp/index.js.map +1 -1
  16. package/dist/cli.d.ts.map +1 -1
  17. package/dist/cli.js +42 -7
  18. package/dist/cli.js.map +1 -1
  19. package/dist/cycle-rules.d.ts +24 -0
  20. package/dist/cycle-rules.d.ts.map +1 -0
  21. package/dist/cycle-rules.js +212 -0
  22. package/dist/cycle-rules.js.map +1 -0
  23. package/dist/domain/body-pages.d.ts +29 -0
  24. package/dist/domain/body-pages.d.ts.map +1 -1
  25. package/dist/domain/body-pages.js +25 -0
  26. package/dist/domain/body-pages.js.map +1 -1
  27. package/dist/domain/cycle.d.ts +148 -0
  28. package/dist/domain/cycle.d.ts.map +1 -0
  29. package/dist/domain/cycle.js +281 -0
  30. package/dist/domain/cycle.js.map +1 -0
  31. package/dist/domain/deadline.d.ts +128 -0
  32. package/dist/domain/deadline.d.ts.map +1 -0
  33. package/dist/domain/deadline.js +281 -0
  34. package/dist/domain/deadline.js.map +1 -0
  35. package/dist/eslint-rules/cycle-stage.d.ts +39 -0
  36. package/dist/eslint-rules/cycle-stage.d.ts.map +1 -0
  37. package/dist/eslint-rules/cycle-stage.js +119 -0
  38. package/dist/eslint-rules/cycle-stage.js.map +1 -0
  39. package/dist/lib/paper-config.d.ts.map +1 -1
  40. package/dist/lib/paper-config.js +2 -0
  41. package/dist/lib/paper-config.js.map +1 -1
  42. package/dist/paper-includes.d.ts +3 -0
  43. package/dist/paper-includes.d.ts.map +1 -1
  44. package/dist/paper-includes.js +17 -2
  45. package/dist/paper-includes.js.map +1 -1
  46. package/dist/paper-settings.d.ts +7 -0
  47. package/dist/paper-settings.d.ts.map +1 -1
  48. package/dist/paper-settings.js +61 -8
  49. package/dist/paper-settings.js.map +1 -1
  50. package/dist/ports/venue-deadlines.d.ts +14 -0
  51. package/dist/ports/venue-deadlines.d.ts.map +1 -0
  52. package/dist/ports/venue-deadlines.js +2 -0
  53. package/dist/ports/venue-deadlines.js.map +1 -0
  54. package/dist/presets.d.ts +22 -3
  55. package/dist/presets.d.ts.map +1 -1
  56. package/dist/presets.js +26 -17
  57. package/dist/presets.js.map +1 -1
  58. package/dist/submission.d.ts +16 -0
  59. package/dist/submission.d.ts.map +1 -1
  60. package/dist/submission.js +59 -16
  61. package/dist/submission.js.map +1 -1
  62. package/dist/tex-requirements.d.ts +12 -0
  63. package/dist/tex-requirements.d.ts.map +1 -1
  64. package/dist/tex-requirements.js +26 -3
  65. package/dist/tex-requirements.js.map +1 -1
  66. package/dist/tex-venue-rules.d.ts +24 -2
  67. package/dist/tex-venue-rules.d.ts.map +1 -1
  68. package/dist/tex-venue-rules.js +98 -10
  69. package/dist/tex-venue-rules.js.map +1 -1
  70. package/dist/venue-rules.d.ts +12 -1
  71. package/dist/venue-rules.d.ts.map +1 -1
  72. package/dist/venue-rules.js +81 -17
  73. package/dist/venue-rules.js.map +1 -1
  74. package/docs/configuration.md +140 -12
  75. package/docs/e2e.md +14 -12
  76. package/docs/rules/cycle/evidence.md +87 -0
  77. package/docs/rules/cycle/port-done.md +75 -0
  78. package/docs/rules/cycle/record.md +152 -0
  79. package/docs/rules/cycle/stage.md +85 -0
  80. package/docs/rules/format/page-limit.md +18 -1
  81. package/docs/rules/tex/template.md +25 -5
  82. package/docs/rules/tex/venue-leftover.md +13 -0
  83. package/docs/rules.md +68 -55
  84. package/docs/submission.md +10 -1
  85. package/eslint-rules/cycle-stage.test.mjs +131 -0
  86. package/eslint-rules/cycle-stage.ts +141 -0
  87. package/eslint.config.mjs +41 -1
  88. package/fixtures/build-e2e/msr/PIPELINE-STATUS.md +3 -0
  89. package/fixtures/build-e2e/msr/expect.json +18 -0
  90. package/fixtures/build-e2e/msr/paper.tex +49 -0
  91. package/fixtures/build-e2e/msr/paperlint.json +1 -0
  92. package/fixtures/build-e2e/msr-appendix-after/PIPELINE-STATUS.md +3 -0
  93. package/fixtures/build-e2e/msr-appendix-after/expect.json +20 -0
  94. package/fixtures/build-e2e/msr-appendix-after/paper.tex +48 -0
  95. package/fixtures/build-e2e/msr-appendix-after/paperlint.json +1 -0
  96. package/lib/paper-config.ts +3 -0
  97. package/package.json +1 -1
  98. package/presets/aidc.jsonc +24 -3
  99. package/presets/aidc.md +6 -3
  100. package/presets/ieee-conference.md +1 -1
  101. package/presets/msr.jsonc +95 -0
  102. package/presets/msr.md +98 -0
  103. package/presets/venue-profile.schema.json +64 -0
  104. package/scripts/check.ts +4 -0
  105. package/scripts/refresh-deadlines.test.ts +431 -0
  106. package/scripts/refresh-deadlines.ts +387 -0
  107. package/skills/camera-ready/SKILL.md +8 -1
  108. package/skills/camera-ready/SKILL.md.spec.ts +7 -0
  109. package/skills/submission-portal/SKILL.md +2 -2
  110. package/skills/submission-portal/SKILL.md.spec.ts +1 -1
  111. package/skills/submit-paper/SKILL.md +9 -1
  112. package/skills/submit-paper/SKILL.md.spec.ts +8 -0
  113. package/src/adapters/hotcrp/cassette/README.md +22 -0
  114. package/src/adapters/hotcrp/cassette/deadlines-aidc26.html +17 -0
  115. package/src/adapters/hotcrp/cassette/deadlines-fse2027.html +24 -0
  116. package/src/adapters/hotcrp/cassette/deadlines-icse2027.html +25 -0
  117. package/src/adapters/hotcrp/cassette/deadlines-msr2027.html +25 -0
  118. package/src/adapters/hotcrp/cassette/deadlines-nosuch.html +18 -0
  119. package/src/adapters/hotcrp/cassette/deadlines-sosp26.html +26 -0
  120. package/src/adapters/hotcrp/deadlines.io.test.ts +86 -0
  121. package/src/adapters/hotcrp/deadlines.io.ts +27 -0
  122. package/src/adapters/hotcrp/deadlines.test.ts +170 -0
  123. package/src/adapters/hotcrp/deadlines.ts +161 -0
  124. package/src/adapters/hotcrp/index.ts +2 -0
  125. package/src/cli.test.ts +9 -0
  126. package/src/cli.ts +50 -6
  127. package/src/cycle-rules.test.ts +278 -0
  128. package/src/cycle-rules.ts +273 -0
  129. package/src/domain/body-pages.test.ts +144 -0
  130. package/src/domain/body-pages.ts +61 -1
  131. package/src/domain/cycle.test.ts +456 -0
  132. package/src/domain/cycle.ts +432 -0
  133. package/src/domain/deadline.test.ts +386 -0
  134. package/src/domain/deadline.ts +472 -0
  135. package/src/new-paper.test.ts +28 -0
  136. package/src/paper-includes.test.ts +26 -0
  137. package/src/paper-includes.ts +20 -2
  138. package/src/paper-settings-commands.test.ts +1 -1
  139. package/src/paper-settings.test.ts +167 -0
  140. package/src/paper-settings.ts +85 -11
  141. package/src/ports/venue-deadlines.ts +14 -0
  142. package/src/preset-card-rule.test.ts +1 -0
  143. package/src/presets.test.ts +171 -1
  144. package/src/presets.ts +65 -15
  145. package/src/rule-cases.test.ts +144 -0
  146. package/src/submission.test.ts +147 -4
  147. package/src/submission.ts +100 -18
  148. package/src/tex-requirements.ts +52 -4
  149. package/src/tex-venue-rules.test.ts +237 -0
  150. package/src/tex-venue-rules.ts +130 -15
  151. package/src/venue-rules.test.ts +255 -2
  152. package/src/venue-rules.ts +115 -22
@@ -33,6 +33,7 @@ describe("parsePaperSettings", () => {
33
33
  identity: null,
34
34
  talk: null,
35
35
  submission: null,
36
+ cycles: null,
36
37
  },
37
38
  });
38
39
  expect(parsePaperSettings({})).toEqual({
@@ -45,6 +46,7 @@ describe("parsePaperSettings", () => {
45
46
  identity: null,
46
47
  talk: null,
47
48
  submission: null,
49
+ cycles: null,
48
50
  },
49
51
  });
50
52
  });
@@ -60,6 +62,7 @@ describe("parsePaperSettings", () => {
60
62
  identity: null,
61
63
  talk: null,
62
64
  submission: null,
65
+ cycles: null,
63
66
  },
64
67
  });
65
68
  });
@@ -164,6 +167,7 @@ const UNSET = {
164
167
  identity: null,
165
168
  talk: null,
166
169
  submission: null,
170
+ cycles: null,
167
171
  };
168
172
 
169
173
  describe("readPaperSettings — the root paperlint.json's defaults, the paper's file over them", () => {
@@ -220,8 +224,14 @@ describe("readPaperSettings — the root paperlint.json's defaults, the paper's
220
224
  identity: null,
221
225
  talk: null,
222
226
  submission: null,
227
+ cycles: null,
223
228
  });
224
229
  });
230
+ });
231
+
232
+ describe("readPaperSettings — a project key in a paper's file", () => {
233
+ const withFiles = (f: Record<string, string>) =>
234
+ memoryFiles({ "/work/package.json": "{}", ...f });
225
235
 
226
236
  it("papersDir in a paper's file: broken, naming the root file as its place", () => {
227
237
  const r = readPaperSettings(
@@ -264,6 +274,7 @@ describe("paperRules — `rules` in paperlint.json", () => {
264
274
  identity: null,
265
275
  talk: null,
266
276
  submission: null,
277
+ cycles: null,
267
278
  });
268
279
 
269
280
  it("no rules: none", () => {
@@ -352,3 +363,159 @@ describe("talk in a paper's paperlint.json", () => {
352
363
  expect(r.ok ? "" : r.error).toMatch(/"talk.mode"/);
353
364
  });
354
365
  });
366
+
367
+ describe("parsePaperSettings — `cycles`: the current attempt supplies extends, kind and submission", () => {
368
+ const closed = {
369
+ id: "alpha-2026",
370
+ venue: { kind: "preset", extends: "paperlint:acm-sigconf" },
371
+ kind: "short",
372
+ opened: "2026-07-01",
373
+ outcome: {
374
+ kind: "rejected",
375
+ date: "2026-09-08",
376
+ desk: true,
377
+ evidence: "reviews/alpha.md",
378
+ },
379
+ };
380
+ const open = {
381
+ id: "beta-2027",
382
+ venue: { kind: "preset", extends: "paperlint:ieee-conference" },
383
+ kind: "technical",
384
+ opened: "2026-09-09",
385
+ submission: { id: 52 },
386
+ outcome: { kind: "open" },
387
+ };
388
+ const settingsOf = (json: unknown) => {
389
+ const r = parsePaperSettings(json);
390
+ if (!r.ok) throw new Error(r.error);
391
+ return r.value;
392
+ };
393
+
394
+ it("an open cycle: its venue, kind and submission are the paper's", () => {
395
+ const s = settingsOf({ cycles: [closed, open] });
396
+ expect(s.extends).toBe("paperlint:ieee-conference");
397
+ expect(s.kind).toBe("technical");
398
+ expect(s.submission).toEqual({ id: 52 });
399
+ expect(s.cycles?.current.kind).toBe("cycle");
400
+ expect(s.cycles?.list.map((c) => c.id)).toEqual([
401
+ "alpha-2026",
402
+ "beta-2027",
403
+ ]);
404
+ });
405
+
406
+ it("parked (the last cycle closed) and none (`cycles: []`): no venue, as `extends: null` reads", () => {
407
+ const parked = settingsOf({ cycles: [closed] });
408
+ expect([parked.extends, parked.kind, parked.submission]).toEqual([
409
+ null,
410
+ null,
411
+ null,
412
+ ]);
413
+ expect(parked.cycles?.current.kind).toBe("parked");
414
+ expect(settingsOf({ cycles: [] }).cycles?.current).toEqual({
415
+ kind: "none",
416
+ });
417
+ });
418
+ });
419
+
420
+ describe("parsePaperSettings — `cycles`, closed and named", () => {
421
+ const closed = {
422
+ id: "alpha-2026",
423
+ venue: { kind: "preset", extends: "paperlint:acm-sigconf" },
424
+ kind: "short",
425
+ opened: "2026-07-01",
426
+ outcome: { kind: "withdrawn", date: "2026-09-08", evidence: "n.md" },
427
+ };
428
+ const open = {
429
+ id: "beta-2027",
430
+ venue: { kind: "preset", extends: "paperlint:ieee-conference" },
431
+ kind: "technical",
432
+ opened: "2026-09-09",
433
+ };
434
+ const settingsOf = (json: unknown) => {
435
+ const r = parsePaperSettings(json);
436
+ if (!r.ok) throw new Error(r.error);
437
+ return r.value;
438
+ };
439
+
440
+ it("an accepted last cycle keeps its venue and kind: camera-ready and talk are judged against it", () => {
441
+ const accepted = { kind: "accepted", date: "2026-08-21", evidence: "r.md" };
442
+ const s = settingsOf({ cycles: [{ ...closed, outcome: accepted }] });
443
+ expect([s.extends, s.kind, s.cycles?.current.kind]).toEqual([
444
+ "paperlint:acm-sigconf",
445
+ "short",
446
+ "accepted",
447
+ ]);
448
+ });
449
+
450
+ it("a named venue (no preset yet) resolves no preset: extends is null, the record stays", () => {
451
+ const venue = {
452
+ kind: "named",
453
+ name: "X '27",
454
+ url: "https://example.org/c",
455
+ };
456
+ const s = settingsOf({ cycles: [{ ...open, venue }] });
457
+ expect([s.extends, s.kind]).toEqual([null, "technical"]);
458
+ });
459
+ });
460
+
461
+ describe("parsePaperSettings — `cycles` refused", () => {
462
+ const open = {
463
+ id: "beta-2027",
464
+ venue: { kind: "preset", extends: "paperlint:ieee-conference" },
465
+ opened: "2026-09-09",
466
+ };
467
+ const alsoOpen = {
468
+ id: "alpha-2026",
469
+ venue: { kind: "preset", extends: "paperlint:acm-sigconf" },
470
+ opened: "2026-07-01",
471
+ };
472
+
473
+ it("one derived key beside `cycles` is refused in the singular", () => {
474
+ const error: unknown = expect.stringMatching(
475
+ /^"kind" beside "cycles": with cycles, it is the current attempt's/,
476
+ );
477
+ expect(parsePaperSettings({ kind: "short", cycles: [open] })).toEqual({
478
+ ok: false,
479
+ error,
480
+ });
481
+ });
482
+
483
+ it("a derived key written beside `cycles` is refused, naming it — two sources for one fact", () => {
484
+ const error: unknown = expect.stringMatching(
485
+ /^"extends", "kind" beside "cycles": with cycles, they are the current attempt's/,
486
+ );
487
+ expect(
488
+ parsePaperSettings({
489
+ extends: "paperlint:acm-sigconf",
490
+ kind: "short",
491
+ cycles: [open],
492
+ }),
493
+ ).toEqual({ ok: false, error });
494
+ });
495
+
496
+ it("two open cycles is refused as the file's problem: dual submission has no current venue", () => {
497
+ const error: unknown = expect.stringMatching(
498
+ /are both open — a paper is on one attempt/,
499
+ );
500
+ expect(parsePaperSettings({ cycles: [alsoOpen, open] })).toEqual({
501
+ ok: false,
502
+ error,
503
+ });
504
+ });
505
+
506
+ it("a malformed cycle is refused with the entry and key, through readPaperSettings as `broken`", () => {
507
+ const files = memoryFiles({
508
+ "/work/package.json": "{}",
509
+ [`${PAPER}/paperlint.json`]: JSON.stringify({
510
+ cycles: [{ ...open, opened: "soon" }],
511
+ }),
512
+ });
513
+ const why: unknown = expect.stringMatching(
514
+ /^cycles\[0\]: "opened" must be a date/,
515
+ );
516
+ expect(readPaperSettings(files, PAPER)).toEqual({
517
+ ok: false,
518
+ error: { kind: "broken", why },
519
+ });
520
+ });
521
+ });
@@ -4,7 +4,9 @@
4
4
  * One file name at two levels, one schema (lib/paper-config.mjs → SETTINGS_KEYS):
5
5
  *
6
6
  * paperlint.json the project (optional): papersDir, rules, defaults
7
- * <paper>/paperlint.json this paper: { extends, kind, pdf, rules, identity, talk, submission }
7
+ * <paper>/paperlint.json this paper: { extends, kind, pdf, rules, identity, talk, submission } —
8
+ * or `cycles`, from whose current attempt extends, kind and submission
9
+ * are DERIVED (src/domain/cycle.ts); the two forms do not mix
8
10
  * a venue preset `paperlint:<name>` (shipped) or `./x.jsonc` (src/presets.ts)
9
11
  *
10
12
  * The paper's `extends`, `kind` and `pdf` win over the root's; with no own value, the root's is
@@ -36,6 +38,13 @@ import {
36
38
  parsePaperSubmission,
37
39
  type PaperSubmission,
38
40
  } from "./domain/submission.ts";
41
+ import {
42
+ cycleProblemText,
43
+ cyclesOf,
44
+ parseCycles,
45
+ venueCycleOf,
46
+ type Cycles,
47
+ } from "./domain/cycle.ts";
39
48
 
40
49
  /** What one paper declares. Every absent field is null. */
41
50
  export interface PaperSettings {
@@ -60,6 +69,12 @@ export interface PaperSettings {
60
69
  readonly talk: PaperTalk | null;
61
70
  /** Which submission on the venue's portal is this paper's; null when it says none. Never the root's. */
62
71
  readonly submission: PaperSubmission | null;
72
+ /**
73
+ * The paper's attempts at venues and the current one, when the file declares `cycles`; null for a
74
+ * file in the flat form. With cycles, `extends`, `kind` and `submission` above are the current
75
+ * attempt's — derived, never written beside it. Never the root's.
76
+ */
77
+ readonly cycles: Cycles | null;
63
78
  }
64
79
 
65
80
  /** Why a paper's settings cannot be read. */
@@ -139,28 +154,85 @@ function rulesAndTalk(
139
154
  : submission;
140
155
  }
141
156
 
142
- /** The parsed JSON of `paperlint.json` → the settings, or one line saying what is wrong. Pure. */
143
- export function parsePaperSettings(
144
- json: unknown,
145
- ): Result<PaperSettings, string> {
146
- if (!isObject(json)) return err("must be a JSON object");
157
+ /** The keys the current cycle supplies: writing one beside `cycles` is a second source for it. */
158
+ const DERIVED_FROM_CYCLE = ["extends", "kind", "submission"] as const;
159
+
160
+ /**
161
+ * `cycles`, parsed with its current attempt, or null when the file is in the flat form. A file that
162
+ * has both `cycles` and a derived key is refused: two sources for one fact drift apart.
163
+ */
164
+ function cyclesField(
165
+ json: Readonly<Record<string, unknown>>,
166
+ ): Result<Cycles | null, string> {
167
+ const list = parseCycles(json["cycles"]);
168
+ if (!list.ok) return list;
169
+ if (list.value === null) return ok(null);
170
+ const beside = DERIVED_FROM_CYCLE.filter((k) => k in json);
171
+ if (beside.length > 0)
172
+ return err(
173
+ `"${beside.join('", "')}" beside "cycles": with cycles, ${beside.length === 1 ? "it is" : "they are"} the current attempt's and cannot be set here — move the value into the open cycle`,
174
+ );
175
+ const cycles = cyclesOf(list.value);
176
+ return cycles.ok ? cycles : err(cycleProblemText(cycles.error));
177
+ }
178
+
179
+ /**
180
+ * The current attempt's venue, kind and submission, as the flat keys every reader takes. An accepted
181
+ * attempt keeps them: the camera-ready and the talk are judged against the venue that accepted.
182
+ */
183
+ function derived(
184
+ cycles: Cycles,
185
+ ): Pick<PaperSettings, (typeof DERIVED_FROM_CYCLE)[number]> {
186
+ const c = venueCycleOf(cycles.current);
187
+ if (c === null) return { extends: null, kind: null, submission: null };
188
+ return {
189
+ extends: c.venue.kind === "preset" ? c.venue.extends : null,
190
+ kind: c.kind,
191
+ submission: c.submission,
192
+ };
193
+ }
194
+
195
+ /** The keys that may not be in a paper's file: unknown ones, and the project's. */
196
+ function refusedKeys(
197
+ json: Readonly<Record<string, unknown>>,
198
+ ): Result<Readonly<Record<string, unknown>>, string> {
147
199
  const unknown = Object.keys(json).filter((k) => !KNOWN.includes(k));
148
200
  if (unknown.length > 0)
149
201
  return err(
150
202
  `unknown key "${String(unknown[0])}" — known keys: ${KNOWN.join(", ")}`,
151
203
  );
152
204
  const project = Object.keys(json).filter((k) => ROOT_ONLY_KEYS.includes(k));
153
- if (project.length > 0)
154
- return err(
155
- `"${String(project[0])}" is a project setting — set it in the root ${CONFIG_FILE}, not in a paper's`,
156
- );
205
+ return project.length > 0
206
+ ? err(
207
+ `"${String(project[0])}" is a project setting — set it in the root ${CONFIG_FILE}, not in a paper's`,
208
+ )
209
+ : ok(json);
210
+ }
211
+
212
+ /** The parsed JSON of `paperlint.json` → the settings, or one line saying what is wrong. Pure. */
213
+ export function parsePaperSettings(
214
+ json: unknown,
215
+ ): Result<PaperSettings, string> {
216
+ if (!isObject(json)) return err("must be a JSON object");
217
+ const keys = refusedKeys(json);
218
+ if (!keys.ok) return keys;
157
219
  const fields = stringFields(json);
158
220
  if (!fields.ok) return fields;
159
221
  const identity = identityField(json);
160
222
  if (!identity.ok) return identity;
161
223
  const rest = rulesAndTalk(json);
162
224
  if (!rest.ok) return rest;
163
- return ok({ ...fields.value, ...rest.value, identity: identity.value });
225
+ const cycles = cyclesField(json);
226
+ if (!cycles.ok) return cycles;
227
+ const flat = {
228
+ ...fields.value,
229
+ ...rest.value,
230
+ identity: identity.value,
231
+ cycles: cycles.value,
232
+ };
233
+ return ok(
234
+ cycles.value === null ? flat : { ...flat, ...derived(cycles.value) },
235
+ );
164
236
  }
165
237
 
166
238
  /** A JSON file through `files`: undefined when absent, the problem when it does not parse. */
@@ -251,6 +323,7 @@ function merge(
251
323
  rules: null,
252
324
  talk: null,
253
325
  submission: null,
326
+ cycles: null,
254
327
  };
255
328
  const merged: PaperSettings = {
256
329
  extends: p.extends ?? r.extends,
@@ -260,6 +333,7 @@ function merge(
260
333
  identity: joined(r.identity, p.identity),
261
334
  talk: p.talk,
262
335
  submission: p.submission,
336
+ cycles: p.cycles,
263
337
  };
264
338
  return paper === null && Object.values(merged).every((v) => v === null)
265
339
  ? null
@@ -0,0 +1,14 @@
1
+ /**
2
+ * A venue's deadlines as its portal publishes them — the port the scheduled refresh of the shipped
3
+ * presets reads through (`scripts/refresh-deadlines.ts`). Shaped by what the refresh needs (the
4
+ * portal's deadlines, once), not by any portal's page; one adapter per portal kind
5
+ * (`src/adapters/hotcrp/`).
6
+ */
7
+ import type { Result } from "../domain/result.ts";
8
+ import type { PortalDeadlines } from "../domain/deadline.ts";
9
+ import type { PortalFailure } from "../domain/submission.ts";
10
+
11
+ export interface VenueDeadlines {
12
+ /** The deadlines the portal publishes now. Needs no account: the page is public. */
13
+ read(): Promise<Result<PortalDeadlines, PortalFailure>>;
14
+ }
@@ -91,6 +91,7 @@ describe("the shipped cards", () => {
91
91
  "aidc.md",
92
92
  "aisec.md",
93
93
  "ieee-conference.md",
94
+ "msr.md",
94
95
  "realm.md",
95
96
  "tex-base.md",
96
97
  ]);
@@ -63,6 +63,7 @@ describe("shipped presets", () => {
63
63
  expect(r.value.template).toEqual({
64
64
  text: "\\documentclass[sigconf]{acmart}",
65
65
  file: join(VENUES, "acm-sigconf.jsonc"),
66
+ forbids: [],
66
67
  });
67
68
  expect("acmart" in r.value.tex.packages).toBe(true);
68
69
  // The label is the venue's own name, from its file's `name`; the id stays the file name — the
@@ -75,7 +76,57 @@ describe("shipped presets", () => {
75
76
  url: "https://conf.researchr.org/home/ase-2026/agenticdev-2026",
76
77
  });
77
78
  });
79
+ });
80
+
81
+ describe("shipped presets: paperlint:msr", () => {
82
+ it("paperlint:msr → msr over ieee-conference: one kind, appendices counted as body, compsoc forbidden", () => {
83
+ const r = resolve("paperlint:msr");
84
+ expect(r.ok).toBe(true);
85
+ if (!r.ok) return;
86
+ expect(r.value.chain.map((f) => f.split("/").pop())).toEqual([
87
+ "ieee-conference.jsonc",
88
+ "msr.jsonc",
89
+ ]);
90
+ expect([...r.value.format.kinds]).toEqual([
91
+ ["technical", { bodyPagesMax: 10, refPagesMax: 2 }],
92
+ ]);
93
+ expect(r.value.format.bodyEndsAt).toBe("references");
94
+ expect(r.value.format.appendixInBody).toBe(true);
95
+ expect(r.value.format.pageWidthIn).toBe(8.5);
96
+ expect(r.value.template).toEqual({
97
+ text: "\\documentclass[10pt,conference]{IEEEtran}",
98
+ file: join(VENUES, "msr.jsonc"),
99
+ forbids: ["compsoc", "compsocconf"],
100
+ });
101
+ expect(r.value.blind).toBe(true);
102
+ expect(r.value.portal).toEqual({
103
+ kind: "hotcrp",
104
+ url: "https://msr2027.hotcrp.com",
105
+ });
106
+ // The portal's deadlines, read 2026-10-05 off cassette/deadlines-msr2027.html's page.
107
+ expect(r.value.deadlines).toEqual([
108
+ {
109
+ what: "submission",
110
+ at: "2026-10-20T04:00:00Z",
111
+ source: "portal",
112
+ url: "https://msr2027.hotcrp.com/deadlines",
113
+ read: "2026-10-05",
114
+ },
115
+ {
116
+ what: "resubmission",
117
+ at: "2026-10-23T04:00:00Z",
118
+ source: "portal",
119
+ url: "https://msr2027.hotcrp.com/deadlines",
120
+ read: "2026-10-05",
121
+ },
122
+ ]);
123
+ // No rule turned on: the IEEE capitalization rule has not been read and quoted.
124
+ expect(r.value.rules).toEqual({});
125
+ expect(r.value.registerAnchors).toEqual([]);
126
+ });
127
+ });
78
128
 
129
+ describe("shipped presets: a family, and a venue that stands alone", () => {
79
130
  it("a paper may extend a family directly: format and fonts, and no kinds", () => {
80
131
  const r = resolve("paperlint:acm-sigconf");
81
132
  expect(r.ok && r.value.format.kinds.size).toBe(0);
@@ -139,6 +190,11 @@ describe("what each shipped preset declares itself to be", () => {
139
190
  url: "https://aisec.cc/",
140
191
  },
141
192
  "ieee-conference.jsonc": { type: "family" },
193
+ "msr.jsonc": {
194
+ type: "venue",
195
+ name: "MSR",
196
+ url: "https://2027.msrconf.org/track/msr-2027-technical-papers",
197
+ },
142
198
  "realm.jsonc": {
143
199
  type: "venue",
144
200
  name: "REALM",
@@ -287,6 +343,94 @@ describe("a project's own preset: what its children replace", () => {
287
343
  });
288
344
  });
289
345
 
346
+ describe("a project's own preset: its deadlines", () => {
347
+ const reading = (what: string, at: string, source = "portal") => ({
348
+ what,
349
+ at,
350
+ source,
351
+ url: "https://example.org/deadlines",
352
+ read: "2026-10-05",
353
+ });
354
+
355
+ it("a child's deadlines replace its parent's whole; none named keeps the parent's", () => {
356
+ const own = resolve("./mine.jsonc", {
357
+ "/work/papers/p/mine.jsonc": venue({
358
+ extends: "paperlint:msr",
359
+ deadlines: [reading("submission", "2026-10-21T04:00:00Z")],
360
+ }),
361
+ });
362
+ expect(own.ok && own.value.deadlines).toEqual([
363
+ reading("submission", "2026-10-21T04:00:00Z"),
364
+ ]);
365
+ const inherited = resolve("./mine.jsonc", {
366
+ "/work/papers/p/mine.jsonc": venue({ extends: "paperlint:msr" }),
367
+ });
368
+ expect(inherited.ok && inherited.value.deadlines.length).toBe(2);
369
+ const none = resolve("./mine.jsonc", {
370
+ "/work/papers/p/mine.jsonc": venue({ extends: "paperlint:aisec" }),
371
+ });
372
+ expect(none.ok && none.value.deadlines).toEqual([]);
373
+ });
374
+
375
+ it("the portal's and the call's reading of one kind may stand side by side", () => {
376
+ const r = resolve("./mine.jsonc", {
377
+ "/work/papers/p/mine.jsonc": venue({
378
+ extends: "paperlint:aisec",
379
+ deadlines: [
380
+ reading("submission", "2026-10-20T04:00:00Z"),
381
+ reading("submission", "2026-10-21T11:59:59Z", "call"),
382
+ ],
383
+ }),
384
+ });
385
+ expect(r.ok && r.value.deadlines.map((d) => d.source)).toEqual([
386
+ "portal",
387
+ "call",
388
+ ]);
389
+ });
390
+ });
391
+
392
+ describe("a project's own preset: deadlines it refuses", () => {
393
+ const reading = (what: string, at: string, source = "portal") => ({
394
+ what,
395
+ at,
396
+ source,
397
+ url: "https://example.org/deadlines",
398
+ read: "2026-10-05",
399
+ });
400
+
401
+ it.each([
402
+ [
403
+ "two readings of one kind from one source",
404
+ [
405
+ reading("submission", "2026-10-20T04:00:00Z"),
406
+ reading("submission", "2026-10-21T04:00:00Z"),
407
+ ],
408
+ /"deadlines" holds two "portal" readings of "submission" — keep one, the latest/,
409
+ ],
410
+ [
411
+ "an instant that is not UTC as a portal publishes it",
412
+ [reading("submission", "2026-10-20 AoE")],
413
+ /deadlines/,
414
+ ],
415
+ [
416
+ "a reading without the day it was read",
417
+ [{ ...reading("submission", "2026-10-20T04:00:00Z"), read: undefined }],
418
+ /deadlines/,
419
+ ],
420
+ ])("refuses %s, naming the file", (_what, deadlines, re) => {
421
+ const r = resolve("./mine.jsonc", {
422
+ "/work/papers/p/mine.jsonc": venue({
423
+ extends: "paperlint:aisec",
424
+ deadlines,
425
+ }),
426
+ });
427
+ expect(r.ok).toBe(false);
428
+ if (r.ok) return;
429
+ expect(presetProblemText(r.error)).toMatch(/mine\.jsonc/);
430
+ expect(presetProblemText(r.error)).toMatch(re);
431
+ });
432
+ });
433
+
290
434
  describe("what does not resolve, and says why", () => {
291
435
  it.each([
292
436
  [
@@ -300,7 +444,7 @@ describe("what does not resolve, and says why", () => {
300
444
  "a shipped name with a typo",
301
445
  "paperlint:agenticdve",
302
446
  "not-found",
303
- /acm-sigconf, agenticdev, aidc, aisec, ieee-conference, realm/,
447
+ /acm-sigconf, agenticdev, aidc, aisec, ieee-conference, msr, realm/,
304
448
  ],
305
449
  ["the base TeX set", "paperlint:tex-base", "not-found", /agenticdev/],
306
450
  [
@@ -422,8 +566,32 @@ describe("a preset's template: kept as its file spells it, with that file", () =
422
566
  expect(r.ok && r.value.template).toEqual({
423
567
  text: "\\documentclass[conference]{IEEEtran}",
424
568
  file: "/work/papers/p/own.jsonc",
569
+ forbids: [],
425
570
  });
426
571
  });
572
+
573
+ it("the options a file forbids travel with its template", () => {
574
+ const r = resolve("./own.jsonc", {
575
+ "/work/papers/p/own.jsonc": venue({
576
+ extends: "paperlint:ieee-conference",
577
+ template: "\\documentclass[conference]{IEEEtran}",
578
+ template_forbids: ["compsoc"],
579
+ }),
580
+ });
581
+ expect(r.ok && r.value.template?.forbids).toEqual(["compsoc"]);
582
+ });
583
+
584
+ it("forbidden options without a template of the same file are refused by the schema", () => {
585
+ const r = resolve("./own.jsonc", {
586
+ "/work/papers/p/own.jsonc": venue({
587
+ extends: "paperlint:aidc",
588
+ template_forbids: ["compsoc"],
589
+ }),
590
+ });
591
+ expect(r.ok).toBe(false);
592
+ if (r.ok) return;
593
+ expect(presetProblemText(r.error)).toMatch(/template_forbids/);
594
+ });
427
595
  });
428
596
 
429
597
  describe("labelOf — display only, never used to resolve", () => {
@@ -448,6 +616,7 @@ describe("shippedVenueNames — what each shipped venue is called", () => {
448
616
  "AIDC",
449
617
  "AISec",
450
618
  "ieee-conference",
619
+ "MSR",
451
620
  "REALM",
452
621
  ]);
453
622
  expect(names.find((n) => n.label === "AISec")?.aliases).toEqual(
@@ -469,6 +638,7 @@ describe("shippedVenueNames — what each shipped venue is called", () => {
469
638
  "AIDC",
470
639
  "AISec",
471
640
  "ieee-conference",
641
+ "MSR",
472
642
  ]);
473
643
  });
474
644
  });