paperlint 7.1.0 → 7.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/.github/workflows/ci.yml +3 -0
  2. package/.github/workflows/deadlines.yml +100 -0
  3. package/README.md +6 -0
  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 +40 -5
  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/cycle.d.ts +148 -0
  24. package/dist/domain/cycle.d.ts.map +1 -0
  25. package/dist/domain/cycle.js +281 -0
  26. package/dist/domain/cycle.js.map +1 -0
  27. package/dist/domain/deadline.d.ts +128 -0
  28. package/dist/domain/deadline.d.ts.map +1 -0
  29. package/dist/domain/deadline.js +281 -0
  30. package/dist/domain/deadline.js.map +1 -0
  31. package/dist/eslint-rules/cycle-stage.d.ts +39 -0
  32. package/dist/eslint-rules/cycle-stage.d.ts.map +1 -0
  33. package/dist/eslint-rules/cycle-stage.js +119 -0
  34. package/dist/eslint-rules/cycle-stage.js.map +1 -0
  35. package/dist/lib/paper-config.d.ts.map +1 -1
  36. package/dist/lib/paper-config.js +2 -0
  37. package/dist/lib/paper-config.js.map +1 -1
  38. package/dist/paper-settings.d.ts +7 -0
  39. package/dist/paper-settings.d.ts.map +1 -1
  40. package/dist/paper-settings.js +61 -8
  41. package/dist/paper-settings.js.map +1 -1
  42. package/dist/ports/venue-deadlines.d.ts +14 -0
  43. package/dist/ports/venue-deadlines.d.ts.map +1 -0
  44. package/dist/ports/venue-deadlines.js +2 -0
  45. package/dist/ports/venue-deadlines.js.map +1 -0
  46. package/dist/presets.d.ts +12 -0
  47. package/dist/presets.d.ts.map +1 -1
  48. package/dist/presets.js +19 -11
  49. package/dist/presets.js.map +1 -1
  50. package/dist/submission.d.ts +8 -0
  51. package/dist/submission.d.ts.map +1 -1
  52. package/dist/submission.js +41 -10
  53. package/dist/submission.js.map +1 -1
  54. package/dist/tex-requirements.d.ts +3 -0
  55. package/dist/tex-requirements.d.ts.map +1 -1
  56. package/dist/tex-requirements.js +19 -0
  57. package/dist/tex-requirements.js.map +1 -1
  58. package/dist/tex-venue-rules.d.ts +19 -0
  59. package/dist/tex-venue-rules.d.ts.map +1 -1
  60. package/dist/tex-venue-rules.js +69 -0
  61. package/dist/tex-venue-rules.js.map +1 -1
  62. package/dist/venue-rules.d.ts +10 -0
  63. package/dist/venue-rules.d.ts.map +1 -1
  64. package/dist/venue-rules.js +26 -10
  65. package/dist/venue-rules.js.map +1 -1
  66. package/docs/configuration.md +139 -11
  67. package/docs/rules/cycle/evidence.md +87 -0
  68. package/docs/rules/cycle/port-done.md +75 -0
  69. package/docs/rules/cycle/record.md +152 -0
  70. package/docs/rules/cycle/stage.md +85 -0
  71. package/docs/rules.md +48 -43
  72. package/docs/submission.md +5 -0
  73. package/eslint-rules/cycle-stage.test.mjs +131 -0
  74. package/eslint-rules/cycle-stage.ts +141 -0
  75. package/eslint.config.mjs +41 -1
  76. package/lib/paper-config.ts +3 -0
  77. package/package.json +1 -1
  78. package/presets/aidc.jsonc +24 -3
  79. package/presets/aidc.md +6 -3
  80. package/presets/msr.jsonc +25 -1
  81. package/presets/msr.md +5 -1
  82. package/presets/venue-profile.schema.json +39 -0
  83. package/scripts/check.ts +4 -0
  84. package/scripts/refresh-deadlines.test.ts +431 -0
  85. package/scripts/refresh-deadlines.ts +387 -0
  86. package/src/adapters/hotcrp/cassette/README.md +22 -0
  87. package/src/adapters/hotcrp/cassette/deadlines-aidc26.html +17 -0
  88. package/src/adapters/hotcrp/cassette/deadlines-fse2027.html +24 -0
  89. package/src/adapters/hotcrp/cassette/deadlines-icse2027.html +25 -0
  90. package/src/adapters/hotcrp/cassette/deadlines-msr2027.html +25 -0
  91. package/src/adapters/hotcrp/cassette/deadlines-nosuch.html +18 -0
  92. package/src/adapters/hotcrp/cassette/deadlines-sosp26.html +26 -0
  93. package/src/adapters/hotcrp/deadlines.io.test.ts +86 -0
  94. package/src/adapters/hotcrp/deadlines.io.ts +27 -0
  95. package/src/adapters/hotcrp/deadlines.test.ts +170 -0
  96. package/src/adapters/hotcrp/deadlines.ts +161 -0
  97. package/src/adapters/hotcrp/index.ts +2 -0
  98. package/src/cli.test.ts +9 -0
  99. package/src/cli.ts +43 -4
  100. package/src/cycle-rules.test.ts +278 -0
  101. package/src/cycle-rules.ts +273 -0
  102. package/src/domain/cycle.test.ts +456 -0
  103. package/src/domain/cycle.ts +432 -0
  104. package/src/domain/deadline.test.ts +386 -0
  105. package/src/domain/deadline.ts +472 -0
  106. package/src/new-paper.test.ts +1 -0
  107. package/src/paper-settings.test.ts +167 -0
  108. package/src/paper-settings.ts +85 -11
  109. package/src/ports/venue-deadlines.ts +14 -0
  110. package/src/presets.test.ts +105 -0
  111. package/src/presets.ts +43 -10
  112. package/src/rule-cases.test.ts +144 -0
  113. package/src/submission.test.ts +105 -1
  114. package/src/submission.ts +73 -12
  115. package/src/tex-requirements.ts +31 -0
  116. package/src/tex-venue-rules.test.ts +76 -0
  117. package/src/tex-venue-rules.ts +85 -0
  118. package/src/venue-rules.test.ts +82 -0
  119. package/src/venue-rules.ts +44 -12
