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
package/src/submission.ts CHANGED
@@ -30,7 +30,19 @@ import {
30
30
  } from "./domain/submission.ts";
31
31
  import type { Files } from "./ports/files.ts";
32
32
  import type { SubmissionPortal } from "./ports/submission-portal.ts";
33
- import { paperPreset, paperPresetProblem, type PresetDeps } from "./presets.ts";
33
+ import {
34
+ paperPreset,
35
+ unusablePresetLine,
36
+ type PaperPreset,
37
+ type PresetDeps,
38
+ } from "./presets.ts";
39
+ import type { PaperSettings } from "./paper-settings.ts";
40
+ import { currentDeadlines } from "./domain/cycle.ts";
41
+ import {
42
+ deadlinesInForce,
43
+ type InForce,
44
+ type Observation,
45
+ } from "./domain/deadline.ts";
34
46
  import { CONFIG_FILE } from "#lib/paper-config";
35
47
 
36
48
  /** The environment variable each portal kind's token is read from. */
@@ -82,22 +94,29 @@ export interface SubmissionTarget {
82
94
  readonly pdf: string;
83
95
  /** The venue's call for papers (the preset's `url`); null when the preset names no venue. */
84
96
  readonly call: string | null;
97
+ /**
98
+ * The deadlines in force for the current attempt: the paper's own entries (with `cycles`) over the
99
+ * venue preset's readings. Instants and where each came from — never how long is left.
100
+ */
101
+ readonly deadlines: readonly InForce[];
85
102
  }
86
103
 
87
104
  const configOf = (dir: string): string => join(dir, CONFIG_FILE);
88
105
 