@@ -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
+ }
@@ -103,6 +103,23 @@ describe("shipped presets: paperlint:msr", () => {
103
103
  kind: "hotcrp",
104
104
  url: "https://msr2027.hotcrp.com",
105
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
+ ]);
106
123
  // No rule turned on: the IEEE capitalization rule has not been read and quoted.
107
124
  expect(r.value.rules).toEqual({});
108
125
  expect(r.value.registerAnchors).toEqual([]);
@@ -326,6 +343,94 @@ describe("a project's own preset: what its children replace", () => {
326
343
  });
327
344
  });
328
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
+
329
434
  describe("what does not resolve, and says why", () => {
330
435
  it.each([
331
436
  [
package/src/presets.ts CHANGED
@@ -25,8 +25,8 @@
25
25
  * tex union — a child never removes a package its parent needs
26
26
  * format per key, the child wins; `kinds` by kind name, a child's kind replaces that kind
27
27
  * rules per rule id, the child wins; `ruleOrigins` keeps which file set each
28
- * template the child wins, with its `template_forbids`; so does `name`, and `required_sections`, `register`, `talk` and
29
- * `portal` (each whole)
28
+ * template the child wins, with its `template_forbids`; so does `name`, and `required_sections`, `register`, `talk`,
29
+ * `portal` and `deadlines` (each whole)
30
30
  * aliases union, with every `name` — what the venue is called along the chain
31
31
  *
32
32
  * ── THE ID AND THE LABEL ──────────────────────────────────────────────────────────
@@ -63,6 +63,7 @@ import { messageOf } from "./domain/text.ts";
63
63
  import type { RegisterAnchor } from "./domain/register.ts";
64
64
  import type { VenueTalk } from "./domain/talk.ts";
65
65
  import type { VenuePortal } from "./domain/submission.ts";
66
+ import type { Reading } from "./domain/deadline.ts";
66
67
 
67
68
  /** The prefix of a shipped preset's spec. */
68
69
  export const SHIPPED_PREFIX = "paperlint:";
@@ -123,6 +124,11 @@ export interface Preset {
123
124
  readonly talk: VenueTalk | null;
124
125
  /** Where the venue takes submissions (`paperlint submission`); null when no file of the chain says. */
125
126
  readonly portal: VenuePortal | null;
127
+ /**
128
+ * The edition's deadlines as they were read (the portal's, refreshed by paperlint's scheduled
129
+ * workflow); empty when no file of the chain names any. A paper's current cycle derives them.
130
+ */
131
+ readonly deadlines: readonly Reading[];
126
132
  }
127
133
 
128
134
  /** Why a spec does not resolve. */
@@ -293,8 +299,24 @@ const NOTHING_MERGED: Merging = {
293
299
  registerAnchors: [],
294
300
  talk: null,
295
301
  portal: null,
302
+ deadlines: [],
296
303
  };
297
304
 
305
+ /** The blocks a child replaces whole: the child's when it names one, else its parents'. Pure. */
306
+ const wholeBlocks = (
307
+ acc: Merging,
308
+ p: PresetFile,
309
+ ): Pick<
310
+ Merging,
311
+ "requiredSections" | "registerAnchors" | "talk" | "portal" | "deadlines"
312
+ > => ({
313
+ requiredSections: p.requiredSections ?? acc.requiredSections,
314
+ registerAnchors: p.registerAnchors ?? acc.registerAnchors,
315
+ talk: p.talk ?? acc.talk,
316
+ portal: p.portal ?? acc.portal,
317
+ deadlines: p.deadlines ?? acc.deadlines,
318
+ });
319
+
298
320
  /** One file of the chain, `file`, merged over what its parents gave. Pure. */
299
321
  const mergeOne = (
300
322
  acc: Merging,
@@ -308,7 +330,6 @@ const mergeOne = (
308
330
  aliases: [...new Set([...acc.aliases, ...namesOf(p.identity), ...p.aliases])],
309
331
  blind: p.blind ?? acc.blind,
310
332
  mentions: p.mentions ?? acc.mentions,
311
- requiredSections: p.requiredSections ?? acc.requiredSections,
312
333
  tex: p.tex ? mergeRequirements(acc.tex, p.tex) : acc.tex,
313
334
  format: mergeFormat(acc.format, p.format),
314
335
  rules: { ...acc.rules, ...p.rules },
@@ -316,9 +337,7 @@ const mergeOne = (
316
337
  ...acc.ruleOrigins,
317
338
  ...Object.fromEntries(Object.keys(p.rules).map((id) => [id, file])),
318
339
  },
319
- registerAnchors: p.registerAnchors ?? acc.registerAnchors,
320
- talk: p.talk ?? acc.talk,
321
- portal: p.portal ?? acc.portal,
340
+ ...wholeBlocks(acc, p),
322
341
  });
323
342
 
324
343
  /** The chain, root first, merged into one preset. Pure. */
@@ -457,15 +476,29 @@ export function paperPreset(paperDir: string, deps: PresetDeps): PaperPreset {
457
476
  : { kind: "preset-problem", settings, problem: r.error };
458
477
  }
459
478
 
479
+ /** A paper's settings or preset that cannot be used. */
480
+ export type UnusablePreset = Extract<
481
+ PaperPreset,
482
+ { readonly kind: "settings-problem" | "preset-problem" }
483
+ >;
484
+
485
+ /** The one-line reason a paper's settings or preset cannot be used. */
486
+ export const unusablePresetLine = (
487
+ paperDir: string,
488
+ p: UnusablePreset,
489
+ ): string =>
490
+ p.kind === "preset-problem"
491
+ ? `${join(paperDir, CONFIG_FILE)}: ${presetProblemText(p.problem)}`
492
+ : settingsProblemLine(paperDir, p.problem);
493
+
460
494
  /** The one-line reason a paper's settings or preset cannot be used, or null when they can. */
461
495
  export function paperPresetProblem(
462
496
  paperDir: string,
463
497
  p: PaperPreset,
464
498
  ): string | null {
465
- if (p.kind === "preset-problem")
466
- return `${join(paperDir, CONFIG_FILE)}: ${presetProblemText(p.problem)}`;
467
- if (p.kind !== "settings-problem") return null;
468
- return settingsProblemLine(paperDir, p.problem);
499
+ return p.kind === "preset-problem" || p.kind === "settings-problem"
500
+ ? unusablePresetLine(paperDir, p)
501
+ : null;
469
502
  }
470
503
 
471
504
  /** The one line naming a paper's `paperlint.json` that does not parse, and why. */
@@ -316,6 +316,88 @@ const onPdf = (tree: Record<string, string>, line = 1) => ({
316
316
  line,
317
317
  });
318
318
 
319
+ /** One open cycle on the ACM family: what a flat `{ "extends": "paperlint:acm-sigconf" }` says, as a record. */
320
+ const OPEN_CYCLE = JSON.stringify({
321
+ cycles: [
322
+ {
323
+ id: "open-2027",
324
+ venue: { kind: "preset", extends: "paperlint:acm-sigconf" },
325
+ opened: "2026-09-09",
326
+ },
327
+ ],
328
+ });
329
+ /**
330
+ * One open cycle whose registration deadline is after its submission deadline: a date typed wrong.
331
+ * The record's STRUCTURAL problems — two open cycles, a bare date, an unknown key — never reach a
332
+ * rule: `paperRuleBlocks` refuses the settings file before lint runs (measured 2026-10-05: one line
333
+ * naming the file and both ids, exit 2), as it refuses any unreadable `paperlint.json`. The order of
334
+ * the deadlines is what the record parses with and the rule still judges.
335
+ */
336
+ const DISORDERED = JSON.stringify({
337
+ cycles: [
338
+ {
339
+ id: "open-2027",
340
+ venue: { kind: "preset", extends: "paperlint:acm-sigconf" },
341
+ opened: "2026-09-09",
342
+ // The call's own abstract day after its paper day: a date typed wrong. (The portal's and the
343
+ // call's readings disagreeing is not a finding: the earlier binds.)
344
+ deadlines: [
345
+ {
346
+ what: "registration",
347
+ observed: [
348
+ {
349
+ at: "2026-10-25 AoE",
350
+ source: "call",
351
+ url: "https://conf.example/cfp",
352
+ read: "2026-09-09",
353
+ },
354
+ ],
355
+ },
356
+ {
357
+ what: "submission",
358
+ observed: [
359
+ {
360
+ at: "2026-10-20T04:00:00Z",
361
+ source: "portal",
362
+ url: "https://conf.example/deadlines",
363
+ read: "2026-09-09",
364
+ },
365
+ {
366
+ at: "2026-10-23 AoE",
367
+ source: "call",
368
+ url: "https://conf.example/cfp",
369
+ read: "2026-09-09",
370
+ },
371
+ ],
372
+ },
373
+ ],
374
+ },
375
+ ],
376
+ });
377
+ /** One closed cycle whose evidence is `reviews/decision.md`. */
378
+ const CLOSED_CYCLE = JSON.stringify({
379
+ cycles: [
380
+ {
381
+ id: "first-2026",
382
+ venue: { kind: "preset", extends: "paperlint:acm-sigconf" },
383
+ opened: "2026-07-01",
384
+ outcome: {
385
+ kind: "rejected",
386
+ date: "2026-09-08",
387
+ evidence: "reviews/decision.md",
388
+ },
389
+ },
390
+ ],
391
+ });
392
+ /** A BlindConf cycle still declared porting — on a paper whose class line is already BlindConf's. */
393
+ const BLIND_PORTING = {
394
+ id: "blindconf-2027",
395
+ venue: { kind: "preset", extends: "./blindconf.jsonc" },
396
+ opened: "2026-09-09",
397
+ phase: "porting",
398
+ // What `blind()` declares flat; with cycles it moves into the record's siblings, not the cycle.
399
+ };
400
+
319
401
  const CASES: Readonly<Record<string, RuleCases>> = {
320
402
  "paper/stages": {
321
403
  reports: {
@@ -326,6 +408,68 @@ const CASES: Readonly<Record<string, RuleCases>> = {
326
408
  },
327
409
  silent: paper(tex("x")),
328
410
  },
411
+ // ── the cycle rules: the paper's attempts in paperlint.json, judged on PIPELINE-STATUS.md ──────
412
+ "cycle/record": {
413
+ reports: {
414
+ tree: { ...paper(tex("x")), [`${P}/paperlint.json`]: DISORDERED },
415
+ file: STATUS_FILE,
416
+ severity: 2,
417
+ line: 1,
418
+ },
419
+ silent: paper(tex("x")),
420
+ },
421
+ "cycle/evidence": {
422
+ reports: {
423
+ tree: { ...paper(tex("x")), [`${P}/paperlint.json`]: CLOSED_CYCLE },
424
+ file: STATUS_FILE,
425
+ severity: 2,
426
+ line: 1,
427
+ },
428
+ silent: {
429
+ ...paper(tex("x")),
430
+ [`${P}/paperlint.json`]: CLOSED_CYCLE,
431
+ [`${P}/reviews/decision.md`]: "# decision\n",
432
+ },
433
+ },
434
+ "cycle/stage": {
435
+ reports: {
436
+ tree: {
437
+ ...paper(tex("x")),
438
+ [STATUS_FILE]: SHIPPED_STATUS,
439
+ [`${P}/paperlint.json`]: OPEN_CYCLE,
440
+ [`${P}/versions/a.pdf`]: "x",
441
+ },
442
+ file: STATUS_FILE,
443
+ severity: 2,
444
+ line: 1,
445
+ },
446
+ silent: {
447
+ ...paper(tex("x")),
448
+ [STATUS_FILE]: SHIPPED_STATUS.replace(
449
+ "date: 2026-07-22",
450
+ "cycle: open-2027\n date: 2026-07-22",
451
+ ),
452
+ [`${P}/paperlint.json`]: OPEN_CYCLE,
453
+ [`${P}/versions/a.pdf`]: "x",
454
+ },
455
+ },
456
+ "cycle/port-done": {
457
+ reports: {
458
+ tree: {
459
+ ...blind("Text."),
460
+ [`${P}/paperlint.json`]: JSON.stringify({ cycles: [BLIND_PORTING] }),
461
+ },
462
+ file: TEX_FILE,
463
+ severity: 2,
464
+ line: 1,
465
+ },
466
+ silent: {
467
+ ...blind("Text."),
468
+ [`${P}/paperlint.json`]: JSON.stringify({
469
+ cycles: [{ ...BLIND_PORTING, phase: "prepared" }],
470
+ }),
471
+ },
472
+ },
329
473
  "paper/source": {
330
474
  reports: {
331
475
  tree: { ...paper(tex("x")), [STATUS_FILE]: SHIPPED_STATUS },
@@ -22,6 +22,7 @@ import type { SubmissionPortal } from "./ports/submission-portal.ts";
22
22
  import {
23
23
  failureText,
24
24
  runSubmission,
25
+ deadlineLine,
25
26
  submissionTarget,
26
27
  tokenFor,
27
28
  usageProblem,
@@ -41,8 +42,11 @@ const withPresets = (fs: Files): Files => ({
41
42
  readBytes: (p) => fs.readBytes(p) ?? nodeFiles.readBytes(p),
42
43
  });
43
44
 
45
+ /** A venue over aidc's format with a portal of its own — so none of aidc's deadline readings. */
44
46
  const venue = (portal: unknown): string =>
45
- JSON.stringify(venuePreset("venue", { extends: "paperlint:aidc", portal }));
47
+ JSON.stringify(
48
+ venuePreset("venue", { extends: "paperlint:aidc", portal, deadlines: [] }),
49
+ );
46
50
  const files = (o: {
47
51
  readonly portal?: unknown;
48
52
  readonly paper?: Readonly<Record<string, unknown>>;
@@ -476,6 +480,28 @@ test("🔴 the file and key to set: no portal in the preset, no submission in th
476
480
  const noExtends = files({ paper: { submission: { id: 7 } } });
477
481
  const t3 = submissionTarget(PAPER, deps(noExtends));
478
482
  assert.match(!t3.ok ? t3.error : "", /paperlint\.json: no "extends"/);
483
+ // With cycles, `extends` beside them is refused: the message names the cycle, not the key.
484
+ const named = files({
485
+ paper: {
486
+ cycles: [
487
+ {
488
+ id: "secdev-2027",
489
+ venue: {
490
+ kind: "named",
491
+ name: "SecDev 2027",
492
+ url: "https://x.example",
493
+ },
494
+ opened: "2027-01-10",
495
+ submission: { id: 3 },
496
+ },
497
+ ],
498
+ },
499
+ });
500
+ const t5 = submissionTarget(PAPER, deps(named));
501
+ assert.equal(
502
+ !t5.ok ? t5.error : "",
503
+ `${PAPER}/paperlint.json: the current cycle names no venue preset, and the preset is what declares the portal — set the venue in the current cycle ("venue": { "kind": "preset", "extends": "paperlint:…" }), or upload by hand`,
504
+ );
479
505
  const broken = files({
480
506
  paper: { extends: "../../venue.jsonc", submission: { id: "7" } },
481
507
  });
@@ -534,3 +560,81 @@ test("usage: a subcommand is required, one paper folder, update-only flags refus
534
560
  );
535
561
  assert.equal(usageProblem({ ...ARGS, sub: "update", save: true }), null);
536
562
  });
563
+
564
+ test("show prints the current attempt's deadlines in force: the preset's portal readings, the paper's call readings and override over them", () => {
565
+ const fs = memoryFiles({
566
+ "/w/package.json": "{}",
567
+ [`${PAPER}/paperlint.json`]: JSON.stringify({
568
+ cycles: [
569
+ {
570
+ id: "msr-2027",
571
+ venue: { kind: "preset", extends: "paperlint:msr" },
572
+ kind: "technical",
573
+ opened: "2026-10-05",
574
+ submission: { id: 7 },
575
+ deadlines: [
576
+ {
577
+ what: "registration",
578
+ observed: [
579
+ {
580
+ at: "2026-10-20 AoE",
581
+ source: "call",
582
+ url: "https://2027.msrconf.org/cfp",
583
+ read: "2026-10-05",
584
+ },
585
+ ],
586
+ },
587
+ {
588
+ what: "submission",
589
+ observed: [
590
+ {
591
+ at: "2026-10-23 AoE",
592
+ source: "call",
593
+ url: "https://2027.msrconf.org/cfp",
594
+ read: "2026-10-05",
595
+ },
596
+ ],
597
+ },
598
+ {
599
+ what: "resubmission",
600
+ override: {
601
+ at: "2026-10-27T04:00:00Z",
602
+ reason: "the chairs extended updates by mail",
603
+ evidence: "mail/extension.eml",
604
+ },
605
+ },
606
+ ],
607
+ },
608
+ ],
609
+ }),
610
+ });
611
+ const t = submissionTarget(PAPER, {
612
+ files: withPresets(fs),
613
+ venuesDir: presetsDir(),
614
+ });
615
+ assert.ok(t.ok, JSON.stringify(t));
616
+ assert.deepEqual(t.value.deadlines.map(deadlineLine), [
617
+ "deadline registration 2026-10-21T11:59:59Z — call, read 2026-10-05",
618
+ "deadline submission 2026-10-20T04:00:00Z — portal, read 2026-10-05; also call 2026-10-24T11:59:59Z, read 2026-10-05",
619
+ "deadline resubmission 2026-10-27T04:00:00Z — override: the chairs extended updates by mail (mail/extension.eml); also portal 2026-10-23T04:00:00Z, read 2026-10-05",
620
+ ]);
621
+ });
622
+
623
+ test("show, in the flat form: the deadlines in force are the preset's readings alone", () => {
624
+ const flat = memoryFiles({
625
+ "/w/package.json": "{}",
626
+ [`${PAPER}/paperlint.json`]: JSON.stringify({
627
+ extends: "paperlint:msr",
628
+ kind: "technical",
629
+ submission: { id: 7 },
630
+ }),
631
+ });
632
+ const f = submissionTarget(PAPER, {
633
+ files: withPresets(flat),
634
+ venuesDir: presetsDir(),
635
+ });
636
+ assert.deepEqual(f.ok ? f.value.deadlines.map((d) => [d.what, d.at]) : f, [
637
+ ["submission", "2026-10-20T04:00:00Z"],
638
+ ["resubmission", "2026-10-23T04:00:00Z"],
639
+ ]);
640
+ });