89
- /** The paper directory → its portal and submission, or the line naming the file and key to set. */
90
- export function submissionTarget(
106
+ /** Why a paper without a venue preset has no portal: the key to set, or — with cycles — where. */
107
+ const noPresetLine = (
91
108
  paperDir: string,
92
- deps: PresetDeps,
109
+ settings: PaperSettings | null,
110
+ ): string =>
111
+ settings === null || settings.cycles === null
112
+ ? `${configOf(paperDir)}: no "extends" — the venue preset it names declares the portal ("portal": { "kind": …, "url": … })`
113
+ : `${configOf(paperDir)}: 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`;
114
+
115
+ /** A resolved paper → its portal and submission, or the line naming the file and key to set. */
116
+ function targetOf(
117
+ paperDir: string,
118
+ p: Extract<PaperPreset, { readonly kind: "resolved" }>,
93
119
  ): Result<SubmissionTarget, string> {
94
- const p = paperPreset(paperDir, deps);
95
- const problem = paperPresetProblem(paperDir, p);
96
- if (problem !== null) return err(problem);
97
- if (p.kind !== "resolved")
98
- return err(
99
- `${configOf(paperDir)}: no "extends" — the venue preset it names declares the portal ("portal": { "kind": …, "url": … })`,
100
- );
101
120
  const declared = p.preset.portal;
102
121
  if (declared === null)
103
122
  return err(
@@ -113,7 +132,29 @@ export function submissionTarget(
113
132
  const pdf = join(paperDir, p.settings.pdf ?? "paper.pdf");
114
133
  const identity = p.preset.identity;
115
134
  const call = identity.type === "venue" ? identity.url : null;
116
- return ok({ portal: portal.value, id: sub.id, pdf, call });
135
+ const own =
136
+ p.settings.cycles === null
137
+ ? []
138
+ : currentDeadlines(p.settings.cycles.current);
139
+ const deadlines = deadlinesInForce(own, p.preset.deadlines);
140
+ return ok({ portal: portal.value, id: sub.id, pdf, call, deadlines });
141
+ }
142
+
143
+ /** The paper directory → its portal and submission, or the line naming the file and key to set. */
144
+ export function submissionTarget(
145
+ paperDir: string,
146
+ deps: PresetDeps,
147
+ ): Result<SubmissionTarget, string> {
148
+ const p = paperPreset(paperDir, deps);
149
+ switch (p.kind) {
150
+ case "none":
151
+ return err(noPresetLine(paperDir, p.settings));
152
+ case "settings-problem":
153
+ case "preset-problem":
154
+ return err(unusablePresetLine(paperDir, p));
155
+ case "resolved":
156
+ return targetOf(paperDir, p);
157
+ }
117
158
  }
118
159
 
119
160
  /** The token from the environment, or the line naming the variable and where to get one. */
@@ -166,6 +207,25 @@ const pdfLine = (view: SubmissionView): string =>
166
207
  ? "portal PDF none"
167
208
  : `portal PDF ${view.pdf.hash}, ${view.pdf.size === null ? "?" : String(view.pdf.size)} bytes, uploaded ${iso(view.pdf.uploadedAt)}`;
168
209
 
210
+ const readingText = (o: Observation): string =>
211
+ `${o.source} ${o.at}, read ${o.read}`;
212
+
213
+ /** One deadline in force, what set it, and the other readings of it. Pure. */
214
+ export function deadlineLine(d: InForce): string {
215
+ const by = d.by;
216
+ const why =
217
+ by.kind === "override"
218
+ ? `override: ${by.override.reason} (${by.override.evidence})`
219
+ : `${by.reading.source}, read ${by.reading.read}`;
220
+ const others = d.readings.filter(
221
+ (r) => by.kind === "override" || r !== by.reading,
222
+ );
223
+ const also = others.length
224
+ ? `; also ${others.map(readingText).join("; ")}`
225
+ : "";
226
+ return `deadline ${d.what} ${d.at} — ${why}${also}`;
227
+ }
228
+
169
229
  /** `show`'s report. Pure. */
170
230
  export function showLines(
171
231
  t: SubmissionTarget,
@@ -182,6 +242,7 @@ export function showLines(
182
242
  pdfLine(view),
183
243
  `local PDF ${local.shown} ${local.sha256 === null ? "(missing)" : `sha2-${local.sha256}`}`,
184
244
  matchLine(view, local.sha256),
245
+ ...t.deadlines.map(deadlineLine),
185
246
  ...view.messages.map(messageLine),
186
247
  ];
187
248
  }
@@ -35,6 +35,7 @@ import {
35
35
  type VenueTalkJson,
36
36
  } from "./domain/talk.ts";
37
37
  import type { VenuePortal } from "./domain/submission.ts";
38
+ import type { Reading } from "./domain/deadline.ts";
38
39
 
39
40
  /** CTAN package name → the names that prove it is installed. */
40
41
  export type PackageProofs = Readonly<Record<string, readonly string[]>>;
@@ -219,6 +220,8 @@ export interface PresetFile {
219
220
  readonly talk: VenueTalk | null;
220
221
  /** Where the venue takes submissions (`paperlint submission`); null when the file does not say. */
221
222
  readonly portal: VenuePortal | null;
223
+ /** The edition's deadlines as they were read; null when the file names none (a child's list replaces its parent's). */
224
+ readonly deadlines: readonly Reading[] | null;
222
225
  }
223
226
 
224
227
  type KindsJson = Readonly<
@@ -276,6 +279,7 @@ interface PresetBodyJson {
276
279
  };
277
280
  readonly talk?: VenueTalkJson;
278
281
  readonly portal?: VenuePortal;
282
+ readonly deadlines?: readonly Reading[];
279
283
  }
280
284
 
281
285
  /** An optional field as the typed preset holds it: absent is null. */
@@ -394,6 +398,32 @@ const anchorsOf = (
394
398
  const portalOf = (p: PresetJson["portal"]): Readonly<VenuePortal> | null =>
395
399
  p === undefined ? null : { kind: p.kind, url: p.url };
396
400
 
401
+ /**
402
+ * A preset's `deadlines`, or null when it names none. Two readings of one kind from one source are
403
+ * refused: the schema cannot say it, and a paper could not tell which binds.
404
+ */
405
+ function deadlinesOf(
406
+ d: PresetJson["deadlines"],
407
+ file: string,
408
+ ): readonly Reading[] | null {
409
+ if (d === undefined) return null;
410
+ const twice = d.find(
411
+ (r, i) =>
412
+ d.findIndex((x) => x.what === r.what && x.source === r.source) !== i,
413
+ );
414
+ if (twice !== undefined)
415
+ throw new Error(
416
+ `${file}: "deadlines" holds two "${twice.source}" readings of "${twice.what}" — keep one, the latest`,
417
+ );
418
+ return d.map(({ what, at, source, url, read }) => ({
419
+ what,
420
+ at,
421
+ source,
422
+ url,
423
+ read,
424
+ }));
425
+ }
426
+
397
427
  /**
398
428
  * The text of one preset file → the typed file, or an Error naming every problem. The ONE parser
399
429
  * of a preset: the toolchain reads its `tex`, the venue rules its `format`, the config its `rules`.
@@ -429,6 +459,7 @@ export function parsePreset(
429
459
  registerAnchors: anchorsOf(j.register),
430
460
  talk: j.talk === undefined ? null : venueTalkOf(j.talk),
431
461
  portal: portalOf(j.portal),
462
+ deadlines: deadlinesOf(j.deadlines, file),
432
463
  };
433
464
  }
434
465
 
@@ -10,6 +10,7 @@ import { memoryFiles } from "./adapters/memory/index.ts";
10
10
  import { presetsDir } from "./package-dirs.ts";
11
11
  import { latexReader } from "./adapters/latex/index.ts";
12
12
  import {
13
+ cyclePortRules,
13
14
  judgeRequiredSections,
14
15
  judgeTemplate,
15
16
  otherVenues,
@@ -77,6 +78,9 @@ function lint(
77
78
  ...Object.entries(formatRules(deps)).map(
78
79
  ([n, r]) => [`format/${n}`, r] as const,
79
80
  ),
81
+ ...Object.entries(cyclePortRules(deps)).map(
82
+ ([n, r]) => [`cycle/${n}`, r] as const,
83
+ ),
80
84
  ]) {
81
85
  const visitor = rule.create({
82
86
  filename,
@@ -302,6 +306,78 @@ describe("tex/template — the shipped ACM and ACL templates", () => {
302
306
  });
303
307
  });
304
308
 
309
+ const PORTING = {
310
+ id: "aidc-2026",
311
+ venue: { kind: "preset", extends: "paperlint:aidc" },
312
+ kind: "regular",
313
+ opened: "2026-09-26",
314
+ phase: "porting",
315
+ };
316
+
317
+ describe("tex/template — a cycle in `porting`", () => {
318
+ it("the class line is the previous venue's by declaration: silent, where the flat form reports", () => {
319
+ const src = paper("\\documentclass[sigconf]{acmart}");
320
+ expect(lint(src, AIDC).map((f) => f.messageId)).toEqual(["wrongClass"]);
321
+ expect(lint(src, { cycles: [PORTING] })).toEqual([]);
322
+ expect(
323
+ lint(src, { cycles: [{ ...PORTING, phase: "prepared" }] }).map(
324
+ (f) => f.messageId,
325
+ ),
326
+ ).toEqual(["wrongClass"]);
327
+ });
328
+ });
329
+
330
+ describe("cycle/port-done — the declared port is finished", () => {
331
+ it("porting declared and the class line already the venue's: the one finding, naming the cycle", () => {
332
+ const fs = lint(OFFICIAL, { cycles: [PORTING] });
333
+ expect(fs.map((f) => `${f.rule}:${f.messageId}`)).toEqual([
334
+ "cycle/port-done:done",
335
+ ]);
336
+ expect(fs[0]?.message).toMatch(
337
+ /already `\\documentclass\[conference,compsoc\]\{IEEEtran\}`, which AIDC requires, and the cycle «aidc-2026» still declares "phase": "porting"/,
338
+ );
339
+ });
340
+
341
+ it("silent while the class line is still the old venue's, once the phase is dropped, and on the flat form", () => {
342
+ const old = paper("\\documentclass[sigconf]{acmart}");
343
+ expect(lint(old, { cycles: [PORTING] })).toEqual([]);
344
+ expect(
345
+ lint(OFFICIAL, { cycles: [{ ...PORTING, phase: "prepared" }] }),
346
+ ).toEqual([]);
347
+ expect(lint(OFFICIAL, AIDC)).toEqual([]);
348
+ });
349
+
350
+ it("judges the text when the source carries no `raw`", () => {
351
+ expect(
352
+ lint(OFFICIAL, { cycles: [PORTING] }, { raw: false }).map(
353
+ (f) => `${f.rule}:${f.messageId}`,
354
+ ),
355
+ ).toContain("cycle/port-done:done");
356
+ });
357
+
358
+ it("silent when the venue names no template, and when the preset does not resolve", () => {
359
+ const own = `${PAPER}/own.jsonc`;
360
+ const porting = (ext: string) => ({
361
+ cycles: [{ ...PORTING, venue: { kind: "preset", extends: ext } }],
362
+ });
363
+ expect(
364
+ lint(OFFICIAL, porting("./own.jsonc"), {
365
+ extra: {
366
+ [own]: JSON.stringify({
367
+ type: "family",
368
+ tex: { packages: { x: ["x"] } },
369
+ }),
370
+ },
371
+ }).filter((f) => f.rule === "cycle/port-done"),
372
+ ).toEqual([]);
373
+ expect(
374
+ lint(OFFICIAL, porting("./missing.jsonc")).filter(
375
+ (f) => f.rule === "cycle/port-done",
376
+ ),
377
+ ).toEqual([]);
378
+ });
379
+ });
380
+
305
381
  describe("tex/template — a project's own preset, and nothing to judge", () => {
306
382
  it("a preset with no template: nothing to compare, no finding", () => {
307
383
  const own = `${PAPER}/own.jsonc`;
@@ -52,6 +52,7 @@ import type { PaperSource } from "./domain/paper-source.ts";
52
52
  import { readPaper, reportInPaper } from "./tex-paper.ts";
53
53
  import type { RequiredSection } from "./tex-requirements.ts";
54
54
  import type { Finding, VenueRuleDeps } from "./venue-rules.ts";
55
+ import { isPorting } from "./domain/cycle.ts";
55
56
 
56
57
  /** A finding and where it points; null points at the top of the file. */
57
58
  export interface Located extends Finding {
@@ -493,6 +494,10 @@ function readingOf(
493
494
  const dir = dirname(filename);
494
495
  const p = paperPreset(dir, deps);
495
496
  if (p.kind !== "resolved") return null;
497
+ // The port to this venue's template is declared open work: the class line is the previous
498
+ // venue's by declaration, and `cycle/port-done` watches that declaration.
499
+ if (p.settings.cycles !== null && isPorting(p.settings.cycles.current))
500
+ return null;
496
501
  const preset = p.preset;
497
502
  const others = () => otherVenues(preset, deps);
498
503
  const paper = readPaper(filename, src, deps);
@@ -538,3 +543,83 @@ export function formatRules(
538
543
  ): Readonly<Record<"layout-override", TexRuleModule>> {
539
544
  return { "layout-override": rule("layout-override", deps) };
540
545
  }
546
+
547
+ // ── cycle/port-done: the declared port is finished ───────────────────────────────────────
548
+
549
+ /**
550
+ * `cycle/port-done` (error): the current cycle declares `"phase": "porting"`, and the paper's
551
+ * `\documentclass` is already the venue's template. The declaration silences every venue rule
552
+ * (`src/venue-rules.ts`, `readingOf` above); once the class line is the venue's, the port is done
553
+ * and the declaration is stale — this is the other half that keeps `porting` from being a mute
554
+ * button. Silent when the preset names no template: there is nothing to compare, as for `tex/template`.
555
+ */
556
+ export function judgePortDone(
557
+ r: Pick<Reading, "src" | "latex" | "preset">,
558
+ cycleId: string,
559
+ ): readonly Located[] {
560
+ if (r.preset.template === null) return [];
561
+ const mismatch = judgeTemplate(
562
+ r.latex.documentClass(r.src),
563
+ r.preset,
564
+ r.latex,
565
+ );
566
+ return mismatch.length > 0
567
+ ? []
568
+ : [
569
+ {
570
+ messageId: "done",
571
+ data: {
572
+ venue: r.preset.label,
573
+ template: r.preset.template.text,
574
+ id: cycleId,
575
+ },
576
+ at: null,
577
+ },
578
+ ];
579
+ }
580
+
581
+ const PORT_DONE_META: TexRuleModule["meta"] = {
582
+ type: "problem",
583
+ docs: {
584
+ description:
585
+ "the current cycle still says `porting` while the \\documentclass is already the venue's template — the port is done, drop the phase",
586
+ url: "https://github.com/zernie/paperlint/blob/main/docs/rules/cycle/port-done.md",
587
+ },
588
+ schema: [],
589
+ messages: {
590
+ done: 'the \\documentclass is already `{{template}}`, which {{venue}} requires, and the cycle «{{id}}» still declares "phase": "porting" — the port is done: remove the phase, so the venue\'s format rules judge this paper again',
591
+ },
592
+ };
593
+
594
+ /** The `cycle` plugin's rule over the source, for every `paper.tex`. */
595
+ export function cyclePortRules(
596
+ deps: TexVenueRuleDeps,
597
+ ): Readonly<Record<"port-done", TexRuleModule>> {
598
+ return {
599
+ "port-done": {
600
+ meta: PORT_DONE_META,
601
+ create(context) {
602
+ if (basename(context.filename) !== "paper.tex") return {};
603
+ return {
604
+ root() {
605
+ const p = paperPreset(dirname(context.filename), deps);
606
+ if (p.kind !== "resolved") return;
607
+ const current = p.settings.cycles?.current;
608
+ if (current?.kind !== "cycle" || !isPorting(current)) return;
609
+ const sc = context.sourceCode;
610
+ const paper = readPaper(context.filename, sc.raw ?? sc.text, deps);
611
+ const found = judgePortDone(
612
+ { src: paper.text, latex: deps.latex, preset: p.preset },
613
+ current.cycle.id,
614
+ );
615
+ reportInPaper(context, PORT_DONE_META.messages, paper, found);
616
+ },
617
+ };
618
+ },
619
+ },
620
+ };
621
+ }
622
+
623
+ export const CYCLE_PORT_RULE_LEVELS: Readonly<
624
+ Record<"cycle/port-done", "error">
625
+ > = { "cycle/port-done": "error" };
@@ -231,6 +231,36 @@ describe("a paper that names no venue", () => {
231
231
  lint({ venue: DECL, facts: null }, `${PAPER}/PIPELINE-STATUS.md`),
232
232
  ).toEqual([]);
233
233
  });
234
+
235
+ it.each([
236
+ [
237
+ "a named venue",
238
+ {
239
+ id: "secdev-2027",
240
+ venue: { kind: "named", name: "SecDev 2027", url: "https://x.example" },
241
+ opened: "2027-01-10",
242
+ },
243
+ ],
244
+ [
245
+ "the last cycle closed, none open",
246
+ {
247
+ id: "first-2026",
248
+ venue: { kind: "preset", extends: "paperlint:aisec" },
249
+ opened: "2026-07-01",
250
+ outcome: { kind: "withdrawn", date: "2026-07-18", evidence: "n.md" },
251
+ },
252
+ ],
253
+ ])(
254
+ "with `cycles` and %s: the warning says to set the venue IN THE CYCLE — `extends` beside `cycles` is refused",
255
+ (_, cycle) => {
256
+ const fs = lint({ venue: { cycles: [cycle] }, facts: null, pdf: null });
257
+ expect(ids(fs)).toEqual(["pdf/measured:noPresetCycle"]);
258
+ expect(fs[0]?.message).toMatch(
259
+ /^this paper's current cycle names no venue preset, so its page limit, fonts and format are not checked — set the venue in the current cycle in .*paperlint\.json \("venue": \{ "kind": "preset", "extends": "paperlint:…" \}\), or open a new cycle when the last one has ended$/,
260
+ );
261
+ expect(fs[0]?.message).not.toMatch(/set "extends"/);
262
+ },
263
+ );
234
264
  });
235
265
 
236
266
  describe("pdf/profile — the declaration must resolve, or nothing is judged", () => {
@@ -1193,3 +1223,55 @@ describe("judgeBodyEnd, on a venue it does not apply to", () => {
1193
1223
  expect(judgeBodyEnd(NO_TEXT, venue(null))).toEqual([]);
1194
1224
  });
1195
1225
  });
1226
+
1227
+ /** A paper on AIDC through `cycles`: one attempt, open unless `outcome` says otherwise. */
1228
+ const onCycle = (cycle: Record<string, unknown>) => ({
1229
+ identity: ["Ada Example"],
1230
+ cycles: [
1231
+ {
1232
+ id: "aidc-2026",
1233
+ venue: { kind: "preset", extends: "paperlint:aidc" },
1234
+ kind: "regular",
1235
+ opened: "2026-09-26",
1236
+ ...cycle,
1237
+ },
1238
+ ],
1239
+ });
1240
+
1241
+ describe("the cycle decides what applies", () => {
1242
+ it("an open cycle reads as the flat form: the same findings on the same facts", () => {
1243
+ const facts = said((f) => (f.columns = 1));
1244
+ expect(ids(lint({ venue: onCycle({}), facts }))).toEqual(
1245
+ ids(lint({ venue: BLIND, facts })),
1246
+ );
1247
+ expect(ids(lint({ venue: onCycle({}), facts }))).toContain(
1248
+ "pdf/geometry:columns",
1249
+ );
1250
+ });
1251
+
1252
+ it("`phase: porting` — the source is in the previous venue's template by declaration: every venue rule is silent", () => {
1253
+ const facts = said(
1254
+ (f) => ((f.columns = 1), (f.pages_text = ["Ada Example\n"])),
1255
+ );
1256
+ expect(lint({ venue: onCycle({ phase: "porting" }), facts })).toEqual([]);
1257
+ expect(lint({ venue: onCycle({ phase: "porting" }), facts: null })).toEqual(
1258
+ [],
1259
+ );
1260
+ });
1261
+
1262
+ it("an ACCEPTED cycle at a blind venue: the camera-ready carries the authors, anonymity/identity is silent", () => {
1263
+ const facts = said(
1264
+ (f) =>
1265
+ (f.pages_text = ["Ada Example\n", ...bodyPages(11), "References\n"]),
1266
+ );
1267
+ const accepted = {
1268
+ kind: "accepted",
1269
+ date: "2026-10-16",
1270
+ evidence: "reviews/decision.md",
1271
+ };
1272
+ expect(ids(lint({ venue: onCycle({}), facts }))).toContain(
1273
+ "anonymity/identity:leak",
1274
+ );
1275
+ expect(lint({ venue: onCycle({ outcome: accepted }), facts })).toEqual([]);
1276
+ });
1277
+ });
@@ -37,6 +37,8 @@
37
37
  * no geometry (no banal) pdf/measured (warn); geometry and body-size silent, page-limit judges
38
38
  * only a body counted before the references; fonts still judge
39
39
  * kind does not resolve pdf/profile (error); page-limit silent; everything else still judges
40
+ * cycle phase `porting` every rule silent — the source is in the previous venue's template by
41
+ * declaration (src/domain/cycle.ts); cycle/port-done watches the declaration
40
42
  */
41
43
  import { dirname, isAbsolute, join, basename, relative } from "node:path";
42
44
  import {
@@ -65,6 +67,7 @@ import {
65
67
  type PageSplit,
66
68
  } from "./domain/body-pages.ts";
67
69
  import type { TextFacts } from "./facts-file.ts";
70
+ import { effectiveBlind, isPorting } from "./domain/cycle.ts";
68
71
 
69
72
  // ── the verdict's vocabulary ─────────────────────────────────────────────────────────
70
73
 
@@ -96,8 +99,19 @@ export interface Resolved {
96
99
  export type Assessment =
97
100
  | { readonly kind: "no-venue" }
98
101
  /** A `paperlint.json` that extends no preset yet — `pdf/measured` names the file to set. */
99
- | { readonly kind: "no-preset"; readonly file: string }
102
+ | {
103
+ readonly kind: "no-preset";
104
+ readonly file: string;
105
+ /** The paper keeps `cycles`: its venue is the current cycle's, and `extends` beside them is refused. */
106
+ readonly cycles: boolean;
107
+ }
100
108
  | { readonly kind: "unresolved"; readonly finding: Finding }
109
+ /**
110
+ * The current cycle declares its port to the venue's template as open work (`"phase": "porting"`):
111
+ * a PDF built with the previous venue's template says nothing about this venue's limits, so every
112
+ * venue rule is silent, and `cycle/port-done` speaks when the port is done and the phase still says so.
113
+ */
114
+ | { readonly kind: "porting" }
101
115
  | { readonly kind: "unbuilt"; readonly venue: Resolved }
102
116
  | { readonly kind: "stale"; readonly finding: Finding }
103
117
  | {
@@ -147,9 +161,13 @@ function kindOf(
147
161
  /** How the venue reviews, and who the paper says wrote it: what `anonymity/*` judges by. */
148
162
  const reviewOf = (
149
163
  preset: Pick<Preset, "blind">,
150
- settings: Pick<PaperSettings, "identity">,
164
+ settings: Pick<PaperSettings, "identity" | "cycles">,
151
165
  ): Pick<Resolved, "blind" | "identity"> => ({
152
- blind: preset.blind,
166
+ // An accepted attempt's camera-ready carries the authors: no longer blind.
167
+ blind: effectiveBlind(
168
+ preset.blind,
169
+ settings.cycles?.current ?? { kind: "none" },
170
+ ),
153
171
  identity: settings.identity,
154
172
  });
155
173
 
@@ -174,13 +192,25 @@ function freshFacts(
174
192
 
175
193
  const isFinding = (v: object): v is Finding => "messageId" in v;
176
194
 
195
+ /** The paper's declared venue and kind, resolved: what the judges compare the facts against. */
196
+ const resolvedOf = (preset: Preset, settings: PaperSettings): Resolved => ({
197
+ venue: preset.label,
198
+ format: preset.format,
199
+ ...kindOf(preset.label, preset.format, settings.kind),
200
+ ...reviewOf(preset, settings),
201
+ });
202
+
177
203
  /** One paper, assessed. Reads through `deps.files` only; never throws on a paper's files. */
178
204
  export function assessPaper(paperDir: string, deps: VenueRuleDeps): Assessment {
179
205
  const p = paperPreset(paperDir, deps);
180
206
  if (p.kind === "none")
181
207
  return p.settings === null
182
208
  ? { kind: "no-venue" }
183
- : { kind: "no-preset", file: join(paperDir, CONFIG_FILE) };
209
+ : {
210
+ kind: "no-preset",
211
+ file: join(paperDir, CONFIG_FILE),
212
+ cycles: p.settings.cycles !== null,
213
+ };
184
214
  if (p.kind === "settings-problem")
185
215
  return {
186
216
  kind: "unresolved",
@@ -191,13 +221,9 @@ export function assessPaper(paperDir: string, deps: VenueRuleDeps): Assessment {
191
221
  kind: "unresolved",
192
222
  finding: finding("preset", { why: presetProblemText(p.problem) }),
193
223
  };
194
- const label = p.preset.label;
195
- const venue: Resolved = {
196
- venue: label,
197
- format: p.preset.format,
198
- ...kindOf(label, p.preset.format, p.settings.kind),
199
- ...reviewOf(p.preset, p.settings),
200
- };
224
+ if (p.settings.cycles !== null && isPorting(p.settings.cycles.current))
225
+ return { kind: "porting" };
226
+ const venue = resolvedOf(p.preset, p.settings);
201
227
  const factsText = text(deps.files, factsPath(paperDir));
202
228
  if (factsText === null) return { kind: "unbuilt", venue };
203
229
  const facts = freshFacts(deps.files, paperDir, factsText);
@@ -558,7 +584,11 @@ const JUDGES: Readonly<Record<VenueRuleName, Judge>> = {
558
584
  fresh: (a) => (a.kind === "stale" ? [a.finding] : []),
559
585
  measured: (a, { shown }) =>
560
586
  a.kind === "no-preset"
561
- ? [finding("noPreset", { file: shown(a.file) })]
587
+ ? [
588
+ finding(a.cycles ? "noPresetCycle" : "noPreset", {
589
+ file: shown(a.file),
590
+ }),
591
+ ]
562
592
  : a.kind === "unbuilt"
563
593
  ? [
564
594
  finding("unbuilt", {
@@ -655,6 +685,8 @@ const META: Readonly<Record<VenueRuleName, Meta>> = {
655
685
  "not built yet, so `{{venue}}`'s page limit, fonts and format are not checked — run `paperlint build`",
656
686
  noPreset:
657
687
  'this paper names no venue preset yet, so its page limit, fonts and format are not checked — set "extends" in {{file}} (e.g. "paperlint:agenticdev"; see docs/rules.md)',
688
+ noPresetCycle:
689
+ 'this paper\'s current cycle names no venue preset, so its page limit, fonts and format are not checked — set the venue in the current cycle in {{file}} ("venue": { "kind": "preset", "extends": "paperlint:…" }), or open a new cycle when the last one has ended',
658
690
  noGeometry:
659
691
  "the build measured no page geometry (banal was not found or failed), so page size, columns, page limits and font sizes were NOT checked — run `paperlint toolchain`, then `paperlint build`",
660
692
  },